@samitouri / QOS-React-2 / commits / 4cd7065665

Fix uSES hydration in strict mode (#26791)

Previously, we'd call and use getSnapshot on the second render resulting in `Warning: Text content did not match. Server: "Nay!" Client: "Yay!"` and then `Error: Text content does not match server-rendered HTML.`. Fixes #26095. Closes #26113. Closes #25650. --------- Co-authored-by: eps1lon <silbermann.sebastian@gmail.com>

Sophie Alpert committed May 12, 2023 at 14:18 UTC 4cd7065665ea2cf33c306265c8d817904bb401ca
2 files changed +120 -14
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+94
@@ -20,6 +20,7 @@ let JSDOM;
20 let Stream;
21 let Scheduler;
22 let React;
23 +let ReactDOM;
24 let ReactDOMClient;
25 let ReactDOMFizzServer;
26 let Suspense;
@@ -73,6 +74,7 @@ describe('ReactDOMFizzServer', () => {
74
75 Scheduler = require('scheduler');
76 React = require('react');
77 + ReactDOM = require('react-dom');
78 ReactDOMClient = require('react-dom/client');
79 ReactDOMFizzServer = require('react-dom/server');
80 Stream = require('stream');
@@ -2507,6 +2509,98 @@ describe('ReactDOMFizzServer', () => {
2509 },
2510 );
2511
2512 + it('can hydrate uSES in StrictMode with different client and server snapshot (sync)', async () => {
2513 + function subscribe() {
2514 + return () => {};
2515 + }
2516 + function getClientSnapshot() {
2517 + return 'Yay!';
2518 + }
2519 + function getServerSnapshot() {
2520 + return 'Nay!';
2521 + }
2522 +
2523 + function App() {
2524 + const value = useSyncExternalStore(
2525 + subscribe,
2526 + getClientSnapshot,
2527 + getServerSnapshot,
2528 + );
2529 + Scheduler.log(value);
2530 +
2531 + return value;
2532 + }
2533 +
2534 + const element = (
2535 + <React.StrictMode>
2536 + <App />
2537 + </React.StrictMode>
2538 + );
2539 +
2540 + await act(async () => {
2541 + const {pipe} = renderToPipeableStream(element);
2542 + pipe(writable);
2543 + });
2544 +
2545 + assertLog(['Nay!']);
2546 + expect(getVisibleChildren(container)).toEqual('Nay!');
2547 +
2548 + await clientAct(() => {
2549 + ReactDOM.flushSync(() => {
2550 + ReactDOMClient.hydrateRoot(container, element);
2551 + });
2552 + });
2553 +
2554 + expect(getVisibleChildren(container)).toEqual('Yay!');
2555 + assertLog(['Nay!', 'Yay!']);
2556 + });
2557 +
2558 + it('can hydrate uSES in StrictMode with different client and server snapshot (concurrent)', async () => {
2559 + function subscribe() {
2560 + return () => {};
2561 + }
2562 + function getClientSnapshot() {
2563 + return 'Yay!';
2564 + }
2565 + function getServerSnapshot() {
2566 + return 'Nay!';
2567 + }
2568 +
2569 + function App() {
2570 + const value = useSyncExternalStore(
2571 + subscribe,
2572 + getClientSnapshot,
2573 + getServerSnapshot,
2574 + );
2575 + Scheduler.log(value);
2576 +
2577 + return value;
2578 + }
2579 +
2580 + const element = (
2581 + <React.StrictMode>
2582 + <App />
2583 + </React.StrictMode>
2584 + );
2585 +
2586 + await act(async () => {
2587 + const {pipe} = renderToPipeableStream(element);
2588 + pipe(writable);
2589 + });
2590 +
2591 + assertLog(['Nay!']);
2592 + expect(getVisibleChildren(container)).toEqual('Nay!');
2593 +
2594 + await clientAct(() => {
2595 + React.startTransition(() => {
2596 + ReactDOMClient.hydrateRoot(container, element);
2597 + });
2598 + });
2599 +
2600 + expect(getVisibleChildren(container)).toEqual('Yay!');
2601 + assertLog(['Nay!', 'Yay!']);
2602 + });
2603 +
2604 it(
2605 'errors during hydration force a client render at the nearest Suspense ' +
2606 'boundary, and during the client render it recovers',
packages/react-reconciler/src/ReactFiberHooks.js
+26 -14
@@ -1776,8 +1776,6 @@ function mountSyncExternalStore<T>(
1776 // clean-up function, and we track the deps correctly, we can call pushEffect
1777 // directly, without storing any additional state. For the same reason, we
1778 // don't need to set a static flag, either.
1779 - // TODO: We can move this to the passive phase once we add a pre-commit
1780 - // consistency check. See the next comment.
1779 fiber.flags |= PassiveEffect;
1780 pushEffect(
1781 HookHasEffect | HookPassive,
@@ -1799,15 +1797,28 @@ function updateSyncExternalStore<T>(
1797 // Read the current snapshot from the store on every render. This breaks the
1798 // normal rules of React, and only works because store updates are
1799 // always synchronous.
1802 - const nextSnapshot = getSnapshot();
1803 - if (__DEV__) {
1804 - if (!didWarnUncachedGetSnapshot) {
1805 - const cachedSnapshot = getSnapshot();
1806 - if (!is(nextSnapshot, cachedSnapshot)) {
1807 - console.error(
1808 - 'The result of getSnapshot should be cached to avoid an infinite loop',
1809 - );
1810 - didWarnUncachedGetSnapshot = true;
1800 + let nextSnapshot;
1801 + const isHydrating = getIsHydrating();
1802 + if (isHydrating) {
1803 + // Needed for strict mode double render
1804 + if (getServerSnapshot === undefined) {
1805 + throw new Error(
1806 + 'Missing getServerSnapshot, which is required for ' +
1807 + 'server-rendered content. Will revert to client rendering.',
1808 + );
1809 + }
1810 + nextSnapshot = getServerSnapshot();
1811 + } else {
1812 + nextSnapshot = getSnapshot();
1813 + if (__DEV__) {
1814 + if (!didWarnUncachedGetSnapshot) {
1815 + const cachedSnapshot = getSnapshot();
1816 + if (!is(nextSnapshot, cachedSnapshot)) {
1817 + console.error(
1818 + 'The result of getSnapshot should be cached to avoid an infinite loop',
1819 + );
1820 + didWarnUncachedGetSnapshot = true;
1821 + }
1822 }
1823 }
1824 }
@@ -1830,7 +1841,7 @@ function updateSyncExternalStore<T>(
1841 if (
1842 inst.getSnapshot !== getSnapshot ||
1843 snapshotChanged ||
1833 - // Check if the susbcribe function changed. We can save some memory by
1844 + // Check if the subscribe function changed. We can save some memory by
1845 // checking whether we scheduled a subscription effect above.
1846 (workInProgressHook !== null &&
1847 workInProgressHook.memoizedState.tag & HookHasEffect)
@@ -1854,7 +1865,7 @@ function updateSyncExternalStore<T>(
1865 );
1866 }
1867
1857 - if (!includesBlockingLane(root, renderLanes)) {
1868 + if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
1869 pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
1870 }
1871 }
@@ -2217,7 +2228,8 @@ function updateEffectImpl(
2228 const effect: Effect = hook.memoizedState;
2229 const inst = effect.inst;
2230
2220 - // currentHook is null when rerendering after a render phase state update.
2231 + // currentHook is null on initial mount when rerendering after a render phase
2232 + // state update or for strict mode.
2233 if (currentHook !== null) {
2234 if (nextDeps !== null) {
2235 const prevEffect: Effect = currentHook.memoizedState;