From 6f29295566b1e1bc034986d7112ae79cb581b7be Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sun, 19 Sep 2021 15:32:24 -0400 Subject: [PATCH] Implement getServerSnapshot in userspace shim If the DOM is not present, we assume that we are running in a server environment and return the result of `getServerSnapshot`. This heuristic doesn't work in React Native, so we'll need to provide a separate native build (using the `.native` extension). I've left this for a follow-up. We can't call `getServerSnapshot` on the client, because in versions of React before 18, there's no built-in mechanism to detect whether we're hydrating. To avoid a server mismatch warning, users must account for this themselves and return the correct value inside `getSnapshot`. Note that none of this is relevant to the built-in API that is being added in 18. This only affects the userspace shim that is provided for backwards compatibility with versions 16 and 17. --- .../useSyncExternalStoreShared-test.js | 52 ++++++++++ .../useSyncExternalStoreShimServer-test.js | 98 +++++++++++++++++++ .../src/useSyncExternalStore.js | 35 ++++++- scripts/error-codes/codes.json | 3 +- 4 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js index dfcf50ac35946..6952bba46b516 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -688,6 +688,58 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(Scheduler).toHaveYielded(['A1']); expect(container.textContent).toEqual('A1B1'); }); + + test('basic server hydration', async () => { + const store = createExternalStore('client'); + + const ref = React.createRef(); + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server', + ); + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect: ' + text); + }, [text]); + return ( +
+ +
+ ); + } + + const container = document.createElement('div'); + container.innerHTML = '
server
'; + const serverRenderedDiv = container.getElementsByTagName('div')[0]; + + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) { + act(() => { + ReactDOM.hydrateRoot(container, ); + }); + expect(Scheduler).toHaveYielded([ + // First it hydrates the server rendered HTML + 'server', + 'Passive effect: server', + // Then in a second paint, it re-renders with the client state + 'client', + 'Passive effect: client', + ]); + } else { + // In the userspace shim, there's no mechanism to detect whether we're + // currently hydrating, so `getServerSnapshot` is not called on the + // client. To avoid this server mismatch warning, user must account for + // this themselves and return the correct value inside `getSnapshot`. + act(() => { + expect(() => ReactDOM.hydrate(, container)).toErrorDev( + 'Text content did not match', + ); + }); + expect(Scheduler).toHaveYielded(['client', 'Passive effect: client']); + } + expect(container.textContent).toEqual('client'); + expect(ref.current).toEqual(serverRenderedDiv); + }); }); // The selector implementation uses the lazy ref initialization pattern diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js new file mode 100644 index 0000000000000..9fa5b9ce21ebf --- /dev/null +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js @@ -0,0 +1,98 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * + * @jest-environment node + */ + +'use strict'; + +let useSyncExternalStore; +let React; +let ReactDOM; +let ReactDOMServer; +let Scheduler; + +// This tests the userspace shim of `useSyncExternalStore` in a server-rendering +// (Node) environment +describe('useSyncExternalStore (userspace shim, server rendering)', () => { + beforeEach(() => { + jest.resetModules(); + + // Remove useSyncExternalStore from the React imports so that we use the + // shim instead. Also removing startTransition, since we use that to detect + // outdated 18 alphas that don't yet include useSyncExternalStore. + // + // Longer term, we'll probably test this branch using an actual build of + // React 17. + jest.mock('react', () => { + const { + // eslint-disable-next-line no-unused-vars + startTransition: _, + // eslint-disable-next-line no-unused-vars + useSyncExternalStore: __, + // eslint-disable-next-line no-unused-vars + unstable_useSyncExternalStore: ___, + ...otherExports + } = jest.requireActual('react'); + return otherExports; + }); + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); + + useSyncExternalStore = require('use-sync-external-store') + .useSyncExternalStore; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function createExternalStore(initialState) { + const listeners = new Set(); + let currentState = initialState; + return { + set(text) { + currentState = text; + ReactDOM.unstable_batchedUpdates(() => { + listeners.forEach(listener => listener()); + }); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState() { + return currentState; + }, + getSubscriberCount() { + return listeners.size; + }, + }; + } + + test('basic server render', async () => { + const store = createExternalStore('client'); + + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server', + ); + return ; + } + + const html = ReactDOMServer.renderToString(); + expect(Scheduler).toHaveYielded(['server']); + expect(html).toEqual('server'); + }); +}); diff --git a/packages/use-sync-external-store/src/useSyncExternalStore.js b/packages/use-sync-external-store/src/useSyncExternalStore.js index 6d3199d7f2d52..6b30854aea03a 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -9,6 +9,8 @@ import * as React from 'react'; import is from 'shared/objectIs'; +import invariant from 'shared/invariant'; +import {canUseDOM} from 'shared/ExecutionEnvironment'; // Intentionally not using named imports because Rollup uses dynamic // dispatch for CommonJS interop named imports. @@ -21,17 +23,38 @@ const { unstable_useSyncExternalStore: builtInAPI, } = React; +// TODO: This heuristic doesn't work in React Native. We'll need to provide a +// special build, using the `.native` extension. +const isServerEnvironment = !canUseDOM; + // Prefer the built-in API, if it exists. If it doesn't exist, then we assume // we're in version 16 or 17, so rendering is always synchronous. The shim // does not support concurrent rendering, only the built-in API. export const useSyncExternalStore = builtInAPI !== undefined - ? ((builtInAPI: any): typeof useSyncExternalStore_shim) - : useSyncExternalStore_shim; + ? ((builtInAPI: any): typeof useSyncExternalStore_client) + : isServerEnvironment + ? useSyncExternalStore_server + : useSyncExternalStore_client; let didWarnOld18Alpha = false; let didWarnUncachedGetSnapshot = false; +function useSyncExternalStore_server( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, +): T { + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for server-' + + 'rendered content.', + ); + } + return getServerSnapshot(); +} + // Disclaimer: This shim breaks many of the rules of React, and only works // because of a very particular set of implementation details and assumptions // -- change any one of them and it will break. The most important assumption @@ -42,10 +65,13 @@ let didWarnUncachedGetSnapshot = false; // // Do not assume that the clever hacks used by this hook also work in general. // The point of this shim is to replace the need for hacks by other libraries. -function useSyncExternalStore_shim( +function useSyncExternalStore_client( subscribe: (() => void) => () => void, getSnapshot: () => T, - // TODO: Add a canUseDOM check and use this one on the server + // Note: The client shim does not use getServerSnapshot, because pre-18 + // versions of React do not expose a way to check if we're hydrating. So + // users of the shim will need to track that themselves and return the + // correct value from `getSnapshot`. getServerSnapshot?: () => T, ): T { if (__DEV__) { @@ -97,7 +123,6 @@ function useSyncExternalStore_shim( // Track the latest getSnapshot function with a ref. This needs to be updated // in the layout phase so we can access it during the tearing check that // happens on subscribe. - // TODO: Circumvent SSR warning with canUseDOM check useLayoutEffect(() => { inst.value = value; inst.getSnapshot = getSnapshot; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index c3206daa0d339..1a6afe0233ed1 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -395,5 +395,6 @@ "404": "Invalid hook call. Hooks can only be called inside of the body of a function component.", "405": "hydrateRoot(...): Target container is not a DOM element.", "406": "act(...) is not supported in production builds of React.", - "407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering." + "407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.", + "408": "Missing getServerSnapshot, which is required for server-rendered content." }