From 43b1ca0786e0d805ce2b80b9f02592fc071b3f4b Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 20 Aug 2020 11:35:48 -0700 Subject: [PATCH] Add SSRProvider to ensure consistent ids between server and client --- jest.ssr.config.js | 5 +- packages/@react-aria/ssr/README.md | 3 + .../ssr/index.ts} | 20 +---- packages/@react-aria/ssr/package.json | 29 +++++++ packages/@react-aria/ssr/src/SSRProvider.tsx | 79 +++++++++++++++++++ packages/@react-aria/ssr/src/index.ts | 13 +++ packages/@react-aria/utils/package.json | 1 + packages/@react-aria/utils/src/useId.ts | 10 +-- .../menu/test/Menu.ssr.test.js | 2 +- .../@react-spectrum/test-utils/package.json | 1 + .../test-utils/src/hydrateWorker.js | 49 ------------ .../test-utils/src/ssrSetup.js | 5 ++ .../test-utils/src/ssrTeardown.js | 4 + .../test-utils/src/ssrWorker.js | 64 +++++++++++++++ .../@react-spectrum/test-utils/src/testSSR.js | 62 ++++++++++++--- 15 files changed, 257 insertions(+), 90 deletions(-) create mode 100644 packages/@react-aria/ssr/README.md rename packages/{@react-spectrum/test-utils/src/SSREnvironment.js => @react-aria/ssr/index.ts} (55%) create mode 100644 packages/@react-aria/ssr/package.json create mode 100644 packages/@react-aria/ssr/src/SSRProvider.tsx create mode 100644 packages/@react-aria/ssr/src/index.ts delete mode 100644 packages/@react-spectrum/test-utils/src/hydrateWorker.js create mode 100644 packages/@react-spectrum/test-utils/src/ssrSetup.js create mode 100644 packages/@react-spectrum/test-utils/src/ssrTeardown.js create mode 100644 packages/@react-spectrum/test-utils/src/ssrWorker.js diff --git a/jest.ssr.config.js b/jest.ssr.config.js index 5c061e90ed0..44619faeacd 100644 --- a/jest.ssr.config.js +++ b/jest.ssr.config.js @@ -33,8 +33,11 @@ module.exports = { 'packages/' ], + globalSetup: require.resolve('@react-spectrum/test-utils/src/ssrSetup'), + globalTeardown: require.resolve('@react-spectrum/test-utils/src/ssrTeardown'), + // The test environment that will be used for testing - testEnvironment: require.resolve('@react-spectrum/test-utils/src/SSREnvironment'), + testEnvironment: 'jsdom', // The glob patterns Jest uses to detect test files testMatch: [ diff --git a/packages/@react-aria/ssr/README.md b/packages/@react-aria/ssr/README.md new file mode 100644 index 00000000000..13f4ef1df14 --- /dev/null +++ b/packages/@react-aria/ssr/README.md @@ -0,0 +1,3 @@ +# @react-aria/ssr + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-spectrum/test-utils/src/SSREnvironment.js b/packages/@react-aria/ssr/index.ts similarity index 55% rename from packages/@react-spectrum/test-utils/src/SSREnvironment.js rename to packages/@react-aria/ssr/index.ts index eb6dce9b822..1210ae1e402 100644 --- a/packages/@react-spectrum/test-utils/src/SSREnvironment.js +++ b/packages/@react-aria/ssr/index.ts @@ -10,22 +10,4 @@ * governing permissions and limitations under the License. */ -const NodeEnvironment = require('jest-environment-node'); -const {Worker} = require('worker_threads'); - -// Setup a single worker instance that's shared between test environments via a __SSR_WORKER__ global. -let worker = new Worker(__dirname + '/hydrateWorker.js'); -worker.unref(); - -class SSREnvironment extends NodeEnvironment { - async setup() { - await super.setup(); - this.global.__SSR_WORKER__ = worker; - } - - async teardown() { - await super.teardown(); - } -} - -module.exports = SSREnvironment; +export * from './src'; diff --git a/packages/@react-aria/ssr/package.json b/packages/@react-aria/ssr/package.json new file mode 100644 index 00000000000..aeb7c555a1b --- /dev/null +++ b/packages/@react-aria/ssr/package.json @@ -0,0 +1,29 @@ +{ + "name": "@react-aria/ssr", + "version": "3.0.0-alpha.1", + "private": true, + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2" + }, + "peerDependencies": { + "react": "^16.8.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-aria/ssr/src/SSRProvider.tsx b/packages/@react-aria/ssr/src/SSRProvider.tsx new file mode 100644 index 00000000000..51aa5bd0345 --- /dev/null +++ b/packages/@react-aria/ssr/src/SSRProvider.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React, {ReactNode, useContext, useMemo} from 'react'; + +// To support SSR, the auto incrementing id counter is stored in a context. This allows +// it to be reset on every request to ensure the client and server are consistent. +// There is also a prefix counter that is used to support async loading components +// Each async boundary must be wrapped in an SSR provider, which increments the prefix +// and resets the current id counter. This ensures that async loaded components have +// consistent ids regardless of the loading order. +interface SSRContextValue { + prefix: number, + current: number +} + +// Default context value to use in case there is no SSRProvider. This is fine for +// client-only apps. In order to support multiple copies of React Aria potentially +// being on the page at once, the prefix is set to a random number. SSRProvider +// will reset this to zero for consistency between server and client, so in the +// SSR case multiple copies of React Aria is not supported. +const defaultContext: SSRContextValue = { + prefix: Math.round(Math.random() * 10000000000), + current: 0 +}; + +const SSRContext = React.createContext(defaultContext); + +interface SSRProviderProps { + children: ReactNode +} + +/** + * When using SSR with React Aria, applications must be wrapped in an SSRProvider. + * This ensures that auto generated ids are consistent between the client and server + * by resetting the incremented value on each request. + */ +export function SSRProvider(props: SSRProviderProps): JSX.Element { + let cur = useContext(SSRContext); + let value: SSRContextValue = { + // If this is the first SSRProvider, set to zero, otherwise increment. + prefix: cur === defaultContext ? 0 : cur.prefix + 1, + current: 0 + }; + + return ( + + {props.children} + + ); +} + +let canUseDOM = Boolean( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + +/** @private */ +export function useSSRSafeId(defaultId?: string): string { + let ctx = useContext(SSRContext); + + // If we are rendering in a non-DOM environment, and there's no SSRProvider, + // provide a warning to hint to the developer to add one. + if (ctx === defaultContext && !canUseDOM) { + console.warn('When server rendering, you must wrap your application in an to ensure consistent ids are generated between the client and server.'); + } + + return useMemo(() => defaultId || `react-aria-${ctx.prefix}-${++ctx.current}`, [defaultId]); +} diff --git a/packages/@react-aria/ssr/src/index.ts b/packages/@react-aria/ssr/src/index.ts new file mode 100644 index 00000000000..41418afe9b8 --- /dev/null +++ b/packages/@react-aria/ssr/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './SSRProvider'; diff --git a/packages/@react-aria/utils/package.json b/packages/@react-aria/utils/package.json index 97e37886148..23bfeb02178 100644 --- a/packages/@react-aria/utils/package.json +++ b/packages/@react-aria/utils/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@react-aria/ssr": "3.0.0-alpha.1", "@react-types/shared": "^3.2.0", "clsx": "^1.1.1" }, diff --git a/packages/@react-aria/utils/src/useId.ts b/packages/@react-aria/utils/src/useId.ts index 57f2a6a247e..05ac1bab28b 100644 --- a/packages/@react-aria/utils/src/useId.ts +++ b/packages/@react-aria/utils/src/useId.ts @@ -10,22 +10,18 @@ * governing permissions and limitations under the License. */ -import {useLayoutEffect, useMemo, useState} from 'react'; +import {useLayoutEffect, useState} from 'react'; +import {useSSRSafeId} from '@react-aria/ssr'; let map: Map void> = new Map(); -let id = 0; -// don't want to conflict with ids from v2, this will guarantee something unique -// plus we'll know how many instances of this module are loaded on a page if there are more than one number ;) -let randomInstanceNumber = Math.round(Math.random() * 10000000000); - /** * If a default is not provided, generate an id. * @param defaultId - Default component id. */ export function useId(defaultId?: string): string { let [value, setValue] = useState(defaultId); - let res = useMemo(() => value || `react-aria-${randomInstanceNumber}-${++id}`, [value]); + let res = useSSRSafeId(value); map.set(res, setValue); return res; } diff --git a/packages/@react-spectrum/menu/test/Menu.ssr.test.js b/packages/@react-spectrum/menu/test/Menu.ssr.test.js index f67b5bf7c48..44ba16ff5ac 100644 --- a/packages/@react-spectrum/menu/test/Menu.ssr.test.js +++ b/packages/@react-spectrum/menu/test/Menu.ssr.test.js @@ -16,7 +16,7 @@ describe('Menu SSR', function () { it('should render without errors', async function () { await testSSR(__filename, ` import {Menu, Item} from '../'; - + Left Middle Right diff --git a/packages/@react-spectrum/test-utils/package.json b/packages/@react-spectrum/test-utils/package.json index 1138b958731..299d55cd422 100644 --- a/packages/@react-spectrum/test-utils/package.json +++ b/packages/@react-spectrum/test-utils/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@react-aria/ssr": "3.0.0-alpha.1", "resolve": "^1.17.0" }, "peerDependencies": { diff --git a/packages/@react-spectrum/test-utils/src/hydrateWorker.js b/packages/@react-spectrum/test-utils/src/hydrateWorker.js deleted file mode 100644 index 78a1293fd74..00000000000 --- a/packages/@react-spectrum/test-utils/src/hydrateWorker.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const {JSDOM} = require('jsdom'); -const {parentPort} = require('worker_threads'); -const util = require('util'); -require('ignore-styles'); -require('@babel/register')({ - extensions: ['.js', '.ts', '.tsx'] -}); - -let {evaluate} = require('./ssrUtils'); - -let globalNames = new Set(Object.getOwnPropertyNames(global)); - -parentPort.on('message', message => { - // Setup JSDOM environment for the rendered HTML - let dom = new JSDOM(`
${message.html}
`); - - // Copy JSDOM globals into the node global object so React and others can access them. - Object.getOwnPropertyNames(dom.window).forEach(key => { - if (!globalNames.has(key)) { - Object.defineProperty(global, key, Object.getOwnPropertyDescriptor(dom.window, key)); - } - }); - - // Capture React errors/warning and make them fail the tests. - let errors = []; - console.error = console.warn = (...messages) => { - errors.push(util.format(...messages)); - }; - - // Evaluate the code to get a React element, and hydrate into the JSDOM. - let ReactDOM = require('react-dom'); - let container = document.querySelector('#root'); - let element = evaluate(message.source, message.filename); - ReactDOM.hydrate(element, container); - - parentPort.postMessage({errors}); -}); diff --git a/packages/@react-spectrum/test-utils/src/ssrSetup.js b/packages/@react-spectrum/test-utils/src/ssrSetup.js new file mode 100644 index 00000000000..11314829ffe --- /dev/null +++ b/packages/@react-spectrum/test-utils/src/ssrSetup.js @@ -0,0 +1,5 @@ +const {Worker} = require('worker_threads'); + +module.exports = async () => { + global.__SSR_SERVER__ = new Worker(__dirname + '/ssrWorker.js'); +}; diff --git a/packages/@react-spectrum/test-utils/src/ssrTeardown.js b/packages/@react-spectrum/test-utils/src/ssrTeardown.js new file mode 100644 index 00000000000..b706f2ae5b3 --- /dev/null +++ b/packages/@react-spectrum/test-utils/src/ssrTeardown.js @@ -0,0 +1,4 @@ +// teardown.js +module.exports = async function () { + global.__SSR_SERVER__.terminate(); +}; diff --git a/packages/@react-spectrum/test-utils/src/ssrWorker.js b/packages/@react-spectrum/test-utils/src/ssrWorker.js new file mode 100644 index 00000000000..3740eaf1584 --- /dev/null +++ b/packages/@react-spectrum/test-utils/src/ssrWorker.js @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const http = require('http'); +const identityObjectProxy = require('identity-obj-proxy'); +const ignoreStyles = require('ignore-styles'); +const React = require('react'); +const ReactDOMServer = require('react-dom/server'); +const util = require('util'); + +ignoreStyles.default(undefined, (module) => { + module.exports = identityObjectProxy; +}); + +require('@babel/register')({ + extensions: ['.js', '.ts', '.tsx'] +}); + +let {evaluate} = require('./ssrUtils'); +let {SSRProvider} = require('@react-aria/ssr'); + +http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + + req.on('end', () => { + let parsed = JSON.parse(body); + + // Capture React errors/warning and make them fail the tests. + let errors = []; + console.error = console.warn = (...messages) => { + errors.push(util.format(...messages)); + }; + + let html; + try { + // Evaluate the code, and render the resulting JSX element to HTML. + let element = evaluate(parsed.source, parsed.filename); + html = ReactDOMServer.renderToString(React.createElement(SSRProvider, undefined, element)); + } catch (err) { + errors.push(err.stack); + } + + if (errors.length > 0) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({errors})); + } else { + res.setHeader('Content-Type', 'text/html'); + res.end(html); + } + }); +}).listen(18235); diff --git a/packages/@react-spectrum/test-utils/src/testSSR.js b/packages/@react-spectrum/test-utils/src/testSSR.js index d44e3b2b50b..1b0b24a33dc 100644 --- a/packages/@react-spectrum/test-utils/src/testSSR.js +++ b/packages/@react-spectrum/test-utils/src/testSSR.js @@ -13,28 +13,64 @@ // Can't `import` babel, have to require? const babel = require('@babel/core'); import {evaluate} from './ssrUtils'; -import ReactDOMServer from 'react-dom/server'; +import http from 'http'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import {SSRProvider} from '@react-aria/ssr'; +import util from 'util'; export async function testSSR(filename, source) { // Transform the code with babel so JSX becomes JS. source = babel.transformSync(source, {filename}).code; - // Evaluate the code, and render the resulting JSX element to HTML. - let element = evaluate(source, filename); - let html = ReactDOMServer.renderToString(element); - // Send the HTML along with the source code to the worker to be hydrated in a DOM environment. return new Promise((resolve, reject) => { - let worker = global.__SSR_WORKER__; - worker.once('error', reject); - worker.once('message', message => { - if (message.errors.length > 0) { - reject(new Error(message.errors[0])); - } else { - resolve(); + let req = http.request({ + hostname: 'localhost', + port: 18235, + method: 'POST', + headers: { + 'Content-Type': 'application/json' } + }, res => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + let data = JSON.parse(body); + reject(new Error(data.errors[0])); + return; + } + + // Capture React errors/warning and make them fail the tests. + let errors = []; + console.error = console.warn = (...messages) => { + errors.push(util.format(...messages)); + }; + + // Evaluate the code to get a React element, and hydrate into the dom. + try { + document.body.innerHTML = `
${body}
`; + let container = document.querySelector('#root'); + let element = evaluate(source, filename); + ReactDOM.hydrate({element}, container); + } catch (err) { + errors.push(err.stack); + } + + if (errors.length > 0) { + reject(new Error(errors[0])); + } else { + resolve(); + } + }); }); - worker.postMessage({html, source, filename}); + req.write(JSON.stringify({filename, source})); + req.end(); }); }