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." }