Skip to content

Implement useSyncExternalStore on server #22347

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 20, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
@@ -258,6 +258,7 @@ function useMemo<T>(
function useSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
// useSyncExternalStore() composes multiple hooks internally.
// Advance the current hook index the same number of times
157 changes: 157 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
@@ -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(<div>Hello</div>);
});

// @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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noob q: I see we use this everywhere in tests, why is it needed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh is it just as a way to avoid relying on implementation details for testing if a codepath executed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a common testing pattern we use. yieldValue pushes something into a log, and then we later assert the output of that log.

The reason it's a Scheduler API is that we also use it to perform some partial work and then yield right after, to test concurrent tasks:

scheduleSomeWork(); // e.g. a React update
expect(Scheduler).toFlushAndYieldThrough([
  'child 1',
  'child 2,'
]);

// The work is paused. Do something concurrent, like an interleaved input event.

// Then flush the rest of the work
expect(Scheduler).toFlushAndYield([
  'child 3',
  'child 4,'
]);

// Assert on final result

It's probably not the most intuitive name; it's inspired by the yield keyword for generator functions since that's roughly the way it's used.

return text;
}

function App() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
return (
<div ref={ref}>
<Child text={value} />
</div>
);
}

const loggedErrors = [];
await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<Suspense fallback="Loading...">
<App />
</Suspense>,
writable,
{
onError(x) {
loggedErrors.push(x);
},
},
);
startWriting();
});
expect(Scheduler).toHaveYielded(['server']);

const serverRenderedDiv = container.getElementsByTagName('div')[0];

ReactDOM.hydrateRoot(container, <App />);

// The first paint uses the server snapshot
expect(Scheduler).toFlushUntilNextPaint(['server']);
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
// 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(<div>client</div>);
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 (
<div ref={ref}>
<Child text={env} />
</div>
);
}

const loggedErrors = [];
await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<Suspense fallback="Loading...">
<App />
</Suspense>,
writable,
{
onError(x) {
loggedErrors.push(x);
},
},
);
startWriting();
});
expect(Scheduler).toHaveYielded(['server']);

const serverRenderedDiv = container.getElementsByTagName('div')[0];

ReactDOM.hydrateRoot(container, <App />);

// The first paint uses the server snapshot
expect(Scheduler).toFlushUntilNextPaint(['server']);
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
// 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(<div>client</div>);
expect(ref.current).toEqual(serverRenderedDiv);
});
});
10 changes: 9 additions & 1 deletion packages/react-dom/src/server/ReactPartialRendererHooks.js
Original file line number Diff line number Diff line change
@@ -464,8 +464,16 @@ export function useCallback<T>(
function useSyncExternalStore<T>(
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<T>(value: T): T {
97 changes: 67 additions & 30 deletions packages/react-reconciler/src/ReactFiberHooks.new.js
Original file line number Diff line number Diff line change
@@ -938,23 +938,64 @@ function rerenderReducer<S, I, A>(
function mountSyncExternalStore<T>(
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<T> = {
value: nextSnapshot,
@@ -980,24 +1021,13 @@ function mountSyncExternalStore<T>(
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<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const fiber = currentlyRenderingFiber;
const hook = updateWorkInProgressHook();
@@ -2235,10 +2265,11 @@ if (__DEV__) {
useSyncExternalStore<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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';
97 changes: 67 additions & 30 deletions packages/react-reconciler/src/ReactFiberHooks.old.js
Original file line number Diff line number Diff line change
@@ -938,23 +938,64 @@ function rerenderReducer<S, I, A>(
function mountSyncExternalStore<T>(
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<T> = {
value: nextSnapshot,
@@ -980,24 +1021,13 @@ function mountSyncExternalStore<T>(
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<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const fiber = currentlyRenderingFiber;
const hook = updateWorkInProgressHook();
@@ -2235,10 +2265,11 @@ if (__DEV__) {
useSyncExternalStore<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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';
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactInternalTypes.js
Original file line number Diff line number Diff line change
@@ -294,6 +294,7 @@ export type Dispatcher = {|
useSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T,
useOpaqueIdentifier(): any,
useCacheRefresh?: () => <T>(?() => T, ?T) => void,
10 changes: 9 additions & 1 deletion packages/react-server/src/ReactFizzHooks.js
Original file line number Diff line number Diff line change
@@ -447,8 +447,16 @@ export function useCallback<T>(
function useSyncExternalStore<T>(
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<T>(value: T): T {
7 changes: 6 additions & 1 deletion packages/react/src/ReactHooks.js
Original file line number Diff line number Diff line change
@@ -166,9 +166,14 @@ export function useOpaqueIdentifier(): OpaqueIDType | void {
export function useSyncExternalStore<T>(
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, ?T) => void {
Original file line number Diff line number Diff line change
@@ -588,6 +588,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
const a = useSyncExternalStoreExtra(
store.subscribe,
store.getState,
null,
selector,
);
return <Text text={'A' + a} />;
@@ -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,
);
4 changes: 3 additions & 1 deletion packages/use-sync-external-store/src/useSyncExternalStore.js
Original file line number Diff line number Diff line change
@@ -45,6 +45,8 @@ let didWarnUncachedGetSnapshot = false;
function useSyncExternalStore_shim<T>(
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<T>(
// 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;
21 changes: 15 additions & 6 deletions packages/use-sync-external-store/src/useSyncExternalStoreExtra.js
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ const {useRef, useEffect, useMemo, useDebugValue} = React;
export function useSyncExternalStoreExtra<Snapshot, Selection>(
subscribe: (() => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: void | null | (() => Snapshot),
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {
@@ -35,17 +36,15 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
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
// copies of the hook/component.
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<Snapshot, Selection>(
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(() => {
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
@@ -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."
}