@samitouri / QOS-React-2 / commits / 86b3e2461d

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.

Andrew Clark committed Sep 20, 2021 at 11:31 UTC 86b3e2461da28d9c074b04f42e4ca69773902fec
12 files changed +341 -71
packages/react-debug-tools/src/ReactDebugHooks.js
+1
@@ -258,6 +258,7 @@ function useMemo<T>(
258 function useSyncExternalStore<T>(
259 subscribe: (() => void) => () => void,
260 getSnapshot: () => T,
261 + getServerSnapshot?: () => T,
262 ): T {
263 // useSyncExternalStore() composes multiple hooks internally.
264 // Advance the current hook index the same number of times
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+157
@@ -17,6 +17,8 @@ let ReactDOM;
17 let ReactDOMFizzServer;
18 let Suspense;
19 let SuspenseList;
20 +let useSyncExternalStore;
21 +let useSyncExternalStoreExtra;
22 let PropTypes;
23 let textCache;
24 let document;
@@ -39,6 +41,9 @@ describe('ReactDOMFizzServer', () => {
41 Stream = require('stream');
42 Suspense = React.Suspense;
43 SuspenseList = React.SuspenseList;
44 + useSyncExternalStore = React.unstable_useSyncExternalStore;
45 + useSyncExternalStoreExtra = require('use-sync-external-store/extra')
46 + .useSyncExternalStoreExtra;
47 PropTypes = require('prop-types');
48
49 textCache = new Map();
@@ -1478,4 +1483,156 @@ describe('ReactDOMFizzServer', () => {
1483 // We should've been able to display the content without waiting for the rest of the fallback.
1484 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1485 });
1486 +
1487 + // @gate supportsNativeUseSyncExternalStore
1488 + // @gate experimental
1489 + it('calls getServerSnapshot instead of getSnapshot', async () => {
1490 + const ref = React.createRef();
1491 +
1492 + function getServerSnapshot() {
1493 + return 'server';
1494 + }
1495 +
1496 + function getClientSnapshot() {
1497 + return 'client';
1498 + }
1499 +
1500 + function subscribe() {
1501 + return () => {};
1502 + }
1503 +
1504 + function Child({text}) {
1505 + Scheduler.unstable_yieldValue(text);
1506 + return text;
1507 + }
1508 +
1509 + function App() {
1510 + const value = useSyncExternalStore(
1511 + subscribe,
1512 + getClientSnapshot,
1513 + getServerSnapshot,
1514 + );
1515 + return (
1516 + <div ref={ref}>
1517 + <Child text={value} />
1518 + </div>
1519 + );
1520 + }
1521 +
1522 + const loggedErrors = [];
1523 + await act(async () => {
1524 + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1525 + <Suspense fallback="Loading...">
1526 + <App />
1527 + </Suspense>,
1528 + writable,
1529 + {
1530 + onError(x) {
1531 + loggedErrors.push(x);
1532 + },
1533 + },
1534 + );
1535 + startWriting();
1536 + });
1537 + expect(Scheduler).toHaveYielded(['server']);
1538 +
1539 + const serverRenderedDiv = container.getElementsByTagName('div')[0];
1540 +
1541 + ReactDOM.hydrateRoot(container, <App />);
1542 +
1543 + // The first paint uses the server snapshot
1544 + expect(Scheduler).toFlushUntilNextPaint(['server']);
1545 + expect(getVisibleChildren(container)).toEqual(<div>server</div>);
1546 + // Hydration succeeded
1547 + expect(ref.current).toEqual(serverRenderedDiv);
1548 +
1549 + // Asynchronously we detect that the store has changed on the client,
1550 + // and patch up the inconsistency
1551 + expect(Scheduler).toFlushUntilNextPaint(['client']);
1552 + expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1553 + expect(ref.current).toEqual(serverRenderedDiv);
1554 + });
1555 +
1556 + // The selector implementation uses the lazy ref initialization pattern
1557 + // @gate !(enableUseRefAccessWarning && __DEV__)
1558 + // @gate supportsNativeUseSyncExternalStore
1559 + // @gate experimental
1560 + it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
1561 + // Same as previous test, but with a selector that returns a complex object
1562 + // that is memoized with a custom `isEqual` function.
1563 + const ref = React.createRef();
1564 +
1565 + function getServerSnapshot() {
1566 + return {env: 'server', other: 'unrelated'};
1567 + }
1568 +
1569 + function getClientSnapshot() {
1570 + return {env: 'client', other: 'unrelated'};
1571 + }
1572 +
1573 + function selector({env}) {
1574 + return {env};
1575 + }
1576 +
1577 + function isEqual(a, b) {
1578 + return a.env === b.env;
1579 + }
1580 +
1581 + function subscribe() {
1582 + return () => {};
1583 + }
1584 +
1585 + function Child({text}) {
1586 + Scheduler.unstable_yieldValue(text);
1587 + return text;
1588 + }
1589 +
1590 + function App() {
1591 + const {env} = useSyncExternalStoreExtra(
1592 + subscribe,
1593 + getClientSnapshot,
1594 + getServerSnapshot,
1595 + selector,
1596 + isEqual,
1597 + );
1598 + return (
1599 + <div ref={ref}>
1600 + <Child text={env} />
1601 + </div>
1602 + );
1603 + }
1604 +
1605 + const loggedErrors = [];
1606 + await act(async () => {
1607 + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1608 + <Suspense fallback="Loading...">
1609 + <App />
1610 + </Suspense>,
1611 + writable,
1612 + {
1613 + onError(x) {
1614 + loggedErrors.push(x);
1615 + },
1616 + },
1617 + );
1618 + startWriting();
1619 + });
1620 + expect(Scheduler).toHaveYielded(['server']);
1621 +
1622 + const serverRenderedDiv = container.getElementsByTagName('div')[0];
1623 +
1624 + ReactDOM.hydrateRoot(container, <App />);
1625 +
1626 + // The first paint uses the server snapshot
1627 + expect(Scheduler).toFlushUntilNextPaint(['server']);
1628 + expect(getVisibleChildren(container)).toEqual(<div>server</div>);
1629 + // Hydration succeeded
1630 + expect(ref.current).toEqual(serverRenderedDiv);
1631 +
1632 + // Asynchronously we detect that the store has changed on the client,
1633 + // and patch up the inconsistency
1634 + expect(Scheduler).toFlushUntilNextPaint(['client']);
1635 + expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1636 + expect(ref.current).toEqual(serverRenderedDiv);
1637 + });
1638 });
packages/react-dom/src/server/ReactPartialRendererHooks.js
+9 -1
@@ -464,8 +464,16 @@ export function useCallback<T>(
464 function useSyncExternalStore<T>(
465 subscribe: (() => void) => () => void,
466 getSnapshot: () => T,
467 + getServerSnapshot?: () => T,
468 ): T {
468 - throw new Error('Not yet implemented');
469 + if (getServerSnapshot === undefined) {
470 + invariant(
471 + false,
472 + 'Missing getServerSnapshot, which is required for ' +
473 + 'server-rendered content. Will revert to client rendering.',
474 + );
475 + }
476 + return getServerSnapshot();
477 }
478
479 function useDeferredValue<T>(value: T): T {
packages/react-reconciler/src/ReactFiberHooks.new.js
+67 -30
@@ -938,23 +938,64 @@ function rerenderReducer<S, I, A>(
938 function mountSyncExternalStore<T>(
939 subscribe: (() => void) => () => void,
940 getSnapshot: () => T,
941 + getServerSnapshot?: () => T,
942 ): T {
943 const fiber = currentlyRenderingFiber;
944 const hook = mountWorkInProgressHook();
944 - // Read the current snapshot from the store on every render. This breaks the
945 - // normal rules of React, and only works because store updates are
946 - // always synchronous.
947 - const nextSnapshot = getSnapshot();
948 - if (__DEV__) {
949 - if (!didWarnUncachedGetSnapshot) {
950 - if (nextSnapshot !== getSnapshot()) {
951 - console.error(
952 - 'The result of getSnapshot should be cached to avoid an infinite loop',
953 - );
954 - didWarnUncachedGetSnapshot = true;
945 +
946 + let nextSnapshot;
947 + const isHydrating = getIsHydrating();
948 + if (isHydrating) {
949 + if (getServerSnapshot === undefined) {
950 + invariant(
951 + false,
952 + 'Missing getServerSnapshot, which is required for ' +
953 + 'server-rendered content. Will revert to client rendering.',
954 + );
955 + }
956 + nextSnapshot = getServerSnapshot();
957 + if (__DEV__) {
958 + if (!didWarnUncachedGetSnapshot) {
959 + if (nextSnapshot !== getServerSnapshot()) {
960 + console.error(
961 + 'The result of getServerSnapshot should be cached to avoid an infinite loop',
962 + );
963 + didWarnUncachedGetSnapshot = true;
964 + }
965 }
966 }
967 + } else {
968 + nextSnapshot = getSnapshot();
969 + if (__DEV__) {
970 + if (!didWarnUncachedGetSnapshot) {
971 + if (nextSnapshot !== getSnapshot()) {
972 + console.error(
973 + 'The result of getSnapshot should be cached to avoid an infinite loop',
974 + );
975 + didWarnUncachedGetSnapshot = true;
976 + }
977 + }
978 + }
979 + // Unless we're rendering a blocking lane, schedule a consistency check.
980 + // Right before committing, we will walk the tree and check if any of the
981 + // stores were mutated.
982 + //
983 + // We won't do this if we're hydrating server-rendered content, because if
984 + // the content is stale, it's already visible anyway. Instead we'll patch
985 + // it up in a passive effect.
986 + const root: FiberRoot | null = getWorkInProgressRoot();
987 + invariant(
988 + root !== null,
989 + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
990 + );
991 + if (!includesBlockingLane(root, renderLanes)) {
992 + pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
993 + }
994 }
995 +
996 + // Read the current snapshot from the store on every render. This breaks the
997 + // normal rules of React, and only works because store updates are
998 + // always synchronous.
999 hook.memoizedState = nextSnapshot;
1000 const inst: StoreInstance<T> = {
1001 value: nextSnapshot,
@@ -980,24 +1021,13 @@ function mountSyncExternalStore<T>(
1021 null,
1022 );
1023
983 - // Unless we're rendering a blocking lane, schedule a consistency check. Right
984 - // before committing, we will walk the tree and check if any of the stores
985 - // were mutated.
986 - const root: FiberRoot | null = getWorkInProgressRoot();
987 - invariant(
988 - root !== null,
989 - 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
990 - );
991 - if (!includesBlockingLane(root, renderLanes)) {
992 - pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
993 - }
994 -
1024 return nextSnapshot;
1025 }
1026
1027 function updateSyncExternalStore<T>(
1028 subscribe: (() => void) => () => void,
1029 getSnapshot: () => T,
1030 + getServerSnapshot?: () => T,
1031 ): T {
1032 const fiber = currentlyRenderingFiber;
1033 const hook = updateWorkInProgressHook();
@@ -2235,10 +2265,11 @@ if (__DEV__) {
2265 useSyncExternalStore<T>(
2266 subscribe: (() => void) => () => void,
2267 getSnapshot: () => T,
2268 + getServerSnapshot?: () => T,
2269 ): T {
2270 currentHookNameInDev = 'useSyncExternalStore';
2271 mountHookTypesDev();
2241 - return mountSyncExternalStore(subscribe, getSnapshot);
2272 + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2273 },
2274 useOpaqueIdentifier(): OpaqueIDType | void {
2275 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2366,10 +2397,11 @@ if (__DEV__) {
2397 useSyncExternalStore<T>(
2398 subscribe: (() => void) => () => void,
2399 getSnapshot: () => T,
2400 + getServerSnapshot?: () => T,
2401 ): T {
2402 currentHookNameInDev = 'useSyncExternalStore';
2403 updateHookTypesDev();
2372 - return mountSyncExternalStore(subscribe, getSnapshot);
2404 + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2405 },
2406 useOpaqueIdentifier(): OpaqueIDType | void {
2407 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2497,10 +2529,11 @@ if (__DEV__) {
2529 useSyncExternalStore<T>(
2530 subscribe: (() => void) => () => void,
2531 getSnapshot: () => T,
2532 + getServerSnapshot?: () => T,
2533 ): T {
2534 currentHookNameInDev = 'useSyncExternalStore';
2535 updateHookTypesDev();
2503 - return updateSyncExternalStore(subscribe, getSnapshot);
2536 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2537 },
2538 useOpaqueIdentifier(): OpaqueIDType | void {
2539 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2629,10 +2662,11 @@ if (__DEV__) {
2662 useSyncExternalStore<T>(
2663 subscribe: (() => void) => () => void,
2664 getSnapshot: () => T,
2665 + getServerSnapshot?: () => T,
2666 ): T {
2667 currentHookNameInDev = 'useSyncExternalStore';
2668 updateHookTypesDev();
2635 - return updateSyncExternalStore(subscribe, getSnapshot);
2669 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2670 },
2671 useOpaqueIdentifier(): OpaqueIDType | void {
2672 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2774,11 +2808,12 @@ if (__DEV__) {
2808 useSyncExternalStore<T>(
2809 subscribe: (() => void) => () => void,
2810 getSnapshot: () => T,
2811 + getServerSnapshot?: () => T,
2812 ): T {
2813 currentHookNameInDev = 'useSyncExternalStore';
2814 warnInvalidHookAccess();
2815 mountHookTypesDev();
2781 - return mountSyncExternalStore(subscribe, getSnapshot);
2816 + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2817 },
2818 useOpaqueIdentifier(): OpaqueIDType | void {
2819 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2921,11 +2956,12 @@ if (__DEV__) {
2956 useSyncExternalStore<T>(
2957 subscribe: (() => void) => () => void,
2958 getSnapshot: () => T,
2959 + getServerSnapshot?: () => T,
2960 ): T {
2961 currentHookNameInDev = 'useSyncExternalStore';
2962 warnInvalidHookAccess();
2963 updateHookTypesDev();
2928 - return updateSyncExternalStore(subscribe, getSnapshot);
2964 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2965 },
2966 useOpaqueIdentifier(): OpaqueIDType | void {
2967 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -3069,11 +3105,12 @@ if (__DEV__) {
3105 useSyncExternalStore<T>(
3106 subscribe: (() => void) => () => void,
3107 getSnapshot: () => T,
3108 + getServerSnapshot?: () => T,
3109 ): T {
3110 currentHookNameInDev = 'useSyncExternalStore';
3111 warnInvalidHookAccess();
3112 updateHookTypesDev();
3076 - return updateSyncExternalStore(subscribe, getSnapshot);
3113 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
3114 },
3115 useOpaqueIdentifier(): OpaqueIDType | void {
3116 currentHookNameInDev = 'useOpaqueIdentifier';
packages/react-reconciler/src/ReactFiberHooks.old.js
+67 -30
@@ -938,23 +938,64 @@ function rerenderReducer<S, I, A>(
938 function mountSyncExternalStore<T>(
939 subscribe: (() => void) => () => void,
940 getSnapshot: () => T,
941 + getServerSnapshot?: () => T,
942 ): T {
943 const fiber = currentlyRenderingFiber;
944 const hook = mountWorkInProgressHook();
944 - // Read the current snapshot from the store on every render. This breaks the
945 - // normal rules of React, and only works because store updates are
946 - // always synchronous.
947 - const nextSnapshot = getSnapshot();
948 - if (__DEV__) {
949 - if (!didWarnUncachedGetSnapshot) {
950 - if (nextSnapshot !== getSnapshot()) {
951 - console.error(
952 - 'The result of getSnapshot should be cached to avoid an infinite loop',
953 - );
954 - didWarnUncachedGetSnapshot = true;
945 +
946 + let nextSnapshot;
947 + const isHydrating = getIsHydrating();
948 + if (isHydrating) {
949 + if (getServerSnapshot === undefined) {
950 + invariant(
951 + false,
952 + 'Missing getServerSnapshot, which is required for ' +
953 + 'server-rendered content. Will revert to client rendering.',
954 + );
955 + }
956 + nextSnapshot = getServerSnapshot();
957 + if (__DEV__) {
958 + if (!didWarnUncachedGetSnapshot) {
959 + if (nextSnapshot !== getServerSnapshot()) {
960 + console.error(
961 + 'The result of getServerSnapshot should be cached to avoid an infinite loop',
962 + );
963 + didWarnUncachedGetSnapshot = true;
964 + }
965 }
966 }
967 + } else {
968 + nextSnapshot = getSnapshot();
969 + if (__DEV__) {
970 + if (!didWarnUncachedGetSnapshot) {
971 + if (nextSnapshot !== getSnapshot()) {
972 + console.error(
973 + 'The result of getSnapshot should be cached to avoid an infinite loop',
974 + );
975 + didWarnUncachedGetSnapshot = true;
976 + }
977 + }
978 + }
979 + // Unless we're rendering a blocking lane, schedule a consistency check.
980 + // Right before committing, we will walk the tree and check if any of the
981 + // stores were mutated.
982 + //
983 + // We won't do this if we're hydrating server-rendered content, because if
984 + // the content is stale, it's already visible anyway. Instead we'll patch
985 + // it up in a passive effect.
986 + const root: FiberRoot | null = getWorkInProgressRoot();
987 + invariant(
988 + root !== null,
989 + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
990 + );
991 + if (!includesBlockingLane(root, renderLanes)) {
992 + pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
993 + }
994 }
995 +
996 + // Read the current snapshot from the store on every render. This breaks the
997 + // normal rules of React, and only works because store updates are
998 + // always synchronous.
999 hook.memoizedState = nextSnapshot;
1000 const inst: StoreInstance<T> = {
1001 value: nextSnapshot,
@@ -980,24 +1021,13 @@ function mountSyncExternalStore<T>(
1021 null,
1022 );
1023
983 - // Unless we're rendering a blocking lane, schedule a consistency check. Right
984 - // before committing, we will walk the tree and check if any of the stores
985 - // were mutated.
986 - const root: FiberRoot | null = getWorkInProgressRoot();
987 - invariant(
988 - root !== null,
989 - 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
990 - );
991 - if (!includesBlockingLane(root, renderLanes)) {
992 - pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
993 - }
994 -
1024 return nextSnapshot;
1025 }
1026
1027 function updateSyncExternalStore<T>(
1028 subscribe: (() => void) => () => void,
1029 getSnapshot: () => T,
1030 + getServerSnapshot?: () => T,
1031 ): T {
1032 const fiber = currentlyRenderingFiber;
1033 const hook = updateWorkInProgressHook();
@@ -2235,10 +2265,11 @@ if (__DEV__) {
2265 useSyncExternalStore<T>(
2266 subscribe: (() => void) => () => void,
2267 getSnapshot: () => T,
2268 + getServerSnapshot?: () => T,
2269 ): T {
2270 currentHookNameInDev = 'useSyncExternalStore';
2271 mountHookTypesDev();
2241 - return mountSyncExternalStore(subscribe, getSnapshot);
2272 + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2273 },
2274 useOpaqueIdentifier(): OpaqueIDType | void {
2275 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2366,10 +2397,11 @@ if (__DEV__) {
2397 useSyncExternalStore<T>(
2398 subscribe: (() => void) => () => void,
2399 getSnapshot: () => T,
2400 + getServerSnapshot?: () => T,
2401 ): T {
2402 currentHookNameInDev = 'useSyncExternalStore';
2403 updateHookTypesDev();
2372 - return mountSyncExternalStore(subscribe, getSnapshot);
2404 + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2405 },
2406 useOpaqueIdentifier(): OpaqueIDType | void {
2407 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2497,10 +2529,11 @@ if (__DEV__) {
2529 useSyncExternalStore<T>(
2530 subscribe: (() => void) => () => void,
2531 getSnapshot: () => T,
2532 + getServerSnapshot?: () => T,
2533 ): T {
2534 currentHookNameInDev = 'useSyncExternalStore';
2535 updateHookTypesDev();
2503 - return updateSyncExternalStore(subscribe, getSnapshot);
2536 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2537 },
2538 useOpaqueIdentifier(): OpaqueIDType | void {
2539 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2629,10 +2662,11 @@ if (__DEV__) {
2662 useSyncExternalStore<T>(
2663 subscribe: (() => void) => () => void,
2664 getSnapshot: () => T,
2665 + getServerSnapshot?: () => T,
2666 ): T {
2667 currentHookNameInDev = 'useSyncExternalStore';
2668 updateHookTypesDev();
2635 - return updateSyncExternalStore(subscribe, getSnapshot);
2669 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2670 },
2671 useOpaqueIdentifier(): OpaqueIDType | void {
2672 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2774,11 +2808,12 @@ if (__DEV__) {
2808 useSyncExternalStore<T>(
2809 subscribe: (() => void) => () => void,
2810 getSnapshot: () => T,
2811 + getServerSnapshot?: () => T,
2812 ): T {
2813 currentHookNameInDev = 'useSyncExternalStore';
2814 warnInvalidHookAccess();
2815 mountHookTypesDev();
2781 - return mountSyncExternalStore(subscribe, getSnapshot);
2816 + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2817 },
2818 useOpaqueIdentifier(): OpaqueIDType | void {
2819 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -2921,11 +2956,12 @@ if (__DEV__) {
2956 useSyncExternalStore<T>(
2957 subscribe: (() => void) => () => void,
2958 getSnapshot: () => T,
2959 + getServerSnapshot?: () => T,
2960 ): T {
2961 currentHookNameInDev = 'useSyncExternalStore';
2962 warnInvalidHookAccess();
2963 updateHookTypesDev();
2928 - return updateSyncExternalStore(subscribe, getSnapshot);
2964 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
2965 },
2966 useOpaqueIdentifier(): OpaqueIDType | void {
2967 currentHookNameInDev = 'useOpaqueIdentifier';
@@ -3069,11 +3105,12 @@ if (__DEV__) {
3105 useSyncExternalStore<T>(
3106 subscribe: (() => void) => () => void,
3107 getSnapshot: () => T,
3108 + getServerSnapshot?: () => T,
3109 ): T {
3110 currentHookNameInDev = 'useSyncExternalStore';
3111 warnInvalidHookAccess();
3112 updateHookTypesDev();
3076 - return updateSyncExternalStore(subscribe, getSnapshot);
3113 + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
3114 },
3115 useOpaqueIdentifier(): OpaqueIDType | void {
3116 currentHookNameInDev = 'useOpaqueIdentifier';
packages/react-reconciler/src/ReactInternalTypes.js
+1
@@ -294,6 +294,7 @@ export type Dispatcher = {|
294 useSyncExternalStore<T>(
295 subscribe: (() => void) => () => void,
296 getSnapshot: () => T,
297 + getServerSnapshot?: () => T,
298 ): T,
299 useOpaqueIdentifier(): any,
300 useCacheRefresh?: () => <T>(?() => T, ?T) => void,
packages/react-server/src/ReactFizzHooks.js
+9 -1
@@ -447,8 +447,16 @@ export function useCallback<T>(
447 function useSyncExternalStore<T>(
448 subscribe: (() => void) => () => void,
449 getSnapshot: () => T,
450 + getServerSnapshot?: () => T,
451 ): T {
451 - throw new Error('Not yet implemented');
452 + if (getServerSnapshot === undefined) {
453 + invariant(
454 + false,
455 + 'Missing getServerSnapshot, which is required for ' +
456 + 'server-rendered content. Will revert to client rendering.',
457 + );
458 + }
459 + return getServerSnapshot();
460 }
461
462 function useDeferredValue<T>(value: T): T {
packages/react/src/ReactHooks.js
+6 -1
@@ -166,9 +166,14 @@ export function useOpaqueIdentifier(): OpaqueIDType | void {
166 export function useSyncExternalStore<T>(
167 subscribe: (() => void) => () => void,
168 getSnapshot: () => T,
169 + getServerSnapshot?: () => T,
170 ): T {
171 const dispatcher = resolveDispatcher();
171 - return dispatcher.useSyncExternalStore(subscribe, getSnapshot);
172 + return dispatcher.useSyncExternalStore(
173 + subscribe,
174 + getSnapshot,
175 + getServerSnapshot,
176 + );
177 }
178
179 export function useCacheRefresh(): <T>(?() => T, ?T) => void {
packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
+4
@@ -588,6 +588,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
588 const a = useSyncExternalStoreExtra(
589 store.subscribe,
590 store.getState,
591 + null,
592 selector,
593 );
594 return <Text text={'A' + a} />;
@@ -623,6 +624,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
624 const {a} = useSyncExternalStoreExtra(
625 store.subscribe,
626 store.getState,
627 + null,
628 state => ({a: state.a}),
629 (state1, state2) => state1.a === state2.a,
630 );
@@ -632,6 +634,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
634 const {b} = useSyncExternalStoreExtra(
635 store.subscribe,
636 store.getState,
637 + null,
638 state => {
639 return {b: state.b};
640 },
@@ -710,6 +713,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
713 const items = useSyncExternalStoreExtra(
714 store.subscribe,
715 store.getState,
716 + null,
717 inlineSelector,
718 shallowEqualArray,
719 );
packages/use-sync-external-store/src/useSyncExternalStore.js
+3 -1
@@ -45,6 +45,8 @@ let didWarnUncachedGetSnapshot = false;
45 function useSyncExternalStore_shim<T>(
46 subscribe: (() => void) => () => void,
47 getSnapshot: () => T,
48 + // TODO: Add a canUseDOM check and use this one on the server
49 + getServerSnapshot?: () => T,
50 ): T {
51 if (__DEV__) {
52 if (!didWarnOld18Alpha) {
@@ -95,7 +97,7 @@ function useSyncExternalStore_shim<T>(
97 // Track the latest getSnapshot function with a ref. This needs to be updated
98 // in the layout phase so we can access it during the tearing check that
99 // happens on subscribe.
98 - // TODO: Circumvent SSR warning
100 + // TODO: Circumvent SSR warning with canUseDOM check
101 useLayoutEffect(() => {
102 inst.value = value;
103 inst.getSnapshot = getSnapshot;
packages/use-sync-external-store/src/useSyncExternalStoreExtra.js
+15 -6
@@ -19,6 +19,7 @@ const {useRef, useEffect, useMemo, useDebugValue} = React;
19 export function useSyncExternalStoreExtra<Snapshot, Selection>(
20 subscribe: (() => void) => () => void,
21 getSnapshot: () => Snapshot,
22 + getServerSnapshot: void | null | (() => Snapshot),
23 selector: (snapshot: Snapshot) => Selection,
24 isEqual?: (a: Selection, b: Selection) => boolean,
25 ): Selection {
@@ -35,7 +36,7 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
36 inst = instRef.current;
37 }
38
38 - const getSnapshotWithMemoizedSelector = useMemo(() => {
39 + const [getSelection, getServerSelection] = useMemo(() => {
40 // Track the memoized state using closure variables that are local to this
41 // memoized instance of a getSnapshot function. Intentionally not using a
42 // useRef hook, because that state would be shared across all concurrent
@@ -43,9 +44,7 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
44 let hasMemo = false;
45 let memoizedSnapshot;
46 let memoizedSelection;
46 - return () => {
47 - const nextSnapshot = getSnapshot();
48 -
47 + const memoizedSelector = nextSnapshot => {
48 if (!hasMemo) {
49 // The first time the hook is called, there is no memoized result.
50 hasMemo = true;
@@ -91,11 +90,21 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
90 memoizedSelection = nextSelection;
91 return nextSelection;
92 };
94 - }, [getSnapshot, selector, isEqual]);
93 + // Assigning this to a constant so that Flow knows it can't change.
94 + const maybeGetServerSnapshot =
95 + getServerSnapshot === undefined ? null : getServerSnapshot;
96 + const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
97 + const getServerSnapshotWithSelector =
98 + maybeGetServerSnapshot === null
99 + ? undefined
100 + : () => memoizedSelector(maybeGetServerSnapshot());
101 + return [getSnapshotWithSelector, getServerSnapshotWithSelector];
102 + }, [getSnapshot, getServerSnapshot, selector, isEqual]);
103
104 const value = useSyncExternalStore(
105 subscribe,
98 - getSnapshotWithMemoizedSelector,
106 + getSelection,
107 + getServerSelection,
108 );
109
110 useEffect(() => {
scripts/error-codes/codes.json
+2 -1
@@ -394,5 +394,6 @@
394 "403": "Tried to pop a Context at the root of the app. This is a bug in React.",
395 "404": "Invalid hook call. Hooks can only be called inside of the body of a function component.",
396 "405": "hydrateRoot(...): Target container is not a DOM element.",
397 - "406": "act(...) is not supported in production builds of React."
397 + "406": "act(...) is not supported in production builds of React.",
398 + "407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering."
399 }