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;