From 86b3e2461da28d9c074b04f42e4ca69773902fec Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 20 Sep 2021 11:31:02 -0400 Subject: [PATCH] Implement useSyncExternalStore on server (#22347) Adds a third argument called `getServerSnapshot`. On the server, React calls this one instead of the normal `getSnapshot`. We also call it during hydration. So it represents the snapshot that is used to generate the initial, server-rendered HTML. The purpose is to avoid server-client mismatches. What we render during hydration needs to match up exactly with what we render on the server. The pattern is for the server to send down a serialized copy of the store that was used to generate the initial HTML. On the client, React will call either `getSnapshot` or `getServerSnapshot` on the client as appropriate, depending on whether it's currently hydrating. The argument is optional for fully client rendered use cases. If the user does attempt to omit `getServerSnapshot`, and the hook is called on the server, React will abort that subtree on the server and revert to client rendering, up to the nearest Suspense boundary. For the userspace shim, we will need to use a heuristic (canUseDOM) to determine whether we are in a server environment. I'll do that in a follow up. --- .../react-debug-tools/src/ReactDebugHooks.js | 1 + .../src/__tests__/ReactDOMFizzServer-test.js | 157 ++++++++++++++++++ .../src/server/ReactPartialRendererHooks.js | 10 +- .../src/ReactFiberHooks.new.js | 97 +++++++---- .../src/ReactFiberHooks.old.js | 97 +++++++---- .../src/ReactInternalTypes.js | 1 + packages/react-server/src/ReactFizzHooks.js | 10 +- packages/react/src/ReactHooks.js | 7 +- .../useSyncExternalStoreShared-test.js | 4 + .../src/useSyncExternalStore.js | 4 +- .../src/useSyncExternalStoreExtra.js | 21 ++- scripts/error-codes/codes.json | 3 +- 12 files changed, 341 insertions(+), 71 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index a57c38ab103c2..cddbb0810f753 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -258,6 +258,7 @@ function useMemo( function useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { // useSyncExternalStore() composes multiple hooks internally. // Advance the current hook index the same number of times diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 6a5ba954bda3f..dfe861142ab79 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -17,6 +17,8 @@ let ReactDOM; let ReactDOMFizzServer; let Suspense; let SuspenseList; +let useSyncExternalStore; +let useSyncExternalStoreExtra; let PropTypes; let textCache; let document; @@ -39,6 +41,9 @@ describe('ReactDOMFizzServer', () => { Stream = require('stream'); Suspense = React.Suspense; SuspenseList = React.SuspenseList; + useSyncExternalStore = React.unstable_useSyncExternalStore; + useSyncExternalStoreExtra = require('use-sync-external-store/extra') + .useSyncExternalStoreExtra; PropTypes = require('prop-types'); textCache = new Map(); @@ -1478,4 +1483,156 @@ describe('ReactDOMFizzServer', () => { // We should've been able to display the content without waiting for the rest of the fallback. expect(getVisibleChildren(container)).toEqual(
Hello
); }); + + // @gate supportsNativeUseSyncExternalStore + // @gate experimental + it('calls getServerSnapshot instead of getSnapshot', async () => { + const ref = React.createRef(); + + function getServerSnapshot() { + return 'server'; + } + + function getClientSnapshot() { + return 'client'; + } + + function subscribe() { + return () => {}; + } + + function Child({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function App() { + const value = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + return ( +
+ +
+ ); + } + + const loggedErrors = []; + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + + + , + writable, + { + onError(x) { + loggedErrors.push(x); + }, + }, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['server']); + + const serverRenderedDiv = container.getElementsByTagName('div')[0]; + + ReactDOM.hydrateRoot(container, ); + + // The first paint uses the server snapshot + expect(Scheduler).toFlushUntilNextPaint(['server']); + expect(getVisibleChildren(container)).toEqual(
server
); + // Hydration succeeded + expect(ref.current).toEqual(serverRenderedDiv); + + // Asynchronously we detect that the store has changed on the client, + // and patch up the inconsistency + expect(Scheduler).toFlushUntilNextPaint(['client']); + expect(getVisibleChildren(container)).toEqual(
client
); + expect(ref.current).toEqual(serverRenderedDiv); + }); + + // The selector implementation uses the lazy ref initialization pattern + // @gate !(enableUseRefAccessWarning && __DEV__) + // @gate supportsNativeUseSyncExternalStore + // @gate experimental + it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => { + // Same as previous test, but with a selector that returns a complex object + // that is memoized with a custom `isEqual` function. + const ref = React.createRef(); + + function getServerSnapshot() { + return {env: 'server', other: 'unrelated'}; + } + + function getClientSnapshot() { + return {env: 'client', other: 'unrelated'}; + } + + function selector({env}) { + return {env}; + } + + function isEqual(a, b) { + return a.env === b.env; + } + + function subscribe() { + return () => {}; + } + + function Child({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function App() { + const {env} = useSyncExternalStoreExtra( + subscribe, + getClientSnapshot, + getServerSnapshot, + selector, + isEqual, + ); + return ( +
+ +
+ ); + } + + const loggedErrors = []; + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + + + , + writable, + { + onError(x) { + loggedErrors.push(x); + }, + }, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['server']); + + const serverRenderedDiv = container.getElementsByTagName('div')[0]; + + ReactDOM.hydrateRoot(container, ); + + // The first paint uses the server snapshot + expect(Scheduler).toFlushUntilNextPaint(['server']); + expect(getVisibleChildren(container)).toEqual(
server
); + // Hydration succeeded + expect(ref.current).toEqual(serverRenderedDiv); + + // Asynchronously we detect that the store has changed on the client, + // and patch up the inconsistency + expect(Scheduler).toFlushUntilNextPaint(['client']); + expect(getVisibleChildren(container)).toEqual(
client
); + expect(ref.current).toEqual(serverRenderedDiv); + }); }); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 175a394a4dbf0..a57e6952a3fd9 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -464,8 +464,16 @@ export function useCallback( function useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { - throw new Error('Not yet implemented'); + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for ' + + 'server-rendered content. Will revert to client rendering.', + ); + } + return getServerSnapshot(); } function useDeferredValue(value: T): T { diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 5f1c30a834c15..f797d7d5812e6 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -938,23 +938,64 @@ function rerenderReducer( function mountSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { const fiber = currentlyRenderingFiber; const hook = mountWorkInProgressHook(); - // Read the current snapshot from the store on every render. This breaks the - // normal rules of React, and only works because store updates are - // always synchronous. - const nextSnapshot = getSnapshot(); - if (__DEV__) { - if (!didWarnUncachedGetSnapshot) { - if (nextSnapshot !== getSnapshot()) { - console.error( - 'The result of getSnapshot should be cached to avoid an infinite loop', - ); - didWarnUncachedGetSnapshot = true; + + let nextSnapshot; + const isHydrating = getIsHydrating(); + if (isHydrating) { + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for ' + + 'server-rendered content. Will revert to client rendering.', + ); + } + nextSnapshot = getServerSnapshot(); + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + if (nextSnapshot !== getServerSnapshot()) { + console.error( + 'The result of getServerSnapshot should be cached to avoid an infinite loop', + ); + didWarnUncachedGetSnapshot = true; + } } } + } else { + nextSnapshot = getSnapshot(); + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + if (nextSnapshot !== getSnapshot()) { + console.error( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + didWarnUncachedGetSnapshot = true; + } + } + } + // Unless we're rendering a blocking lane, schedule a consistency check. + // Right before committing, we will walk the tree and check if any of the + // stores were mutated. + // + // We won't do this if we're hydrating server-rendered content, because if + // the content is stale, it's already visible anyway. Instead we'll patch + // it up in a passive effect. + const root: FiberRoot | null = getWorkInProgressRoot(); + invariant( + root !== null, + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', + ); + if (!includesBlockingLane(root, renderLanes)) { + pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); + } } + + // Read the current snapshot from the store on every render. This breaks the + // normal rules of React, and only works because store updates are + // always synchronous. hook.memoizedState = nextSnapshot; const inst: StoreInstance = { value: nextSnapshot, @@ -980,24 +1021,13 @@ function mountSyncExternalStore( null, ); - // Unless we're rendering a blocking lane, schedule a consistency check. Right - // before committing, we will walk the tree and check if any of the stores - // were mutated. - const root: FiberRoot | null = getWorkInProgressRoot(); - invariant( - root !== null, - 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', - ); - if (!includesBlockingLane(root, renderLanes)) { - pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); - } - return nextSnapshot; } function updateSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { const fiber = currentlyRenderingFiber; const hook = updateWorkInProgressHook(); @@ -2235,10 +2265,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; mountHookTypesDev(); - return mountSyncExternalStore(subscribe, getSnapshot); + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2366,10 +2397,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; updateHookTypesDev(); - return mountSyncExternalStore(subscribe, getSnapshot); + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2497,10 +2529,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2629,10 +2662,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2774,11 +2808,12 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; warnInvalidHookAccess(); mountHookTypesDev(); - return mountSyncExternalStore(subscribe, getSnapshot); + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2921,11 +2956,12 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -3069,11 +3105,12 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 016b208d30d90..4d93786f6b798 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -938,23 +938,64 @@ function rerenderReducer( function mountSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { const fiber = currentlyRenderingFiber; const hook = mountWorkInProgressHook(); - // Read the current snapshot from the store on every render. This breaks the - // normal rules of React, and only works because store updates are - // always synchronous. - const nextSnapshot = getSnapshot(); - if (__DEV__) { - if (!didWarnUncachedGetSnapshot) { - if (nextSnapshot !== getSnapshot()) { - console.error( - 'The result of getSnapshot should be cached to avoid an infinite loop', - ); - didWarnUncachedGetSnapshot = true; + + let nextSnapshot; + const isHydrating = getIsHydrating(); + if (isHydrating) { + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for ' + + 'server-rendered content. Will revert to client rendering.', + ); + } + nextSnapshot = getServerSnapshot(); + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + if (nextSnapshot !== getServerSnapshot()) { + console.error( + 'The result of getServerSnapshot should be cached to avoid an infinite loop', + ); + didWarnUncachedGetSnapshot = true; + } } } + } else { + nextSnapshot = getSnapshot(); + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + if (nextSnapshot !== getSnapshot()) { + console.error( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + didWarnUncachedGetSnapshot = true; + } + } + } + // Unless we're rendering a blocking lane, schedule a consistency check. + // Right before committing, we will walk the tree and check if any of the + // stores were mutated. + // + // We won't do this if we're hydrating server-rendered content, because if + // the content is stale, it's already visible anyway. Instead we'll patch + // it up in a passive effect. + const root: FiberRoot | null = getWorkInProgressRoot(); + invariant( + root !== null, + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', + ); + if (!includesBlockingLane(root, renderLanes)) { + pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); + } } + + // Read the current snapshot from the store on every render. This breaks the + // normal rules of React, and only works because store updates are + // always synchronous. hook.memoizedState = nextSnapshot; const inst: StoreInstance = { value: nextSnapshot, @@ -980,24 +1021,13 @@ function mountSyncExternalStore( null, ); - // Unless we're rendering a blocking lane, schedule a consistency check. Right - // before committing, we will walk the tree and check if any of the stores - // were mutated. - const root: FiberRoot | null = getWorkInProgressRoot(); - invariant( - root !== null, - 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', - ); - if (!includesBlockingLane(root, renderLanes)) { - pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); - } - return nextSnapshot; } function updateSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { const fiber = currentlyRenderingFiber; const hook = updateWorkInProgressHook(); @@ -2235,10 +2265,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; mountHookTypesDev(); - return mountSyncExternalStore(subscribe, getSnapshot); + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2366,10 +2397,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; updateHookTypesDev(); - return mountSyncExternalStore(subscribe, getSnapshot); + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2497,10 +2529,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2629,10 +2662,11 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2774,11 +2808,12 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; warnInvalidHookAccess(); mountHookTypesDev(); - return mountSyncExternalStore(subscribe, getSnapshot); + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -2921,11 +2956,12 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; @@ -3069,11 +3105,12 @@ if (__DEV__) { useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { currentHookNameInDev = 'useSyncExternalStore'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateSyncExternalStore(subscribe, getSnapshot); + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 692ec27bd7e80..bf7ffb2b33d17 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -294,6 +294,7 @@ export type Dispatcher = {| useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T, useOpaqueIdentifier(): any, useCacheRefresh?: () => (?() => T, ?T) => void, diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index c272ddbbb9ad2..9976b5be90c1a 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -447,8 +447,16 @@ export function useCallback( function useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { - throw new Error('Not yet implemented'); + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for ' + + 'server-rendered content. Will revert to client rendering.', + ); + } + return getServerSnapshot(); } function useDeferredValue(value: T): T { diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 77dbd53c9ce19..e981907baed55 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -166,9 +166,14 @@ export function useOpaqueIdentifier(): OpaqueIDType | void { export function useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, + getServerSnapshot?: () => T, ): T { const dispatcher = resolveDispatcher(); - return dispatcher.useSyncExternalStore(subscribe, getSnapshot); + return dispatcher.useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); } export function useCacheRefresh(): (?() => T, ?T) => void { 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 cb1bd3b1c8c73..93dd24c838231 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -588,6 +588,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { const a = useSyncExternalStoreExtra( store.subscribe, store.getState, + null, selector, ); return ; @@ -623,6 +624,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { const {a} = useSyncExternalStoreExtra( store.subscribe, store.getState, + null, state => ({a: state.a}), (state1, state2) => state1.a === state2.a, ); @@ -632,6 +634,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { const {b} = useSyncExternalStoreExtra( store.subscribe, store.getState, + null, state => { return {b: state.b}; }, @@ -710,6 +713,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { const items = useSyncExternalStoreExtra( store.subscribe, store.getState, + null, inlineSelector, shallowEqualArray, ); diff --git a/packages/use-sync-external-store/src/useSyncExternalStore.js b/packages/use-sync-external-store/src/useSyncExternalStore.js index 7ad5f3ebafc2f..6d3199d7f2d52 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -45,6 +45,8 @@ let didWarnUncachedGetSnapshot = false; function useSyncExternalStore_shim( subscribe: (() => void) => () => void, getSnapshot: () => T, + // TODO: Add a canUseDOM check and use this one on the server + getServerSnapshot?: () => T, ): T { if (__DEV__) { if (!didWarnOld18Alpha) { @@ -95,7 +97,7 @@ 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 + // TODO: Circumvent SSR warning with canUseDOM check useLayoutEffect(() => { inst.value = value; inst.getSnapshot = getSnapshot; diff --git a/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js b/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js index eeff987ae8f1d..f4a1885aec5b9 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js +++ b/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js @@ -19,6 +19,7 @@ const {useRef, useEffect, useMemo, useDebugValue} = React; export function useSyncExternalStoreExtra( subscribe: (() => void) => () => void, getSnapshot: () => Snapshot, + getServerSnapshot: void | null | (() => Snapshot), selector: (snapshot: Snapshot) => Selection, isEqual?: (a: Selection, b: Selection) => boolean, ): Selection { @@ -35,7 +36,7 @@ export function useSyncExternalStoreExtra( inst = instRef.current; } - const getSnapshotWithMemoizedSelector = useMemo(() => { + const [getSelection, getServerSelection] = useMemo(() => { // Track the memoized state using closure variables that are local to this // memoized instance of a getSnapshot function. Intentionally not using a // useRef hook, because that state would be shared across all concurrent @@ -43,9 +44,7 @@ export function useSyncExternalStoreExtra( let hasMemo = false; let memoizedSnapshot; let memoizedSelection; - return () => { - const nextSnapshot = getSnapshot(); - + const memoizedSelector = nextSnapshot => { if (!hasMemo) { // The first time the hook is called, there is no memoized result. hasMemo = true; @@ -91,11 +90,21 @@ export function useSyncExternalStoreExtra( memoizedSelection = nextSelection; return nextSelection; }; - }, [getSnapshot, selector, isEqual]); + // Assigning this to a constant so that Flow knows it can't change. + const maybeGetServerSnapshot = + getServerSnapshot === undefined ? null : getServerSnapshot; + const getSnapshotWithSelector = () => memoizedSelector(getSnapshot()); + const getServerSnapshotWithSelector = + maybeGetServerSnapshot === null + ? undefined + : () => memoizedSelector(maybeGetServerSnapshot()); + return [getSnapshotWithSelector, getServerSnapshotWithSelector]; + }, [getSnapshot, getServerSnapshot, selector, isEqual]); const value = useSyncExternalStore( subscribe, - getSnapshotWithMemoizedSelector, + getSelection, + getServerSelection, ); useEffect(() => { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 950598f4c71fc..c3206daa0d339 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -394,5 +394,6 @@ "403": "Tried to pop a Context at the root of the app. This is a bug in React.", "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." + "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." }