diff --git a/packages/pwa-kit-dev/package-lock.json b/packages/pwa-kit-dev/package-lock.json index e8be12439a..b73df640a4 100644 --- a/packages/pwa-kit-dev/package-lock.json +++ b/packages/pwa-kit-dev/package-lock.json @@ -1813,6 +1813,23 @@ "@sinonjs/commons": "^1.7.0" } }, + "@tanstack/query-core": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.0.10.tgz", + "integrity": "sha512-9LsABpZXkWZHi4P1ozRETEDXQocLAxVzQaIhganxbNuz/uA3PsCAJxJTiQrknG5htLMzOF5MqM9G10e6DCxV1A==", + "dev": true + }, + "@tanstack/react-query": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.0.10.tgz", + "integrity": "sha512-Wn5QhZUE5wvr6rGClV7KeQIUsdTmYR9mgmMZen7DSRWauHW2UTynFg3Kkf6pw+XlxxOLsyLWwz/Q6q1lSpM3TQ==", + "dev": true, + "requires": { + "@tanstack/query-core": "^4.0.0-beta.1", + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.2.0" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1945,6 +1962,12 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "dev": true + }, "@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -10199,6 +10222,12 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "dev": true + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", diff --git a/packages/pwa-kit-dev/package.json b/packages/pwa-kit-dev/package.json index d70846f55c..8f8aa64945 100644 --- a/packages/pwa-kit-dev/package.json +++ b/packages/pwa-kit-dev/package.json @@ -109,6 +109,7 @@ }, "devDependencies": { "@loadable/component": "^5.15.0", + "@tanstack/react-query": "^4.0.10", "internal-lib-build": "^2.3.0-dev", "nock": "^13.1.1", "superagent": "^6.1.0", diff --git a/packages/pwa-kit-dev/src/configs/webpack/config.js b/packages/pwa-kit-dev/src/configs/webpack/config.js index 2c81dd0995..8a9e173774 100644 --- a/packages/pwa-kit-dev/src/configs/webpack/config.js +++ b/packages/pwa-kit-dev/src/configs/webpack/config.js @@ -120,6 +120,7 @@ const baseConfig = (target) => { '@loadable/webpack-plugin': findInProjectThenSDK( '@loadable/webpack-plugin' ), + '@tanstack/react-query': findInProjectThenSDK('@tanstack/react-query'), 'svg-sprite-loader': findInProjectThenSDK('svg-sprite-loader'), react: findInProjectThenSDK('react'), 'react-router-dom': findInProjectThenSDK('react-router-dom'), diff --git a/packages/pwa-kit-react-sdk/CHANGELOG.md b/packages/pwa-kit-react-sdk/CHANGELOG.md index 2faa6177b1..2f64bbaddf 100644 --- a/packages/pwa-kit-react-sdk/CHANGELOG.md +++ b/packages/pwa-kit-react-sdk/CHANGELOG.md @@ -1,3 +1,6 @@ +## To be released +- Integrate `react-query`. [#693](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/693) + ## v2.3.0-dev (Aug 25, 2022) ## v2.2.0 (Aug 25, 2022) ## v2.1.0 (Jul 05, 2022) diff --git a/packages/pwa-kit-react-sdk/package-lock.json b/packages/pwa-kit-react-sdk/package-lock.json index 66858ceff9..885794eaef 100644 --- a/packages/pwa-kit-react-sdk/package-lock.json +++ b/packages/pwa-kit-react-sdk/package-lock.json @@ -96,6 +96,29 @@ "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", "dev": true }, + "@tanstack/query-core": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.2.3.tgz", + "integrity": "sha512-zdt5lYWs1dZaA3IxJbCgtAfHZJScRZONpiLL7YkeOkrme5MfjQqTpjq7LYbzpyuwPOh2Jx68le0PLl57JFv5hQ==", + "dev": true + }, + "@tanstack/react-query": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.2.3.tgz", + "integrity": "sha512-JLaMOxoJTkiAu7QpevRCt2uI/0vd3E8K/rSlCuRgWlcW5DeJDFpDS5kfzmLO5MOcD97fgsJRrDbxDORxR1FdJA==", + "dev": true, + "requires": { + "@tanstack/query-core": "4.2.3", + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.2.0" + } + }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "dev": true + }, "@wojtekmaj/enzyme-adapter-react-17": { "version": "0.6.7", "resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.6.7.tgz", @@ -2454,6 +2477,11 @@ "shallowequal": "^1.0.1" } }, + "react-ssr-prepass": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", + "integrity": "sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==" + }, "react-test-renderer": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", @@ -3376,6 +3404,12 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "dev": true + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/packages/pwa-kit-react-sdk/package.json b/packages/pwa-kit-react-sdk/package.json index e5090c96ae..32710759e1 100644 --- a/packages/pwa-kit-react-sdk/package.json +++ b/packages/pwa-kit-react-sdk/package.json @@ -50,6 +50,7 @@ "minimatch": "3.0.4", "mkdirp": "^1.0.4", "prop-types": "^15.6.0", + "react-ssr-prepass": "^1.5.0", "pwa-kit-runtime": "^2.3.0-dev", "react-uid": "^2.2.0", "serialize-javascript": "^6.0.0", @@ -59,6 +60,7 @@ }, "devDependencies": { "@loadable/component": "^5.15.0", + "@tanstack/react-query": "^4.0.10", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.6", "enzyme": "^3.8.0", "enzyme-adapter-react-16": "1.15.2", @@ -76,9 +78,15 @@ }, "peerDependencies": { "@loadable/component": "^5.15.0", + "@tanstack/react-query": "^4.0.10", "react": ">=16.14 || <18", "react-dom": ">=16.14 || <18", "react-helmet": "6", "react-router-dom": "^5.1.2" + }, + "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + } } } diff --git a/packages/pwa-kit-react-sdk/setup-jest.js b/packages/pwa-kit-react-sdk/setup-jest.js index d23b745814..11d151bc12 100644 --- a/packages/pwa-kit-react-sdk/setup-jest.js +++ b/packages/pwa-kit-react-sdk/setup-jest.js @@ -31,6 +31,7 @@ jest.mock('pwa-kit-runtime/utils/ssr-config', () => { '**/*.json' ], ssrParameters: { + ssrPrepassEnabled: true, ssrFunctionNodeVersion: '14.x', proxyConfigs: [ { diff --git a/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx b/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx index 3c4f9a1a1f..947c86b22f 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx +++ b/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx @@ -8,12 +8,13 @@ import React from 'react' import ReactDOM from 'react-dom' import {BrowserRouter as Router} from 'react-router-dom' -import DeviceContext from '../universal/device-context' +import {DeviceContext, ExpressContext} from '../universal/contexts' import App from '../universal/components/_app' import AppConfig from '../universal/components/_app-config' import Switch from '../universal/components/switch' -import {getRoutes, routeComponent} from '../universal/components/route-component' import {loadableReady} from '@loadable/component' +import {withReactQuery} from '../universal/components' +import {getRoutes} from '../universal/utils' /* istanbul ignore next */ export const registerServiceWorker = (url) => { @@ -54,7 +55,6 @@ export const start = () => { // AppConfig.restore *must* come before getRoutes() AppConfig.restore(locals, window.__PRELOADED_STATE__.__STATE_MANAGEMENT_LIBRARY) - const routes = getRoutes(locals) // We need to tell the routeComponent HOC when the app is hydrating in order to // prevent pages from re-fetching data on the first client-side render. The @@ -66,25 +66,37 @@ export const start = () => { // been warned. window.__HYDRATING__ = true - const WrappedApp = routeComponent(App, false, locals) + // const WrappedApp = routeComponent(App, false, locals) + const WrappedApp = withReactQuery(App) + + // NOTE: It's kinda weird how frozn state is loaded in the JSX here. Would be nice if it was + // "added" via or in, the hoc. + let routes = getRoutes(locals) + + if (WrappedApp.enhanceRoutes) { + routes = WrappedApp.enhanceRoutes(routes) + } + const error = window.__ERROR__ return Promise.resolve() .then(() => new Promise((resolve) => loadableReady(resolve))) .then(() => { ReactDOM.hydrate( - - - - - - - , + + + + + + + + + , rootEl, () => { window.__HYDRATING__ = false 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..a863d65dbe 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 @@ -17,8 +17,9 @@ import {ChunkExtractor} from '@loadable/server' import {StaticRouter as Router, matchPath} from 'react-router-dom' import serialize from 'serialize-javascript' -import {getAssetUrl} from '../universal/utils' +import {getAssetUrl, getRoutes} from '../universal/utils' import DeviceContext from '../universal/device-context' +import {ExpressContext} from '../universal/contexts' import Document from '../universal/components/_document' import App from '../universal/components/_app' @@ -26,7 +27,6 @@ import Throw404 from '../universal/components/throw-404' import AppConfig from '../universal/components/_app-config' import Switch from '../universal/components/switch' -import {getRoutes, routeComponent} from '../universal/components/route-component' import * as errors from '../universal/errors' import {detectDeviceType, isRemote} from 'pwa-kit-runtime/utils/ssr-server' import {proxyConfigs} from 'pwa-kit-runtime/utils/ssr-shared' @@ -34,6 +34,8 @@ import {getConfig} from 'pwa-kit-runtime/utils/ssr-config' import sprite from 'svg-sprite-loader/runtime/sprite.build' +import {withErrorHandling, withLoadableResolver, withReactQuery} from '../universal/components' + const CWD = process.cwd() const BUNDLES_PATH = path.resolve(CWD, 'build/loadable-stats.json') @@ -72,52 +74,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. * @@ -136,8 +92,27 @@ export const render = async (req, res, next) => { // to inject arguments into the wrapped component's getProps methods. AppConfig.restore(res.locals) - const routes = getRoutes(res.locals) - const WrappedApp = routeComponent(App, false, res.locals) + // NOTE: I think the recommened functionality by Oli is to not apply this enchancement if there is an + // existing api applied to the application? I need to get clarification on this. + // NOTE: We shouldn't have to wrap the App with withLoadableResolver since it's required to be in the + // bundle, we can probably clean up the logive for getProps somehow. + const WrappedApp = withReactQuery(App) + const deviceType = detectDeviceType(req) + + WrappedApp.initStaticContext({ + req, + res + }) + + // Get routes and wrap with resolver and error handlers + let routes = getRoutes(res.locals).map(({component, ...rest}) => ({ + component: component ? withErrorHandling(withLoadableResolver(component)) : component, + ...rest + })) + + if (WrappedApp.enhanceRoutes) { + routes = WrappedApp.enhanceRoutes(routes, true, res.locals) + } const [pathname, search] = req.originalUrl.split('?') const location = { @@ -162,30 +137,74 @@ 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, - req, - res, - location - }) + let appState = {} + let routerContext = {} + let error + let routeError + let appStateError + let html + + // Step 3. Bail if there is a 404. + if (component === Throw404) { + routeError = new errors.HTTPNotFound('Not found') + } + + if (!routeError) { + const AppJSX = getAppJSX(req, res, error, { + App: WrappedApp, + appState, + deviceType, + location, + routerContext, + routes + }) + + try { + // Get all the data fetching promises for the various API's attached to the App component. + // This is the react query, and getProps enhancements. + const allPromises = WrappedApp.getDataPromises({ + App, + AppJSX, + location, + req, + res, + deviceType, + route, + routes, + match + }) + const data = await Promise.all(allPromises) + + appState = data.reduce( + (acc, appState = {}) => ({ + ...acc, + ...appState + }), + {} + ) + } catch (e) { + appStateError = logAndFormatError(e || new Error()) + } + } + + // Support the AppConfig freeze API. + appState.__STATE_MANAGEMENT_LIBRARY = AppConfig.freeze(res.locals) // Step 4 - Render the App - let renderResult const args = { App: WrappedApp, appState, - appStateError: appStateError && logAndFormatError(appStateError), + appStateError, + routeError, routes, req, res, location, config } + try { - renderResult = renderApp(args) + ;({html, routerContext, error} = await renderApp(args)) } catch (e) { // This is an unrecoverable error. // (errors handled by the AppErrorBoundary are considered recoverable) @@ -197,7 +216,6 @@ export const render = async (req, res, next) => { // Step 5 - Determine what is going to happen, redirect, or send html with // the correct status code. - const {html, routerContext, error} = renderResult const redirectUrl = routerContext.url const status = (error && error.status) || res.statusCode @@ -208,29 +226,42 @@ 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 getAppJSX = (req, res, error, appData) => { + const {App, appState = {}, deviceType, location, routerContext, routes} = appData + + return ( + + + + + + + + + ) +} - appJSX = extractor.collectChunks(appJSX) +const renderAppHtml = (req, res, error, appData) => { + const {extractor} = appData + const appJSX = extractor.collectChunks(getAppJSX(req, res, error, appData)) return ReactDOMServer.renderToString(appJSX) } -const renderApp = (args) => { - const {req, res, appStateError, App, appState, location, routes, config} = args +const renderApp = async (args) => { + const {req, res, appStateError, routeError, App, appState, location, routes, config} = args const deviceType = detectDeviceType(req) const extractor = new ChunkExtractor({statsFile: BUNDLES_PATH, publicPath: getAssetUrl()}) const routerContext = {} - const appData = {App, appState, location, routes, routerContext, deviceType, extractor} + 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 @@ -266,7 +297,8 @@ const renderApp = (args) => { const helmet = Helmet.renderStatic() // Return the first error encountered during the rendering pipeline. - const error = appStateError || renderError + const error = routeError || appStateError || renderError + // Remove the stacktrace when executing remotely as to not leak any important // information to users about our system. if (error && isRemote()) { @@ -281,6 +313,7 @@ const renderApp = (args) => { // object, client-side, by code in ssr/browser/main.jsx. // // Do *not* add to these without a very good reason - globals are a liability. + const windowGlobals = { __CONFIG__: config, __DEVICE_TYPE__: deviceType, diff --git a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.test.js b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.test.js index 6400201294..44447e2cd9 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.test.js +++ b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.test.js @@ -39,12 +39,23 @@ const mobile = const tablet = 'Mozilla/5.0 (iPad; CPU OS 6_1 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10B141 Safari/8536.25' +jest.mock('../universal/components/_app', () => { + const actual = jest.requireActual('../universal/components/_app') + const {withLegacyGetProps} = jest.requireActual('../universal/components') + + return { + __esModule: true, + default: withLegacyGetProps(actual.default) + } +}) + jest.mock('../universal/routes', () => { const React = require('react') const PropTypes = require('prop-types') const errors = require('../universal/errors') const {Redirect} = require('react-router-dom') const {Helmet} = require('react-helmet') + const {useQuery} = require('@tanstack/react-query') // Test utility to exercise paths that work with @loadable/component. const fakeLoadable = (Wrapped) => { @@ -127,6 +138,7 @@ jest.mock('../universal/routes', () => { class GetPropsRejectsWithEmptyString extends React.Component { static getProps() { + console.log('GETPROPS REJECT') return Promise.reject('') } @@ -228,6 +240,26 @@ jest.mock('../universal/routes', () => { } } + const UseQueryResolvesObject = () => { + const {data, isLoading} = useQuery(['use-query-resolves-object'], async () => ({ + prop: 'prop-value' + })) + return
{isLoading ? 'loading' : data.prop}
+ } + + const DisabledUseQueryIsntResolved = () => { + const {data, isLoading} = useQuery( + ['use-query-resolves-object'], + async () => ({ + prop: 'prop-value' + }), + { + enabled: false + } + ) + return
{isLoading ? 'loading' : data.prop}
+ } + GetPropsReturnsObject.propTypes = { prop: PropTypes.node } @@ -282,6 +314,14 @@ jest.mock('../universal/routes', () => { { path: '/xss/', component: XSSPage + }, + { + path: '/use-query-resolves-object/', + component: UseQueryResolvesObject + }, + { + path: '/disabled-use-query-isnt-resolved/', + component: DisabledUseQueryIsntResolved } ] } @@ -618,6 +658,24 @@ describe('The Node SSR Environment', () => { shouldIncludeErrorStack ? 'Error: ' : 'Internal Server Error' ) } + }, + { + description: `Works if the user resolves an Object with useQuery`, + req: {url: '/use-query-resolves-object/'}, + assertions: (res) => { + expect(res.statusCode).toBe(200) + const html = res.text + expect(html).toEqual(expect.stringContaining('
prop-value
')) + } + }, + { + description: `Disabled useQuery queries aren't run on the server`, + req: {url: '/disabled-use-query-isnt-resolved/'}, + assertions: (res) => { + expect(res.statusCode).toBe(200) + const html = res.text + expect(html).toEqual(expect.stringContaining('
loading
')) + } } ] diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/index.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/index.js new file mode 100644 index 0000000000..af48902056 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/index.js @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2022, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import withReactQuery from './with-react-query' +import withLegacyGetProps from './with-legacy-get-props' +import withLoadableResolver from './with-loadable-resolver' +import withErrorHandling from './with-error-handling' + +export {withErrorHandling, withLegacyGetProps, withLoadableResolver, withReactQuery} diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/switch/index.jsx b/packages/pwa-kit-react-sdk/src/ssr/universal/components/switch/index.jsx index eee989daf3..96a5918085 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/components/switch/index.jsx +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/switch/index.jsx @@ -25,14 +25,14 @@ const Switch = (props) => { {!error && ( - + {routes.map((route, i) => { const {component: Component, ...routeProps} = route return ( - + ) diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-error-handling/index.jsx b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-error-handling/index.jsx new file mode 100644 index 0000000000..b93d2e6206 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-error-handling/index.jsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import hoistNonReactStatic from 'hoist-non-react-statics' +import {AppErrorContext} from '../../components/app-error-boundary' + +/** + * @private + */ +export const withErrorHandling = (Wrapped) => { + /* istanbul ignore next */ + const wrappedComponentName = Wrapped.displayName || Wrapped.name + + const WithErrorHandling = (props) => ( + + {(ctx) => } + + ) + + // Expose statics from the wrapped component on the HOC + hoistNonReactStatic(WithErrorHandling, Wrapped) + + WithErrorHandling.displayName = `withErrorHandling(${wrappedComponentName})` + + return WithErrorHandling +} + +export default withErrorHandling diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/route-component/index.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-legacy-get-props/index.jsx similarity index 69% rename from packages/pwa-kit-react-sdk/src/ssr/universal/components/route-component/index.js rename to packages/pwa-kit-react-sdk/src/ssr/universal/components/with-legacy-get-props/index.jsx index 96f041a9db..a732da2ff1 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/components/route-component/index.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-legacy-get-props/index.jsx @@ -8,11 +8,12 @@ import PropTypes from 'prop-types' import React from 'react' import {withRouter} from 'react-router-dom' import hoistNonReactStatic from 'hoist-non-react-statics' -import {AppErrorContext} from '../../components/app-error-boundary' -import Throw404 from '../../components/throw-404' import AppConfig from '../../components/_app-config' -import routes from '../../routes' import {pages as pageEvents} from '../../events' +import {compose} from '../../utils' + +// const USAGE_WARNING = `This HOC can only be used on your PWA-Kit App component. We cannot guarantee its functionality if used elsewhere.` +const STATE_KEY = '__LEGACY_GET_PROPS__' const noop = () => undefined @@ -28,33 +29,17 @@ const now = () => { : Date.now() } -/** - * @private - */ -const withErrorHandling = (Wrapped) => { - /* istanbul ignore next */ - const wrappedComponentName = Wrapped.displayName || Wrapped.name - - const WithErrorHandling = (props) => ( - - {(ctx) => } - - ) - - // Expose statics from the wrapped component on the HOC - hoistNonReactStatic(WithErrorHandling, Wrapped) - - WithErrorHandling.displayName = `WithErrorHandling(${wrappedComponentName})` - return WithErrorHandling -} - /** * The `routeComponent` HOC is automatically used on every component in a project's * route-config. It provides an interface, via static methods on React components, * that can be used to fetch data on the server and on the client, seamlessly. */ -export const routeComponent = (Wrapped, isPage, locals) => { - const extraArgs = AppConfig.extraGetPropsArgs(locals) +// @private + export const routeComponent = (Wrapped, isPage, locals, extraGetPropsArgsFn) => { + // NOTE: At the point in time when the API is being wrapped by `withLegacyGetProps` the __PRELOADED_STATE__ object doesn't + // exist. Meaning we can't access it. We need to find a solution to this. + let _preloadedProps + let extraArgs /* istanbul ignore next */ const wrappedComponentName = Wrapped.displayName || Wrapped.name @@ -62,11 +47,21 @@ export const routeComponent = (Wrapped, isPage, locals) => { class RouteComponent extends React.Component { constructor(props, context) { super(props, context) + + // Initialize the preloaded state from the internal "global" variable when rendering on server, + // and from the frozen state when rendered on the client. + // Ideally this logic would exist inside the `withLegacyGetProps` HOC, but because we "enhance" + // the routes before we get data, there is no way to pass data down to them. + const preloadedState = + typeof window !== 'undefined' ? + window?.__PRELOADED_STATE__?.[STATE_KEY]?.[`${isPage ? 'page' : 'app'}Props`] + : _preloadedProps + this.state = { childProps: { // When serverside or hydrating, forward props from the frozen app state // to the wrapped component. - ...(isServerSide || isHydrating() ? this.props.preloadedProps : undefined), + ...(isServerSide || isHydrating() ? this.props.preloadedProps || preloadedState : undefined), isLoading: false } } @@ -102,12 +97,18 @@ export const routeComponent = (Wrapped, isPage, locals) => { * @return {Promise} */ static async shouldGetProps(args) { + let component const defaultImpl = () => { const {previousLocation, location} = args return !previousLocation || previousLocation.pathname !== location.pathname } - const component = await RouteComponent.getComponent() + if (Wrapped.getComponent) { + component = await Wrapped.getComponent() + } else { + component = Wrapped + } + return component.shouldGetProps ? component.shouldGetProps(args) : defaultImpl() } @@ -153,37 +154,24 @@ export const routeComponent = (Wrapped, isPage, locals) => { */ // eslint-disable-next-line static getProps(args) { - RouteComponent._latestPropsPromise = RouteComponent.getComponent().then((component) => - component.getProps ? component.getProps({...args, ...extraArgs}) : Promise.resolve() - ) - return RouteComponent._latestPropsPromise - } + RouteComponent._latestPropsPromise = Promise.resolve() + .then(() => { + return Wrapped.getComponent ? Wrapped.getComponent() : Wrapped + }) + .then((component) => { + if (!extraArgs && extraGetPropsArgsFn) { + extraArgs = extraGetPropsArgsFn() + } + + return component.getProps ? component.getProps({...args, ...extraArgs}) : Promise.resolve() + }) + .then((props) => { + _preloadedProps = props - /** - * Get the underlying component this HoC wraps. This handles loading of - * `@loadable/component` components. - * - * @return {Promise} - */ - static async getComponent() { - return Wrapped.load - ? Wrapped.load().then((module) => module.default) - : Promise.resolve(Wrapped) - } + return props + }) - /** - * Route-components implement `getTemplateName()` to return a readable - * name for the component that is used internally for analytics-tracking – - * eg. performance/page-view events. - * - * If not implemented defaults to the `displayName` of the React component. - * - * @return {Promise} - */ - static async getTemplateName() { - return RouteComponent.getComponent().then((c) => - c.getTemplateName ? c.getTemplateName() : Promise.resolve(wrappedComponentName) - ) + return RouteComponent._latestPropsPromise } /** @@ -261,8 +249,7 @@ export const routeComponent = (Wrapped, isPage, locals) => { // // Since the time is overwhelmingly spent fetching data on soft-navs, // we think this is a good approximation in both cases. - - const templateName = await RouteComponent.getTemplateName() + const templateName = await Wrapped.getTemplateName() const start = now() @@ -361,6 +348,7 @@ export const routeComponent = (Wrapped, isPage, locals) => { } } + RouteComponent.displayName = `routeComponent(${wrappedComponentName})` RouteComponent.defaultProps = { @@ -383,28 +371,120 @@ export const routeComponent = (Wrapped, isPage, locals) => { getProps: true, getTemplateName: true } + hoistNonReactStatic(RouteComponent, Wrapped, excludes) - return withErrorHandling(withRouter(RouteComponent)) + return RouteComponent } /** - * Wrap all the components found in the application's route config with the - * route-component HOC so that they all support `getProps` methods server-side - * and client-side in the same way. - * - * @private + * The `routeComponent` HOC is automatically used on every component in a project's + * route-config. It provides an interface, via static methods on React components, + * that can be used to fetch data on the server and on the client, seamlessly. */ -export const getRoutes = (locals) => { - let _routes = routes - if (typeof routes === 'function') { - _routes = routes() +export const withLegacyGetProps = (Wrapped, options) => { + let _staticContext = {} + + // Wrapped = + // compose( + // routeComponent, + // withRouter + // )(Wrapped, false, {}, () => { + // return options.extraGetPropsArgs ? options.extraGetPropsArgs(_staticContext) : {} + // }) // Sketchy + + Wrapped = routeComponent(withRouter(Wrapped), false, {}, () => { + return options.extraGetPropsArgs ? options.extraGetPropsArgs(_staticContext) : {} + }) // Sketchy + + /* istanbul ignore next */ + const wrappedComponentName = Wrapped.displayName || Wrapped.name + + const WrappedComponent = ({...passThroughProps}) => { + return } - const allRoutes = [..._routes, {path: '*', component: Throw404}] - return allRoutes.map(({component, ...rest}) => { - return { - component: component ? routeComponent(component, true, locals) : component, + + /** + * Enhance route components with the `withLegacyGetProps` higher order component. + * + * @param {Object[]} routes + * @param {Boolean} isPage + * @param {Object} locals + * + * @return {Object[]} + */ + WrappedComponent.enhanceRoutes = (routes = [], isPage, locals) => { + routes = Wrapped.enhanceRoutes ? Wrapped.enhanceRoutes(routes) : routes + + return routes.map(({component, ...rest}) => ({ + component: component ? routeComponent(withRouter(component), true, locals) : component, ...rest + })) + } + + /** + * Returns the `getProps` promises for the matched App and Page components, this includes + * any promises returned by the child components `getDataPromises` implementation if one + * exists. + * + * @param {Object} renderContext + * @return {Promise} + */ + WrappedComponent.getDataPromises = (renderContext) => { + const {App, route, match, req, res, location} = renderContext + + const dataPromise = Promise.resolve() + .then(() => { + const {params} = match + const components = [App, route.component] + const promises = components.map((c) => + c.getProps + ? c.getProps({ + req, + res, + params, + location + }) + : Promise.resolve({}) + ) + + return Promise.all(promises) + }) + .then(([appProps, pageProps]) => { + return { + [STATE_KEY]: { + appProps, + pageProps + } + } + }) + + return [ + dataPromise, + ...(Wrapped.getDataPromises ? Wrapped.getDataPromises(renderContext) : []) + ] + } + + // Should be called immediately after wrapping a component with this HOC + // @private + WrappedComponent.initStaticContext = (value) => { + _staticContext = value + + if (Wrapped.initStaticContext) { + Wrapped.initStaticContext(value) } - }) + } + + const excludes = { + enhanceRoutes: true, + getDataPromises: true + } + + hoistNonReactStatic(WrappedComponent, Wrapped, excludes) + + WrappedComponent.displayName = `withLegacyGetProps(${wrappedComponentName})` + + return WrappedComponent } + +export default withLegacyGetProps diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/route-component/index.test.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-legacy-get-props/index.test.js similarity index 90% rename from packages/pwa-kit-react-sdk/src/ssr/universal/components/route-component/index.test.js rename to packages/pwa-kit-react-sdk/src/ssr/universal/components/with-legacy-get-props/index.test.js index 7d2f3566b5..1739b4f7b6 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/components/route-component/index.test.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-legacy-get-props/index.test.js @@ -7,7 +7,8 @@ import React from 'react' import {mount} from 'enzyme' -import {getRoutes, routeComponent} from './index' +import {withLegacyGetProps} from './index' +import {getRoutes} from '../../utils' const delay = (t) => new Promise((resolve) => setTimeout(resolve, t)) @@ -108,18 +109,19 @@ beforeEach(() => { delete global.__HYDRATING__ }) -describe('The routeComponent component', () => { +describe('The withLegacyGetProps component', () => { test('Is a higher-order component', () => { const Mock = getMockComponent() - const Component = routeComponent(Mock) + const Component = withLegacyGetProps(Mock) const wrapper = mount() expect(wrapper.contains(

MockComponent

)).toBe(true) }) test('Should call getProps on components at the right times during updates/rendering', () => { const Mock = getMockComponent() - const Component = routeComponent(Mock) - Component.displayName = 'routeComponent' + const Component = withLegacyGetProps(Mock) + + Component.displayName = 'withLegacyGetProps' expect(Mock.shouldGetProps.mock.calls.length).toBe(0) expect(Mock.getProps.mock.calls.length).toBe(0) let wrapper @@ -165,7 +167,7 @@ describe('The routeComponent component', () => { test('Provides defaults for getProps(), shouldGetProps() and getTemplateName()', () => { const ComponentWithoutStatics = () =>

ComponentWithoutStatics

- const Component = routeComponent(ComponentWithoutStatics) + const Component = withLegacyGetProps(ComponentWithoutStatics) const l1 = {pathname: '/location-one/'} const l2 = {pathname: '/location-two/'} @@ -202,7 +204,7 @@ describe('The routeComponent component', () => { return
Mock
} } - const Component = routeComponent(Mock) + const Component = withLegacyGetProps(Mock) const l1 = {pathname: '/location-one/'} const l2 = {pathname: '/location-two/'} @@ -233,7 +235,7 @@ describe('The routeComponent component', () => { throw error } - const Component = routeComponent(Mock, {}, true) + const Component = withLegacyGetProps(Mock, {}, true) return new Promise((resolve) => mount() @@ -246,7 +248,7 @@ describe('The routeComponent component', () => { Mock.shouldGetProps = trueOnceThenFalse() Mock.getProps = () => delay(10).then(() => Promise.reject(errorText)) - const Component = routeComponent(Mock) + const Component = withLegacyGetProps(Mock) return new Promise((resolve) => mount() @@ -263,7 +265,7 @@ describe('The routeComponent component', () => { Mock.shouldGetProps = trueOnceThenFalse() - const Component = routeComponent(Mock, {}, true) + const Component = withLegacyGetProps(Mock, {}, true) return new Promise((resolve) => { const wrapper = mount( resolve(wrapper)} />) @@ -275,15 +277,21 @@ describe('The routeComponent component', () => { }) }) -describe('getRoutes', () => { - test('wraps components with the routeComponent HOC', () => { - const mappedRoutes = getRoutes() +describe('withLegacyGetProps enhanceRoutes', () => { + test('wraps components with the withLegacyGetProps HOC', () => { + const Mock = getMockComponent() + const Component = withLegacyGetProps(Mock) + const routes = getRoutes() + + let mappedRoutes = Component.enhanceRoutes(routes) expect(mappedRoutes.length).toBe(2) const [first, second] = mappedRoutes - const expectedName = 'WithErrorHandling(withRouter(routeComponent(Component)))' + const expectedName = + 'withErrorHandling(withRouter(withLegacyGetProps(withLoadableResolver(Component))))' expect(first.component.displayName).toBe(expectedName) - const expected404Name = 'WithErrorHandling(withRouter(routeComponent(Throw404)))' + const expected404Name = + 'withErrorHandling(withRouter(withLegacyGetProps(withLoadableResolver(Throw404))))' expect(second.component.displayName).toBe(expected404Name) }) }) @@ -310,7 +318,7 @@ describe('Handles race conditions for getProps', () => { .mockImplementationOnce(() => Promise.resolve({callId: 1})) .mockImplementationOnce(() => Promise.resolve({callId: 2})) - const Component = routeComponent(MockComponent, {}, true) + const Component = withLegacyGetProps(MockComponent, {}, true) let resolver = [] let wrapper @@ -352,7 +360,7 @@ describe('Uses preloaded props on initial clientside page load', () => { const Mock = (props) =>
Mock {JSON.stringify(props)}
Mock.displayName = 'MockComponent' - const Component = routeComponent(Mock, true, {}) + const Component = withLegacyGetProps(Mock, true, {}) const wrapped = await new Promise((resolve) => { const wrapper = mount( @@ -374,7 +382,7 @@ describe('Uses preloaded props on initial clientside page load', () => { const Mock = (props) =>
Mock {JSON.stringify(props)}
Mock.displayName = 'MockComponent' - const Component = routeComponent(Mock, true, {}) + const Component = withLegacyGetProps(Mock, true, {}) const wrapped = await new Promise((resolve) => { const wrapper = mount( diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-loadable-resolver/index.jsx b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-loadable-resolver/index.jsx new file mode 100644 index 0000000000..8d98c19e41 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-loadable-resolver/index.jsx @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import hoistNonReactStatic from 'hoist-non-react-statics' + +/** + * The `routeComponent` HOC is automatically used on every component in a project's + * route-config. It provides an interface, via static methods on React components, + * that can be used to fetch data on the server and on the client, seamlessly. + */ +const withLoadableResolver = (Component, ...rest) => { + /* istanbul ignore next */ + const wrappedComponentName = Component.displayName || Component.name + + if (wrappedComponentName.includes('withLoadableResolver')) { + return Component + } + + const WrappedComponent = ({...passThroughProps}) => { + return + } + + /** + * Get the underlying component this HoC wraps. This handles loading of + * `@loadable/component` components. + * + * @return {Promise} + */ + WrappedComponent.getComponent = async () => { + return Component.load + ? Component.load().then((module) => module.default) + : Promise.resolve(Component) + } + + /** + * Route-components implement `getTemplateName()` to return a readable + * name for the component that is used internally for analytics-tracking – + * eg. performance/page-view events. + * + * If not implemented defaults to the `displayName` of the React component. + * + * @return {Promise} + */ + WrappedComponent.getTemplateName = async () => { + return WrappedComponent.getComponent + ? WrappedComponent.getComponent().then((c) => + c.getTemplateName ? c.getTemplateName() : Promise.resolve(wrappedComponentName) + ) + : Promise.resolve(Component) // BUG? + } + + const excludes = { + getTemplateName: true + } + + hoistNonReactStatic(WrappedComponent, Component, excludes) + + WrappedComponent.displayName = `withLoadableResolver(${wrappedComponentName})` + + return WrappedComponent +} + +export default withLoadableResolver diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-react-query/index.jsx b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-react-query/index.jsx new file mode 100644 index 0000000000..55c5982bf4 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-react-query/index.jsx @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {dehydrate, Hydrate, QueryClient, QueryClientProvider} from '@tanstack/react-query' +import hoistNonReactStatic from 'hoist-non-react-statics' +import ssrPrepass from 'react-ssr-prepass' + +const USAGE_WARNING = `This HOC can only be used on your PWA-Kit App component. We cannot guarantee its functionality if used elsewhere.` +const STATE_KEY = '__REACT_QUERY__' + +/** + * This higher order component will configure your PWA-Kit application with React Query. Uses of + * the `useQuery` hook will also work server-side. + * + * @param {*} Component + * @returns + */ +export const withReactQuery = (Component) => { + const wrappedComponentName = Component.displayName || Component.name + + // NOTE: Is this a reliable way to determine the component type (e.g. will this work in prodution + // when code is minified?) + if (!wrappedComponentName.includes('App')) { + console.warn(USAGE_WARNING) + } + + const queryClient = new QueryClient() + + const WrappedComponent = ({...passThroughProps}) => { + const state = + typeof window === 'undefined' ? {} : window?.__PRELOADED_STATE__?.[STATE_KEY] || {} + + return ( + + + + + + ) + } + + // Expose statics from the wrapped component on the HOC + hoistNonReactStatic(WrappedComponent, Component) + + /** + * Returns an array of primises. The first is a promise that resolved to the query data, the subsequest + * promises are those primises resolving in query data from child components that implement the + * `getDataPromises` function. + * + * @param {Object} renderContext + * + * @return {Promise} + */ + WrappedComponent.getDataPromises = (renderContext) => { + const {AppJSX} = renderContext + + const dataPromise = Promise.resolve() + .then(() => ssrPrepass(AppJSX)) // NOTE: ssrPrepass will be included in the vendor bundle. BAD + .then(() => { + const queryCache = queryClient.getQueryCache() + const queries = queryCache.getAll() + const promises = queries + .filter(({options}) => options.enabled !== false) + .map((query) => query.fetch().catch((error) => { + // NOTE: Our best attempt to create a logical return object without + // getting out of hand. + return { + error, + errorUpdatedAt: Date.now(), + errorUpdateCount: 1, + isError: true, + isLoadingError: true, + isRefetchError: false, + status: 'error' + } + })) + + return Promise.all(promises) + }) + .then(() => ({[STATE_KEY]: dehydrate(queryClient)})) + + return [ + dataPromise, + ...(Component.getDataPromises ? Component.getDataPromises(renderContext) : []) + ] + } + + let _staticContext + // Should be called immediately after wrapping a component with this HOC + // @private + WrappedComponent.initStaticContext = (value) => { + _staticContext = value + + if (Component.initStaticContext) { + Component.initStaticContext(value) + } + } + + WrappedComponent.displayName = `withReactQuery(${wrappedComponentName})` + + return WrappedComponent +} + +export default withReactQuery diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-react-query/index.test.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-react-query/index.test.js new file mode 100644 index 0000000000..1c78530fa1 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/with-react-query/index.test.js @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {mount} from 'enzyme' +// import {withRouter} from './index' +import {getRoutes} from '../../utils' +import {withReactQuery} from './index' +import {useQuery} from '@tanstack/react-query' + +const delay = (t) => new Promise((resolve) => setTimeout(resolve, t)) + +/** + * Return a mock that returns true, false, false, false, which is what + * we want when testing shouldGetProps – always returning true would cause + * an infinite loop. + */ +const trueOnceThenFalse = () => + jest + .fn() + .mockReturnValue(false) + .mockReturnValueOnce(true) + +const falseOnceThenTrue = () => + jest + .fn() + .mockReturnValue(true) + .mockReturnValueOnce(false) + +jest.mock('../_app-config', () => { + const React = require('react') + const PropTypes = require('prop-types') + + const MockAppConfig = () =>

MockAppConfig

+ MockAppConfig.freeze = jest.fn(() => ({frozen: 'frozen'})) + MockAppConfig.restore = jest.fn(() => undefined) + MockAppConfig.extraGetPropsArgs = jest.fn(() => ({anotherArg: 'anotherArg'})) + MockAppConfig.propTypes = { + children: PropTypes.node + } + + return { + __esModule: true, + default: MockAppConfig + } +}) + +jest.mock('../../routes', () => { + const React = require('react') + const PropTypes = require('prop-types') + + const Component = ({children}) => ( +
+

This is the root

+ {children} +
+ ) + + Component.propTypes = { + children: PropTypes.node + } + + return [ + { + path: '', + component: Component, + exact: true + } + ] +}) + +// NOTE: `react-router-dom` is being mocked because I was not able to get around the +// issue where you can't use a `withRoute` HoC outside of a Router component for this +// specific test. TODO: Revisit this, so that we don't have to mock `react-router-dom` +jest.mock('react-router-dom', () => { + const React = require('react') + const hoistNonReactStatic = require('hoist-non-react-statics') + + const withRouter = (Wrapped) => { + const wrappedComponentName = Wrapped.displayName || Wrapped.name + const WithRouter = (props) => + hoistNonReactStatic(WithRouter, Wrapped) + WithRouter.displayName = `withRouter(${wrappedComponentName})` + + return WithRouter + } + + return { + __esModule: true, + default: {}, + withRouter + } +}) + +const getMockComponent = () => { + const MockComponent = () => { + useQuery(['mock-query'], async () => ({})) + return

MockComponent

+ } + MockComponent.displayName = 'MockComponent' + MockComponent.getTemplateName = jest.fn(() => 'MockComponent') + return MockComponent +} + +beforeEach(() => { + delete global.__HYDRATING__ +}) + +describe('The withReactQuery component', () => { + test('Is a higher-order component', () => { + const Mock = getMockComponent() + const Component = withReactQuery(Mock) + const wrapper = mount() + expect(wrapper.contains(

MockComponent

)).toBe(true) + }) + + test('Wraps with QueryClientProvider', () => { + const Mock = getMockComponent() + const Component = withReactQuery(Mock) + + expect(() => { + mount() + }).not.toThrow() + expect(() => { + mount() + }).toThrow() + }) +}) + +describe('withReactQuery enhanceRoutes', () => { + test('does not wrap routes', () => { + const Mock = getMockComponent() + const Component = withReactQuery(Mock) + const routes = getRoutes() + + let mappedRoutes = Component.enhanceRoutes(routes) + expect(mappedRoutes.length).toBe(2) + const [first, second] = mappedRoutes + const expectedName = 'withLoadableResolver(Component)' + expect(first.component.displayName).toBe(expectedName) + + const expected404Name = 'withLoadableResolver(Throw404)' + expect(second.component.displayName).toBe(expected404Name) + }) +}) diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/contexts/index.js b/packages/pwa-kit-react-sdk/src/ssr/universal/contexts/index.js new file mode 100644 index 0000000000..c76c7350f2 --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/contexts/index.js @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import DeviceContext from '../device-context' + +const ExpressContext = React.createContext() + +export {DeviceContext, ExpressContext} diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/hooks/index.js b/packages/pwa-kit-react-sdk/src/ssr/universal/hooks/index.js new file mode 100644 index 0000000000..900a58cdad --- /dev/null +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/hooks/index.js @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +/* istanbul ignore file */ + +import {useContext} from 'react' +import {ExpressContext} from '../contexts' + +export const useExpress = () => { + return useContext(ExpressContext) +} diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js index 1fd8214552..009216c61d 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js @@ -8,6 +8,9 @@ * @module progressive-web-sdk/ssr/universal/utils */ import {proxyConfigs} from 'pwa-kit-runtime/utils/ssr-shared' +import {withLoadableResolver} from './components' +import routes from './routes' +import Throw404 from './components/throw-404' const onClient = typeof window !== 'undefined' @@ -52,3 +55,46 @@ export const getProxyConfigs = () => { // Clone to avoid accidental mutation of important configuration variables. return configs.map((config) => ({...config})) } + +/** + * Wrap all the components found in the application's route config with the + * with-loadable-resolver HoC so the appropriate component will be loaded + * before rendering of the application. + * + * @private + */ +export const getRoutes = (locals) => { + let _routes = routes + if (typeof routes === 'function') { + _routes = routes() + } + const allRoutes = [..._routes, {path: '*', component: Throw404}] + return allRoutes.map(({component, ...rest}) => { + return { + component: component ? withLoadableResolver(component) : component, + ...rest + } + }) +} + +/** + * Utility function to enhance a component with multiple higher-order components, + * without having to nest. + * + * const WrappedComponent = + * compose( + * withHocA, + * withHocB, + * withHocc, + * )(Component) + * + * @param {...any} funcs + * @returns + * + * @private + */ +export const compose = (...funcs) => + funcs.reduce( + (a, b) => (...args) => a(b(...args)), + (arg) => arg + ) diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 7bfd0a8cd7..edfdf7e8ea 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -52,7 +52,11 @@ const AppConfig = ({children, locals = {}}) => { ) } -AppConfig.restore = (locals = {}) => { +AppConfig.restore = (locals = {}) => {} + +AppConfig.freeze = () => undefined + +AppConfig.extraGetPropsArgs = (locals = {}) => { const path = typeof window === 'undefined' ? locals.originalUrl @@ -69,20 +73,11 @@ AppConfig.restore = (locals = {}) => { apiConfig.parameters.siteId = site.id - locals.api = new CommerceAPI({...apiConfig, locale: locale.id, currency}) - locals.buildUrl = createUrlTemplate(appConfig, site.alias || site.id, locale.id) - locals.site = site - locals.locale = locale.id -} - -AppConfig.freeze = () => undefined - -AppConfig.extraGetPropsArgs = (locals = {}) => { return { - api: locals.api, - buildUrl: locals.buildUrl, - site: locals.site, - locale: locals.locale + api: new CommerceAPI({...apiConfig, locale: locale.id, currency}), + buildUrl: createUrlTemplate(appConfig, site.alias || site.id, locale.id), + site, + locale } } diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index 2fdf569943..2f040218a3 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -49,6 +49,8 @@ import Seo from '../seo' import {resolveSiteFromUrl} from '../../utils/site-utils' import useMultiSite from '../../hooks/use-multi-site' +import {withLegacyGetProps} from 'pwa-kit-react-sdk/ssr/universal/components' + const DEFAULT_NAV_DEPTH = 3 const DEFAULT_ROOT_CATEGORY = 'root' @@ -267,8 +269,7 @@ App.shouldGetProps = () => { return typeof window === 'undefined' } -App.getProps = async ({api, res}) => { - const site = resolveSiteFromUrl(res.locals.originalUrl) +App.getProps = async ({api, res, site}) => { const l10nConfig = site.l10n const targetLocale = getTargetLocale({ getUserPreferredLocales: () => { @@ -338,4 +339,4 @@ App.propTypes = { config: PropTypes.object } -export default App +export default withLegacyGetProps(App) diff --git a/packages/template-typescript-minimal/app/components/_app-config/index.tsx b/packages/template-typescript-minimal/app/components/_app-config/index.tsx new file mode 100644 index 0000000000..fcb386f126 --- /dev/null +++ b/packages/template-typescript-minimal/app/components/_app-config/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React, {Fragment, ReactElement, ReactNode} from 'react' +interface AppConfigProps { + children: ReactNode +} + +const AppConfig = (props: AppConfigProps): ReactElement => { + return {props.children} +} + +AppConfig.restore = () => {} +AppConfig.extraGetPropsArgs = () => {} +AppConfig.freeze = () => {} + +export default AppConfig diff --git a/packages/template-typescript-minimal/app/components/_app/index.tsx b/packages/template-typescript-minimal/app/components/_app/index.tsx new file mode 100644 index 0000000000..47391f2473 --- /dev/null +++ b/packages/template-typescript-minimal/app/components/_app/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React, {Fragment, ReactElement, ReactNode} from 'react' +import {withLegacyGetProps} from 'pwa-kit-react-sdk/ssr/universal/components' + +interface AppProps { + children: ReactNode +} + +const App = (props: AppProps): ReactElement => { + return {JSON.stringify(props.greeting)}{props.children} +} + +App.getProps = ({secretMessage}) => { + console.log(`The secret message is... "${secretMessage}"`) + return {greeting: 'Hello from the App component.'} +} + +const extraGetPropsArgs = ({req}) => { + return { + originalUrl: typeof window !== 'undefined' ? window.location.href : req.originalUrl, + secretMessage: 'The brown cow sleeps when the moon is full' + } +} +export default withLegacyGetProps(App, {extraGetPropsArgs}) diff --git a/packages/template-typescript-minimal/app/pages/home.tsx b/packages/template-typescript-minimal/app/pages/home.tsx index 767814add6..9a4381cb56 100644 --- a/packages/template-typescript-minimal/app/pages/home.tsx +++ b/packages/template-typescript-minimal/app/pages/home.tsx @@ -5,10 +5,10 @@ * 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' - interface Props { value: number } @@ -17,7 +17,7 @@ const style = ` body { background: linear-gradient(-45deg, #e73c7e, #23a6d5, #ee7752); background-size: 400% 400%; - animation: gradient 10s ease infinite; + // animation: gradient 10s ease infinite; height: 100vh; } @keyframes gradient { @@ -83,6 +83,21 @@ h1 { const Home = ({value}: Props) => { const [counter, setCounter] = useState(0) + const query = useQuery( + ['my-query'], + async () => { + // Mock network delay. + await new Promise((resolve) => setTimeout(resolve, 80)) + + return { + message: 'react query works!' + } + }, + { + enabled: typeof window === 'undefined' // Only run on the server. + } + ) + useEffect(() => { const interval = setInterval(() => { setCounter(counter + 1) @@ -109,11 +124,15 @@ const Home = ({value}: Props) => { This page is written in Typescript

- Server-side getProps works if this is a valid expression: "5 times 7 is{' '} - {value} + Server-side getProps works if this is a valid expression: "5 + times 7 is {value} "

+ Server-side useQuery works if you see a message: " + {query?.data?.message}" +
+
Client-side JS works if this counter increments: {counter}

diff --git a/packages/template-typescript-minimal/package-lock.json b/packages/template-typescript-minimal/package-lock.json index 0cba3fecbf..0e0135c1c1 100644 --- a/packages/template-typescript-minimal/package-lock.json +++ b/packages/template-typescript-minimal/package-lock.json @@ -24,6 +24,23 @@ "react-is": "^16.12.0" } }, + "@tanstack/query-core": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.2.3.tgz", + "integrity": "sha512-zdt5lYWs1dZaA3IxJbCgtAfHZJScRZONpiLL7YkeOkrme5MfjQqTpjq7LYbzpyuwPOh2Jx68le0PLl57JFv5hQ==", + "dev": true + }, + "@tanstack/react-query": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.2.3.tgz", + "integrity": "sha512-JLaMOxoJTkiAu7QpevRCt2uI/0vd3E8K/rSlCuRgWlcW5DeJDFpDS5kfzmLO5MOcD97fgsJRrDbxDORxR1FdJA==", + "dev": true, + "requires": { + "@tanstack/query-core": "4.2.3", + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.2.0" + } + }, "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", @@ -49,6 +66,12 @@ "@types/react": "*" } }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "dev": true + }, "cross-env": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.1.tgz", @@ -338,6 +361,12 @@ "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", "dev": true }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "dev": true + }, "value-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", diff --git a/packages/template-typescript-minimal/package.json b/packages/template-typescript-minimal/package.json index 4220a4bab0..bec2eed0a6 100644 --- a/packages/template-typescript-minimal/package.json +++ b/packages/template-typescript-minimal/package.json @@ -8,6 +8,7 @@ "private": true, "devDependencies": { "@loadable/component": "^5.15.0", + "@tanstack/react-query": "^4.0.10", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", "cross-env": "^5.2.0", @@ -46,6 +47,7 @@ "**/*.json" ], "ssrParameters": { + "ssrPrepassEnabled": true, "ssrFunctionNodeVersion": "14.x", "proxyConfigs": [ {