@samitouri / QOS-React-2 / commits / 80059bb730

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.

Andrew Clark committed Feb 16, 2022 at 12:15 UTC 80059bb7307e30234ebcc9e6b491c0606e66e6c7
10 files changed +241 -112
packages/jest-react/src/internalAct.js
+16 -9
@@ -22,7 +22,7 @@ import enqueueTask from 'shared/enqueueTask';
22
23 let actingUpdatesScopeDepth = 0;
24
25 -export function act(scope: () => Thenable<mixed> | void) {
25 +export function act<T>(scope: () => Thenable<T> | T): Thenable<T> {
26 if (Scheduler.unstable_flushAllWithoutAsserting === undefined) {
27 throw Error(
28 'This version of `act` requires a special mock build of Scheduler.',
@@ -66,20 +66,21 @@ export function act(scope: () => Thenable<mixed> | void) {
66 // returned and 2) we could use async/await. Since it's only our used in
67 // our test suite, we should be able to.
68 try {
69 - const thenable = scope();
69 + const result = scope();
70 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'
74 ) {
75 + const thenableResult: Thenable<T> = (result: any);
76 return {
76 - then(resolve: () => void, reject: (error: mixed) => void) {
77 - thenable.then(
78 - () => {
77 + then(resolve, reject) {
78 + thenableResult.then(
79 + returnValue => {
80 flushActWork(
81 () => {
82 unwind();
82 - resolve();
83 + resolve(returnValue);
84 },
85 error => {
86 unwind();
@@ -95,6 +96,7 @@ export function act(scope: () => Thenable<mixed> | void) {
96 },
97 };
98 } else {
99 + const returnValue: T = (result: any);
100 try {
101 // TODO: Let's not support non-async scopes at all in our tests. Need to
102 // migrate existing tests.
@@ -102,6 +104,11 @@ export function act(scope: () => Thenable<mixed> | void) {
104 do {
105 didFlushWork = Scheduler.unstable_flushAllWithoutAsserting();
106 } while (didFlushWork);
107 + return {
108 + then(resolve, reject) {
109 + resolve(returnValue);
110 + },
111 + };
112 } finally {
113 unwind();
114 }
packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
+34 -4
@@ -140,10 +140,10 @@ describe('ReactDOMFizzShellHydration', () => {
140 }
141 }
142
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 + }
147
148 function AsyncText({text}) {
149 readText(text);
@@ -213,4 +213,34 @@ describe('ReactDOMFizzShellHydration', () => {
213 expect(Scheduler).toHaveYielded(['Shell']);
214 expect(container.textContent).toBe('Shell');
215 });
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 + });
246 });
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+1 -6
@@ -1966,6 +1966,7 @@ describe('ReactDOMServerPartialHydration', () => {
1966 expect(b.textContent).toBe('B');
1967
1968 const root = ReactDOM.hydrateRoot(container, <App />);
1969 +
1970 // Increase hydration priority to higher than "offscreen".
1971 root.unstable_scheduleHydration(b);
1972
@@ -1973,14 +1974,8 @@ describe('ReactDOMServerPartialHydration', () => {
1974
1975 await act(async () => {
1976 if (gate(flags => flags.enableSyncDefaultUpdates)) {
1976 - React.startTransition(() => {
1977 - root.render(<App />);
1978 - });
1979 -
1977 expect(Scheduler).toFlushAndYieldThrough(['Before', 'After']);
1978 } else {
1982 - root.render(<App />);
1983 -
1979 expect(Scheduler).toFlushAndYieldThrough(['Before']);
1980 // This took a long time to render.
1981 Scheduler.unstable_advanceTime(1000);
packages/react-dom/src/client/ReactDOMRoot.js
+3 -5
@@ -60,6 +60,7 @@ import {
60
61 import {
62 createContainer,
63 + createHydrationContainer,
64 updateContainer,
65 findHostInstanceWithNoPortals,
66 registerMutableSourceForHydration,
@@ -261,10 +262,10 @@ export function hydrateRoot(
262 }
263 }
264
264 - const root = createContainer(
265 + const root = createHydrationContainer(
266 + initialChildren,
267 container,
268 ConcurrentRoot,
267 - true, // hydrate
269 hydrationCallbacks,
270 isStrictMode,
271 concurrentUpdatesByDefaultOverride,
@@ -284,9 +285,6 @@ export function hydrateRoot(
285 }
286 }
287
287 - // Render the initial children
288 - updateContainer(initialChildren, root, null, null);
289 -
288 return new ReactDOMHydrationRoot(root);
289 }
290
packages/react-reconciler/src/ReactFiberReconciler.js
+5
@@ -16,6 +16,7 @@ import {enableNewReconciler} from 'shared/ReactFeatureFlags';
16
17 import {
18 createContainer as createContainer_old,
19 + createHydrationContainer as createHydrationContainer_old,
20 updateContainer as updateContainer_old,
21 batchedUpdates as batchedUpdates_old,
22 deferredUpdates as deferredUpdates_old,
@@ -53,6 +54,7 @@ import {
54
55 import {
56 createContainer as createContainer_new,
57 + createHydrationContainer as createHydrationContainer_new,
58 updateContainer as updateContainer_new,
59 batchedUpdates as batchedUpdates_new,
60 deferredUpdates as deferredUpdates_new,
@@ -91,6 +93,9 @@ import {
93 export const createContainer = enableNewReconciler
94 ? createContainer_new
95 : createContainer_old;
96 +export const createHydrationContainer = enableNewReconciler
97 + ? createHydrationContainer_new
98 + : createHydrationContainer_old;
99 export const updateContainer = enableNewReconciler
100 ? updateContainer_new
101 : updateContainer_old;
packages/react-reconciler/src/ReactFiberReconciler.new.js
+46
@@ -57,6 +57,7 @@ import {
57 requestEventTime,
58 requestUpdateLane,
59 scheduleUpdateOnFiber,
60 + scheduleInitialHydrationOnRoot,
61 flushRoot,
62 batchedUpdates,
63 flushSync,
@@ -244,6 +245,8 @@ function findHostInstanceWithWarning(
245 export function createContainer(
246 containerInfo: Container,
247 tag: RootTag,
248 + // TODO: We can remove hydration-specific stuff from createContainer once
249 + // we delete legacy mode. The new root API uses createHydrationContainer.
250 hydrate: boolean,
251 hydrationCallbacks: null | SuspenseHydrationCallbacks,
252 isStrictMode: boolean,
@@ -265,6 +268,49 @@ export function createContainer(
268 );
269 }
270
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 +
314 export function updateContainer(
315 element: ReactNodeList,
316 container: OpaqueRoot,
packages/react-reconciler/src/ReactFiberReconciler.old.js
+46
@@ -57,6 +57,7 @@ import {
57 requestEventTime,
58 requestUpdateLane,
59 scheduleUpdateOnFiber,
60 + scheduleInitialHydrationOnRoot,
61 flushRoot,
62 batchedUpdates,
63 flushSync,
@@ -244,6 +245,8 @@ function findHostInstanceWithWarning(
245 export function createContainer(
246 containerInfo: Container,
247 tag: RootTag,
248 + // TODO: We can remove hydration-specific stuff from createContainer once
249 + // we delete legacy mode. The new root API uses createHydrationContainer.
250 hydrate: boolean,
251 hydrationCallbacks: null | SuspenseHydrationCallbacks,
252 isStrictMode: boolean,
@@ -265,6 +268,49 @@ export function createContainer(
268 );
269 }
270
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 +
314 export function updateContainer(
315 element: ReactNodeList,
316 container: OpaqueRoot,
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+45 -2
@@ -517,8 +517,31 @@ export function scheduleUpdateOnFiber(
517 }
518 }
519
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 +
545 // Received an update to a tree that's in the middle of rendering. Mark
546 // that there was an interleaved update work on this root. Unless the
547 // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render
@@ -564,6 +587,26 @@ export function scheduleUpdateOnFiber(
587 return root;
588 }
589
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 +
610 // This is split into a separate function so we can mark a fiber with pending
611 // work without treating it as a typical update that originates from an event;
612 // e.g. retrying a Suspense boundary isn't an update, but it does schedule work
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+45 -2
@@ -517,8 +517,31 @@ export function scheduleUpdateOnFiber(
517 }
518 }
519
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 +
545 // Received an update to a tree that's in the middle of rendering. Mark
546 // that there was an interleaved update work on this root. Unless the
547 // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render
@@ -564,6 +587,26 @@ export function scheduleUpdateOnFiber(
587 return root;
588 }
589
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 +
610 // This is split into a separate function so we can mark a fiber with pending
611 // work without treating it as a typical update that originates from an event;
612 // e.g. retrying a Suspense boundary isn't an update, but it does schedule work
packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
-84
@@ -35,15 +35,6 @@ describe('useMutableSourceHydration', () => {
35 React.useMutableSource || React.unstable_useMutableSource;
36 });
37
38 - function dispatchAndSetCurrentEvent(el, event) {
39 - try {
40 - window.event = event;
41 - el.dispatchEvent(event);
42 - } finally {
43 - window.event = undefined;
44 - }
45 - }
46 -
38 const defaultGetSnapshot = source => source.value;
39 const defaultSubscribe = (source, callback) => source.subscribe(callback);
40
@@ -380,79 +371,4 @@ describe('useMutableSourceHydration', () => {
371 'an issue.',
372 ]);
373 });
383 -
384 - // @gate !enableSyncDefaultUpdates
385 - // @gate enableUseMutableSource
386 - it('should detect a tear during a higher priority interruption', () => {
387 - const source = createSource('one');
388 - const mutableSource = createMutableSource(source, param => param.version);
389 -
390 - function Unrelated({flag}) {
391 - Scheduler.unstable_yieldValue(flag);
392 - return flag;
393 - }
394 -
395 - function TestComponent({flag}) {
396 - return (
397 - <>
398 - <Unrelated flag={flag} />
399 - <Component
400 - label="a"
401 - getSnapshot={defaultGetSnapshot}
402 - mutableSource={mutableSource}
403 - subscribe={defaultSubscribe}
404 - />
405 - </>
406 - );
407 - }
408 -
409 - const container = document.createElement('div');
410 - document.body.appendChild(container);
411 -
412 - const htmlString = ReactDOMServer.renderToString(
413 - <TestComponent flag={1} />,
414 - );
415 - container.innerHTML = htmlString;
416 - expect(Scheduler).toHaveYielded([1, 'a:one']);
417 - expect(source.listenerCount).toBe(0);
418 -
419 - expect(() => {
420 - act(() => {
421 - let root;
422 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
423 - React.startTransition(() => {
424 - root = ReactDOM.hydrateRoot(container, <TestComponent flag={1} />, {
425 - mutableSources: [mutableSource],
426 - });
427 - });
428 - } else {
429 - root = ReactDOM.hydrateRoot(container, <TestComponent flag={1} />, {
430 - mutableSources: [mutableSource],
431 - });
432 - }
433 - expect(Scheduler).toFlushAndYieldThrough([1]);
434 -
435 - // Render an update which will be higher priority than the hydration.
436 - // We can do this by scheduling the update inside a mouseover event.
437 - const arbitraryElement = document.createElement('div');
438 - const mouseOverEvent = document.createEvent('MouseEvents');
439 - mouseOverEvent.initEvent('mouseover', true, true);
440 - arbitraryElement.addEventListener('mouseover', () => {
441 - root.render(<TestComponent flag={2} />);
442 - });
443 - dispatchAndSetCurrentEvent(arbitraryElement, mouseOverEvent);
444 -
445 - expect(Scheduler).toFlushAndYieldThrough([2]);
446 - source.value = 'two';
447 - });
448 - }).toErrorDev(
449 - 'Warning: Text content did not match. Server: "1" Client: "2"',
450 - );
451 - expect(source.listenerCount).toBe(1);
452 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
453 - expect(Scheduler).toHaveYielded([2, 'a:two']);
454 - } else {
455 - expect(Scheduler).toHaveYielded(['a:two']);
456 - }
457 - });
374 });