Skip to content

Commit 80059bb

Browse files
authored
Switch to client rendering if root receives update (#23309)
If a hydration root receives an update before the outermost shell has finished hydrating, we should give up hydrating and switch to client rendering. Since the shell is expected to commit quickly, this doesn't happen that often. The most common sequence is something in the shell suspends, and then the user quickly navigates to a different screen, triggering a top-level update. Instead of immediately switching to client rendering, we could first attempt to hydration at higher priority, like we do for updates that occur inside nested dehydrated trees. But since this case is expected to be rare, and mainly only happens when the shell is suspended, an attempt at higher priority would likely end up suspending again anyway, so it would be wasted effort. Implementing it this way would also require us to add a new lane especially for root hydration. For simplicity's sake, we'll immediately switch to client rendering. In the future, if we find another use case for a root hydration lane, we'll reconsider.
1 parent f7f7ed0 commit 80059bb

10 files changed

+241
-112
lines changed

packages/jest-react/src/internalAct.js

+16-9
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import enqueueTask from 'shared/enqueueTask';
2222

2323
let actingUpdatesScopeDepth = 0;
2424

25-
export function act(scope: () => Thenable<mixed> | void) {
25+
export function act<T>(scope: () => Thenable<T> | T): Thenable<T> {
2626
if (Scheduler.unstable_flushAllWithoutAsserting === undefined) {
2727
throw Error(
2828
'This version of `act` requires a special mock build of Scheduler.',
@@ -66,20 +66,21 @@ export function act(scope: () => Thenable<mixed> | void) {
6666
// returned and 2) we could use async/await. Since it's only our used in
6767
// our test suite, we should be able to.
6868
try {
69-
const thenable = scope();
69+
const result = scope();
7070
if (
71-
typeof thenable === 'object' &&
72-
thenable !== null &&
73-
typeof thenable.then === 'function'
71+
typeof result === 'object' &&
72+
result !== null &&
73+
typeof result.then === 'function'
7474
) {
75+
const thenableResult: Thenable<T> = (result: any);
7576
return {
76-
then(resolve: () => void, reject: (error: mixed) => void) {
77-
thenable.then(
78-
() => {
77+
then(resolve, reject) {
78+
thenableResult.then(
79+
returnValue => {
7980
flushActWork(
8081
() => {
8182
unwind();
82-
resolve();
83+
resolve(returnValue);
8384
},
8485
error => {
8586
unwind();
@@ -95,13 +96,19 @@ export function act(scope: () => Thenable<mixed> | void) {
9596
},
9697
};
9798
} else {
99+
const returnValue: T = (result: any);
98100
try {
99101
// TODO: Let's not support non-async scopes at all in our tests. Need to
100102
// migrate existing tests.
101103
let didFlushWork;
102104
do {
103105
didFlushWork = Scheduler.unstable_flushAllWithoutAsserting();
104106
} while (didFlushWork);
107+
return {
108+
then(resolve, reject) {
109+
resolve(returnValue);
110+
},
111+
};
105112
} finally {
106113
unwind();
107114
}

packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js

+34-4
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ describe('ReactDOMFizzShellHydration', () => {
140140
}
141141
}
142142

143-
// function Text({text}) {
144-
// Scheduler.unstable_yieldValue(text);
145-
// return text;
146-
// }
143+
function Text({text}) {
144+
Scheduler.unstable_yieldValue(text);
145+
return text;
146+
}
147147

148148
function AsyncText({text}) {
149149
readText(text);
@@ -213,4 +213,34 @@ describe('ReactDOMFizzShellHydration', () => {
213213
expect(Scheduler).toHaveYielded(['Shell']);
214214
expect(container.textContent).toBe('Shell');
215215
});
216+
217+
test('updating the root before the shell hydrates forces a client render', async () => {
218+
function App() {
219+
return <AsyncText text="Shell" />;
220+
}
221+
222+
// Server render
223+
await resolveText('Shell');
224+
await serverAct(async () => {
225+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
226+
pipe(writable);
227+
});
228+
expect(Scheduler).toHaveYielded(['Shell']);
229+
230+
// Clear the cache and start rendering on the client
231+
resetTextCache();
232+
233+
// Hydration suspends because the data for the shell hasn't loaded yet
234+
const root = await clientAct(async () => {
235+
return ReactDOM.hydrateRoot(container, <App />);
236+
});
237+
expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
238+
expect(container.textContent).toBe('Shell');
239+
240+
await clientAct(async () => {
241+
root.render(<Text text="New screen" />);
242+
});
243+
expect(Scheduler).toHaveYielded(['New screen']);
244+
expect(container.textContent).toBe('New screen');
245+
});
216246
});

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

+1-6
Original file line numberDiff line numberDiff line change
@@ -1966,21 +1966,16 @@ describe('ReactDOMServerPartialHydration', () => {
19661966
expect(b.textContent).toBe('B');
19671967

19681968
const root = ReactDOM.hydrateRoot(container, <App />);
1969+
19691970
// Increase hydration priority to higher than "offscreen".
19701971
root.unstable_scheduleHydration(b);
19711972

19721973
suspend = true;
19731974

19741975
await act(async () => {
19751976
if (gate(flags => flags.enableSyncDefaultUpdates)) {
1976-
React.startTransition(() => {
1977-
root.render(<App />);
1978-
});
1979-
19801977
expect(Scheduler).toFlushAndYieldThrough(['Before', 'After']);
19811978
} else {
1982-
root.render(<App />);
1983-
19841979
expect(Scheduler).toFlushAndYieldThrough(['Before']);
19851980
// This took a long time to render.
19861981
Scheduler.unstable_advanceTime(1000);

packages/react-dom/src/client/ReactDOMRoot.js

+3-5
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060

6161
import {
6262
createContainer,
63+
createHydrationContainer,
6364
updateContainer,
6465
findHostInstanceWithNoPortals,
6566
registerMutableSourceForHydration,
@@ -261,10 +262,10 @@ export function hydrateRoot(
261262
}
262263
}
263264

264-
const root = createContainer(
265+
const root = createHydrationContainer(
266+
initialChildren,
265267
container,
266268
ConcurrentRoot,
267-
true, // hydrate
268269
hydrationCallbacks,
269270
isStrictMode,
270271
concurrentUpdatesByDefaultOverride,
@@ -284,9 +285,6 @@ export function hydrateRoot(
284285
}
285286
}
286287

287-
// Render the initial children
288-
updateContainer(initialChildren, root, null, null);
289-
290288
return new ReactDOMHydrationRoot(root);
291289
}
292290

packages/react-reconciler/src/ReactFiberReconciler.js

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {enableNewReconciler} from 'shared/ReactFeatureFlags';
1616

1717
import {
1818
createContainer as createContainer_old,
19+
createHydrationContainer as createHydrationContainer_old,
1920
updateContainer as updateContainer_old,
2021
batchedUpdates as batchedUpdates_old,
2122
deferredUpdates as deferredUpdates_old,
@@ -53,6 +54,7 @@ import {
5354

5455
import {
5556
createContainer as createContainer_new,
57+
createHydrationContainer as createHydrationContainer_new,
5658
updateContainer as updateContainer_new,
5759
batchedUpdates as batchedUpdates_new,
5860
deferredUpdates as deferredUpdates_new,
@@ -91,6 +93,9 @@ import {
9193
export const createContainer = enableNewReconciler
9294
? createContainer_new
9395
: createContainer_old;
96+
export const createHydrationContainer = enableNewReconciler
97+
? createHydrationContainer_new
98+
: createHydrationContainer_old;
9499
export const updateContainer = enableNewReconciler
95100
? updateContainer_new
96101
: updateContainer_old;

packages/react-reconciler/src/ReactFiberReconciler.new.js

+46
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
requestEventTime,
5858
requestUpdateLane,
5959
scheduleUpdateOnFiber,
60+
scheduleInitialHydrationOnRoot,
6061
flushRoot,
6162
batchedUpdates,
6263
flushSync,
@@ -244,6 +245,8 @@ function findHostInstanceWithWarning(
244245
export function createContainer(
245246
containerInfo: Container,
246247
tag: RootTag,
248+
// TODO: We can remove hydration-specific stuff from createContainer once
249+
// we delete legacy mode. The new root API uses createHydrationContainer.
247250
hydrate: boolean,
248251
hydrationCallbacks: null | SuspenseHydrationCallbacks,
249252
isStrictMode: boolean,
@@ -265,6 +268,49 @@ export function createContainer(
265268
);
266269
}
267270

271+
export function createHydrationContainer(
272+
initialChildren: ReactNodeList,
273+
containerInfo: Container,
274+
tag: RootTag,
275+
hydrationCallbacks: null | SuspenseHydrationCallbacks,
276+
isStrictMode: boolean,
277+
concurrentUpdatesByDefaultOverride: null | boolean,
278+
identifierPrefix: string,
279+
onRecoverableError: (error: mixed) => void,
280+
transitionCallbacks: null | TransitionTracingCallbacks,
281+
): OpaqueRoot {
282+
const hydrate = true;
283+
const root = createFiberRoot(
284+
containerInfo,
285+
tag,
286+
hydrate,
287+
hydrationCallbacks,
288+
isStrictMode,
289+
concurrentUpdatesByDefaultOverride,
290+
identifierPrefix,
291+
onRecoverableError,
292+
transitionCallbacks,
293+
);
294+
295+
// TODO: Move this to FiberRoot constructor
296+
root.context = getContextForSubtree(null);
297+
298+
// Schedule the initial render. In a hydration root, this is different from
299+
// a regular update because the initial render must match was was rendered
300+
// on the server.
301+
const current = root.current;
302+
const eventTime = requestEventTime();
303+
const lane = requestUpdateLane(current);
304+
const update = createUpdate(eventTime, lane);
305+
// Caution: React DevTools currently depends on this property
306+
// being called "element".
307+
update.payload = {element: initialChildren};
308+
enqueueUpdate(current, update, lane);
309+
scheduleInitialHydrationOnRoot(root, lane, eventTime);
310+
311+
return root;
312+
}
313+
268314
export function updateContainer(
269315
element: ReactNodeList,
270316
container: OpaqueRoot,

packages/react-reconciler/src/ReactFiberReconciler.old.js

+46
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
requestEventTime,
5858
requestUpdateLane,
5959
scheduleUpdateOnFiber,
60+
scheduleInitialHydrationOnRoot,
6061
flushRoot,
6162
batchedUpdates,
6263
flushSync,
@@ -244,6 +245,8 @@ function findHostInstanceWithWarning(
244245
export function createContainer(
245246
containerInfo: Container,
246247
tag: RootTag,
248+
// TODO: We can remove hydration-specific stuff from createContainer once
249+
// we delete legacy mode. The new root API uses createHydrationContainer.
247250
hydrate: boolean,
248251
hydrationCallbacks: null | SuspenseHydrationCallbacks,
249252
isStrictMode: boolean,
@@ -265,6 +268,49 @@ export function createContainer(
265268
);
266269
}
267270

271+
export function createHydrationContainer(
272+
initialChildren: ReactNodeList,
273+
containerInfo: Container,
274+
tag: RootTag,
275+
hydrationCallbacks: null | SuspenseHydrationCallbacks,
276+
isStrictMode: boolean,
277+
concurrentUpdatesByDefaultOverride: null | boolean,
278+
identifierPrefix: string,
279+
onRecoverableError: (error: mixed) => void,
280+
transitionCallbacks: null | TransitionTracingCallbacks,
281+
): OpaqueRoot {
282+
const hydrate = true;
283+
const root = createFiberRoot(
284+
containerInfo,
285+
tag,
286+
hydrate,
287+
hydrationCallbacks,
288+
isStrictMode,
289+
concurrentUpdatesByDefaultOverride,
290+
identifierPrefix,
291+
onRecoverableError,
292+
transitionCallbacks,
293+
);
294+
295+
// TODO: Move this to FiberRoot constructor
296+
root.context = getContextForSubtree(null);
297+
298+
// Schedule the initial render. In a hydration root, this is different from
299+
// a regular update because the initial render must match was was rendered
300+
// on the server.
301+
const current = root.current;
302+
const eventTime = requestEventTime();
303+
const lane = requestUpdateLane(current);
304+
const update = createUpdate(eventTime, lane);
305+
// Caution: React DevTools currently depends on this property
306+
// being called "element".
307+
update.payload = {element: initialChildren};
308+
enqueueUpdate(current, update, lane);
309+
scheduleInitialHydrationOnRoot(root, lane, eventTime);
310+
311+
return root;
312+
}
313+
268314
export function updateContainer(
269315
element: ReactNodeList,
270316
container: OpaqueRoot,

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

+45-2
Original file line numberDiff line numberDiff line change
@@ -517,8 +517,31 @@ export function scheduleUpdateOnFiber(
517517
}
518518
}
519519

520-
// TODO: Consolidate with `isInterleavedUpdate` check
521-
if (root === workInProgressRoot) {
520+
if (root.isDehydrated && root.tag !== LegacyRoot) {
521+
// This root's shell hasn't hydrated yet. Revert to client rendering.
522+
// TODO: Log a recoverable error
523+
if (workInProgressRoot === root) {
524+
// If this happened during an interleaved event, interrupt the
525+
// in-progress hydration. Theoretically, we could attempt to force a
526+
// synchronous hydration before switching to client rendering, but the
527+
// most common reason the shell hasn't hydrated yet is because it
528+
// suspended. So it's very likely to suspend again anyway. For
529+
// simplicity, we'll skip that atttempt and go straight to
530+
// client rendering.
531+
//
532+
// Another way to model this would be to give the initial hydration its
533+
// own special lane. However, it may not be worth adding a lane solely
534+
// for this purpose, so we'll wait until we find another use case before
535+
// adding it.
536+
//
537+
// TODO: Consider only interrupting hydration if the priority of the
538+
// update is higher than default.
539+
prepareFreshStack(root, NoLanes);
540+
}
541+
root.isDehydrated = false;
542+
} else if (root === workInProgressRoot) {
543+
// TODO: Consolidate with `isInterleavedUpdate` check
544+
522545
// Received an update to a tree that's in the middle of rendering. Mark
523546
// that there was an interleaved update work on this root. Unless the
524547
// `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render
@@ -564,6 +587,26 @@ export function scheduleUpdateOnFiber(
564587
return root;
565588
}
566589

590+
export function scheduleInitialHydrationOnRoot(
591+
root: FiberRoot,
592+
lane: Lane,
593+
eventTime: number,
594+
) {
595+
// This is a special fork of scheduleUpdateOnFiber that is only used to
596+
// schedule the initial hydration of a root that has just been created. Most
597+
// of the stuff in scheduleUpdateOnFiber can be skipped.
598+
//
599+
// The main reason for this separate path, though, is to distinguish the
600+
// initial children from subsequent updates. In fully client-rendered roots
601+
// (createRoot instead of hydrateRoot), all top-level renders are modeled as
602+
// updates, but hydration roots are special because the initial render must
603+
// match what was rendered on the server.
604+
const current = root.current;
605+
current.lanes = lane;
606+
markRootUpdated(root, lane, eventTime);
607+
ensureRootIsScheduled(root, eventTime);
608+
}
609+
567610
// This is split into a separate function so we can mark a fiber with pending
568611
// work without treating it as a typical update that originates from an event;
569612
// e.g. retrying a Suspense boundary isn't an update, but it does schedule work

0 commit comments

Comments
 (0)