From fd6d28f920d31d93c5d53655a97ddd13894cd990 Mon Sep 17 00:00:00 2001 From: Oliver Brook Date: Thu, 15 Sep 2022 11:44:08 -0700 Subject: [PATCH 01/18] Add react-query support, with opt-in via an HOC --- .../pwa-kit-dev/src/configs/webpack/config.js | 1 + packages/pwa-kit-react-sdk/package.json | 2 + .../src/ssr/server/react-rendering.js | 136 ++++++++---------- .../components/_app-config/fetchStrategy.js | 25 ++++ .../_app-config/withLegacyGetProps.js | 48 +++++++ .../components/_app-config/withReactQuery.js | 58 ++++++++ .../app/components/_app-config/index.js | 5 + .../app/pages/home.tsx | 16 ++- .../package-lock.json | 22 +++ .../template-typescript-minimal/package.json | 1 + 10 files changed, 234 insertions(+), 80 deletions(-) create mode 100644 packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/fetchStrategy.js create mode 100644 packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withLegacyGetProps.js create mode 100644 packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withReactQuery.js create mode 100644 packages/template-typescript-minimal/app/components/_app-config/index.js diff --git a/packages/pwa-kit-dev/src/configs/webpack/config.js b/packages/pwa-kit-dev/src/configs/webpack/config.js index f99f8450fb..b43cc9ceb8 100644 --- a/packages/pwa-kit-dev/src/configs/webpack/config.js +++ b/packages/pwa-kit-dev/src/configs/webpack/config.js @@ -118,6 +118,7 @@ const baseConfig = (target) => { extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], alias: { 'babel-runtime': findInProjectThenSDK('babel-runtime'), + '@tanstack/react-query': findInProjectThenSDK('@tanstack/react-query'), '@loadable/component': findInProjectThenSDK('@loadable/component'), '@loadable/server': findInProjectThenSDK('@loadable/server'), '@loadable/webpack-plugin': findInProjectThenSDK( diff --git a/packages/pwa-kit-react-sdk/package.json b/packages/pwa-kit-react-sdk/package.json index e5090c96ae..929fe05cab 100644 --- a/packages/pwa-kit-react-sdk/package.json +++ b/packages/pwa-kit-react-sdk/package.json @@ -43,6 +43,7 @@ "@loadable/babel-plugin": "^5.13.2", "@loadable/server": "^5.15.0", "@loadable/webpack-plugin": "^5.15.0", + "@tanstack/react-query": "^4.0.10", "cross-env": "^5.2.0", "event-emitter": "^0.3.5", "glob": "7.1.1", @@ -51,6 +52,7 @@ "mkdirp": "^1.0.4", "prop-types": "^15.6.0", "pwa-kit-runtime": "^2.3.0-dev", + "react-ssr-prepass": "^1.5.0", "react-uid": "^2.2.0", "serialize-javascript": "^6.0.0", "svg-sprite-loader": "^6.0.11", diff --git a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js index 5f3c86f4d5..21979f3f36 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js +++ b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js @@ -72,52 +72,6 @@ const logAndFormatError = (err) => { } } -const initAppState = async ({App, component, match, route, req, res, location}) => { - if (component === Throw404) { - // Don't init if there was no match - return { - error: new errors.HTTPNotFound('Not found'), - appState: {} - } - } - - const {params} = match - - const components = [App, route.component] - const promises = components.map((c) => - c.getProps - ? c.getProps({ - req, - res, - params, - location - }) - : Promise.resolve({}) - ) - let returnVal = {} - - try { - const [appProps, pageProps] = await Promise.all(promises) - const appState = { - appProps, - pageProps, - __STATE_MANAGEMENT_LIBRARY: AppConfig.freeze(res.locals) - } - - returnVal = { - error: undefined, - appState: appState - } - } catch (error) { - returnVal = { - error: error || new Error(), - appState: {} - } - } - - return returnVal -} - /** * This is the main react-rendering function for SSR. It is an Express handler. * @@ -132,8 +86,6 @@ export const render = async (req, res, next) => { // Get the application config which should have been stored at this point. const config = getConfig() - // AppConfig.restore *must* come before using getRoutes() or routeComponent() - // to inject arguments into the wrapped component's getProps methods. AppConfig.restore(res.locals) const routes = getRoutes(res.locals) @@ -162,30 +114,54 @@ export const render = async (req, res, next) => { const component = await route.component.getComponent() // Step 3 - Init the app state - const {appState, error: appStateError} = await initAppState({ - App: WrappedApp, - component, - match, - route, + const deviceType = detectDeviceType(req) + const props = { + error: null, + appState: {}, + routerContext: {}, req, res, - location - }) - - // Step 4 - Render the App - let renderResult - const args = { - App: WrappedApp, - appState, - appStateError: appStateError && logAndFormatError(appStateError), + App, routes, - req, - res, location, - config + deviceType } + let appJSX = + + const {appState, error: appStateError} = + component === Throw404 + ? { + error: new errors.HTTPNotFound('Not found'), + appState: {} + } + : await AppConfig.initAppState({ + App: WrappedApp, + component, + match, + route, + req, + res, + location, + appJSX + }) + + appJSX = React.cloneElement(appJSX, {error: appStateError, appState}) + + // Step 4 - Render the App + let renderResult try { - renderResult = renderApp(args) + renderResult = renderApp({ + App: WrappedApp, + appState, + appStateError: appStateError && logAndFormatError(appStateError), + routes, + req, + res, + location, + config, + appJSX, + deviceType + }) } catch (e) { // This is an unrecoverable error. // (errors handled by the AppErrorBoundary are considered recoverable) @@ -208,10 +184,8 @@ export const render = async (req, res, next) => { } } -const renderAppHtml = (req, res, error, appData) => { - const {App, appState, routes, routerContext, location, extractor, deviceType} = appData - - let appJSX = ( +const OuterApp = ({res, error, App, appState, routes, routerContext, location, deviceType}) => { + return ( @@ -220,33 +194,37 @@ const renderAppHtml = (req, res, error, appData) => { ) - - appJSX = extractor.collectChunks(appJSX) - return ReactDOMServer.renderToString(appJSX) } +const renderToString = (jsx, extractor) => + ReactDOMServer.renderToString(extractor.collectChunks(jsx)) + const renderApp = (args) => { - const {req, res, appStateError, App, appState, location, routes, config} = args - const deviceType = detectDeviceType(req) + const {req, res, appStateError, appJSX, appState, config, deviceType} = args const extractor = new ChunkExtractor({statsFile: BUNDLES_PATH, publicPath: getAssetUrl()}) - const routerContext = {} - const appData = {App, appState, location, routes, routerContext, deviceType, extractor} const ssrOnly = 'mobify_server_only' in req.query || '__server_only' in req.query const prettyPrint = 'mobify_pretty' in req.query || '__pretty_print' in req.query const indent = prettyPrint ? 8 : 0 + let routerContext let appHtml let renderError // It's important that we render the App before extracting the script elements, // otherwise it won't return the correct chunks. + try { - appHtml = renderAppHtml(req, res, appStateError, appData) + routerContext = {} + appHtml = renderToString(React.cloneElement(appJSX, {routerContext}), extractor) } catch (e) { // This will catch errors thrown from the app and pass the error // to the AppErrorBoundary component, and renders the error page. + routerContext = {} renderError = logAndFormatError(e) - appHtml = renderAppHtml(req, res, renderError, appData) + appHtml = renderToString( + React.cloneElement(appJSX, {routerContext, error: renderError}), + extractor + ) } // Setting type: 'application/json' stops the browser from executing the code. diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/fetchStrategy.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/fetchStrategy.js new file mode 100644 index 0000000000..6ad2831736 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/fetchStrategy.js @@ -0,0 +1,25 @@ +import React from 'react' + +export class FetchStrategy extends React.Component { + render() { + return
+ } + + static async initAppState(args) { + try { + const initializers = this.getInitializers() + const promises = initializers.map((fn) => fn(args)) + const results = await Promise.all(promises) + const appState = Object.assign({}, ...results) + return { + error: undefined, + appState: appState + } + } catch (error) { + return { + error: error || new Error(), + appState: {} + } + } + } +} diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withLegacyGetProps.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withLegacyGetProps.js new file mode 100644 index 0000000000..7445e5dd8b --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withLegacyGetProps.js @@ -0,0 +1,48 @@ +import hoistNonReactStatic from 'hoist-non-react-statics' +import AppConfig from '../_app-config/index' +import {FetchStrategy} from '../_app-config/fetchStrategy' +import React from 'react' + +export const withLegacyGetProps = (Wrapped) => { + const wrappedComponentName = Wrapped.displayName || Wrapped.name + + class WithLegacyGetProps extends FetchStrategy { + render() { + return + } + + static async doInitAppState({App, match, route, req, res, location}) { + const {params} = match + + const components = [App, route.component] + const promises = components.map((c) => + c.getProps + ? c.getProps({ + req, + res, + params, + location + }) + : Promise.resolve({}) + ) + + const [appProps, pageProps] = await Promise.all(promises) + return { + appProps, + pageProps, + __STATE_MANAGEMENT_LIBRARY: AppConfig.freeze(res.locals) + } + } + + static getInitializers() { + return [WithLegacyGetProps.doInitAppState, ...(Wrapped.getInitializers?.() ?? [])] + } + } + + WithLegacyGetProps.displayName = `withLegacyGetProps(${wrappedComponentName})` + + const exclude = {doInitAppState: true, getInitializers: true, initAppState: true} + hoistNonReactStatic(WithLegacyGetProps, Wrapped, exclude) + + return WithLegacyGetProps +} diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withReactQuery.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withReactQuery.js new file mode 100644 index 0000000000..b29f882f0c --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/_app-config/withReactQuery.js @@ -0,0 +1,58 @@ +import hoistNonReactStatic from 'hoist-non-react-statics' +import {FetchStrategy} from '../_app-config/fetchStrategy' +import React from 'react' +import {dehydrate, Hydrate, QueryClient, QueryClientProvider} from '@tanstack/react-query' +import ssrPrepass from 'react-ssr-prepass' + +const isServerSide = typeof window === 'undefined' +const STATE_KEY = '__reactQuery' + +export const withReactQuery = (Wrapped) => { + const wrappedComponentName = Wrapped.displayName || Wrapped.name + + class WithReactQuery extends FetchStrategy { + constructor(props) { + super(props) + + // Not crazy about this, but it's *super* important that + // we avoid making queryClient global – it can't be shared + // between requests. + if (!isServerSide && !this.props.locals.__queryClient) { + this.props.locals.__queryClient = new QueryClient() + } + } + + render() { + return ( + + + + + + ) + } + + static async doInitAppState({res, appJSX}) { + const queryClient = (res.locals.__queryClient = new QueryClient()) + + await ssrPrepass(appJSX) + + const queryCache = queryClient.getQueryCache() + const queries = queryCache.getAll().filter((q) => q.options.enabled !== false) + await Promise.all(queries.map((q) => q.fetch())) + + return {[STATE_KEY]: dehydrate(queryClient)} + } + + static getInitializers() { + return [WithReactQuery.doInitAppState, ...(Wrapped.getInitializers?.() ?? [])] + } + } + + WithReactQuery.displayName = `withReactQuery(${wrappedComponentName})` + + const exclude = {doInitAppState: true, getInitializers: true, initAppState: true} + hoistNonReactStatic(WithReactQuery, Wrapped, exclude) + + return WithReactQuery +} diff --git a/packages/template-typescript-minimal/app/components/_app-config/index.js b/packages/template-typescript-minimal/app/components/_app-config/index.js new file mode 100644 index 0000000000..2bc604ac4d --- /dev/null +++ b/packages/template-typescript-minimal/app/components/_app-config/index.js @@ -0,0 +1,5 @@ +import {withLegacyGetProps} from 'pwa-kit-react-sdk/ssr/universal/components/_app-config/withLegacyGetProps' +import {withReactQuery} from 'pwa-kit-react-sdk/ssr/universal/components/_app-config/withReactQuery' +import AppConfig from 'pwa-kit-react-sdk/ssr/universal/components/_app-config' + +export default withReactQuery(withLegacyGetProps(AppConfig)) diff --git a/packages/template-typescript-minimal/app/pages/home.tsx b/packages/template-typescript-minimal/app/pages/home.tsx index 767814add6..0097c0517d 100644 --- a/packages/template-typescript-minimal/app/pages/home.tsx +++ b/packages/template-typescript-minimal/app/pages/home.tsx @@ -5,6 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React, {useEffect, useState} from 'react' +import {useQuery} from '@tanstack/react-query' import HelloTS from '../components/hello-typescript' import HelloJS from '../components/hello-javascript' @@ -90,6 +91,16 @@ const Home = ({value}: Props) => { return () => clearInterval(interval) }, [counter, setCounter]) + const query = useQuery( + ['example-data'], + () => + new Promise((resolve) => { + setTimeout(() => { + resolve('This came from react-query') + }, 1000) + }) + ) + return (