@samitouri / QOS-React-2 / commits / 6fbe630549

[Partial Hydration] Attempt hydration at a higher pri first if props/context changes (#16352)

* Test that we can suspend updates while waiting to hydrate * Attempt hydration at a higher pri first if props/context changes * Retrying a dehydrated boundary pings at the earliest forced time This might quickly become an already expired time. * Mark the render as delayed if we have to retry This allows the suspense config to kick in and we can wait for much longer before we're forced to give up on hydrating.

Sebastian Markbåge committed Aug 13, 2019 at 18:26 UTC 6fbe630549de1ea7d2c34752880459f854c4440d
6 files changed +248 -28
fixtures/ssr/src/components/Chrome.js
+10 -1
@@ -26,7 +26,16 @@ export default class Chrome extends Component {
26 <Theme.Provider value={this.state.theme}>
27 {this.props.children}
28 <div>
29 - <ThemeToggleButton onChange={theme => this.setState({theme})} />
29 + <ThemeToggleButton
30 + onChange={theme => {
31 + React.unstable_withSuspenseConfig(
32 + () => {
33 + this.setState({theme});
34 + },
35 + {timeoutMs: 6000}
36 + );
37 + }}
38 + />
39 </div>
40 </Theme.Provider>
41 <script
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+172 -12
@@ -277,7 +277,7 @@ describe('ReactDOMServerPartialHydration', () => {
277 expect(container.firstChild.children[1].textContent).toBe('After');
278 });
279
280 - it('regenerates the content if props have changed before hydration completes', async () => {
280 + it('blocks updates to hydrate the content first if props have changed', async () => {
281 let suspend = false;
282 let resolve;
283 let promise = new Promise(resolvePromise => (resolve = resolvePromise));
@@ -331,14 +331,14 @@ describe('ReactDOMServerPartialHydration', () => {
331 resolve();
332 await promise;
333
334 - // Flushing both of these in the same batch won't be able to hydrate so we'll
335 - // probably throw away the existing subtree.
334 + // This should first complete the hydration and then flush the update onto the hydrated state.
335 Scheduler.unstable_flushAll();
336 jest.runAllTimers();
337
339 - // Pick up the new span. In an ideal implementation this might be the same span
340 - // but patched up. At the time of writing, this will be a new span though.
341 - span = container.getElementsByTagName('span')[0];
338 + // The new span should be the same since we should have successfully hydrated
339 + // before changing it.
340 + let newSpan = container.getElementsByTagName('span')[0];
341 + expect(span).toBe(newSpan);
342
343 // We should now have fully rendered with a ref on the new span.
344 expect(ref.current).toBe(span);
@@ -562,7 +562,87 @@ describe('ReactDOMServerPartialHydration', () => {
562 expect(container.textContent).toBe('Hi Hi');
563 });
564
565 - it('regenerates the content if context has changed before hydration completes', async () => {
565 + it('hydrates first if props changed but we are able to resolve within a timeout', async () => {
566 + let suspend = false;
567 + let resolve;
568 + let promise = new Promise(resolvePromise => (resolve = resolvePromise));
569 + let ref = React.createRef();
570 +
571 + function Child({text}) {
572 + if (suspend) {
573 + throw promise;
574 + } else {
575 + return text;
576 + }
577 + }
578 +
579 + function App({text, className}) {
580 + return (
581 + <div>
582 + <Suspense fallback="Loading...">
583 + <span ref={ref} className={className}>
584 + <Child text={text} />
585 + </span>
586 + </Suspense>
587 + </div>
588 + );
589 + }
590 +
591 + suspend = false;
592 + let finalHTML = ReactDOMServer.renderToString(
593 + <App text="Hello" className="hello" />,
594 + );
595 + let container = document.createElement('div');
596 + container.innerHTML = finalHTML;
597 +
598 + let span = container.getElementsByTagName('span')[0];
599 +
600 + // On the client we don't have all data yet but we want to start
601 + // hydrating anyway.
602 + suspend = true;
603 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
604 + root.render(<App text="Hello" className="hello" />);
605 + Scheduler.unstable_flushAll();
606 + jest.runAllTimers();
607 +
608 + expect(ref.current).toBe(null);
609 + expect(container.textContent).toBe('Hello');
610 +
611 + // Render an update with a long timeout.
612 + React.unstable_withSuspenseConfig(
613 + () => root.render(<App text="Hi" className="hi" />),
614 + {timeoutMs: 5000},
615 + );
616 +
617 + // This shouldn't force the fallback yet.
618 + Scheduler.unstable_flushAll();
619 +
620 + expect(ref.current).toBe(null);
621 + expect(container.textContent).toBe('Hello');
622 +
623 + // Resolving the promise so that rendering can complete.
624 + suspend = false;
625 + resolve();
626 + await promise;
627 +
628 + // This should first complete the hydration and then flush the update onto the hydrated state.
629 + Scheduler.unstable_flushAll();
630 + jest.runAllTimers();
631 +
632 + // The new span should be the same since we should have successfully hydrated
633 + // before changing it.
634 + let newSpan = container.getElementsByTagName('span')[0];
635 + expect(span).toBe(newSpan);
636 +
637 + // We should now have fully rendered with a ref on the new span.
638 + expect(ref.current).toBe(span);
639 + expect(container.textContent).toBe('Hi');
640 + // If we ended up hydrating the existing content, we won't have properly
641 + // patched up the tree, which might mean we haven't patched the className.
642 + expect(span.className).toBe('hi');
643 + });
644 +
645 + it('blocks the update to hydrate first if context has changed', async () => {
646 let suspend = false;
647 let resolve;
648 let promise = new Promise(resolvePromise => (resolve = resolvePromise));
@@ -630,14 +710,13 @@ describe('ReactDOMServerPartialHydration', () => {
710 resolve();
711 await promise;
712
633 - // Flushing both of these in the same batch won't be able to hydrate so we'll
634 - // probably throw away the existing subtree.
713 + // This should first complete the hydration and then flush the update onto the hydrated state.
714 Scheduler.unstable_flushAll();
715 jest.runAllTimers();
716
638 - // Pick up the new span. In an ideal implementation this might be the same span
639 - // but patched up. At the time of writing, this will be a new span though.
640 - span = container.getElementsByTagName('span')[0];
717 + // Since this should have been hydrated, this should still be the same span.
718 + let newSpan = container.getElementsByTagName('span')[0];
719 + expect(newSpan).toBe(span);
720
721 // We should now have fully rendered with a ref on the new span.
722 expect(ref.current).toBe(span);
@@ -1421,4 +1500,85 @@ describe('ReactDOMServerPartialHydration', () => {
1500 expect(ref1.current).toBe(span1);
1501 expect(ref2.current).toBe(span2);
1502 });
1503 +
1504 + it('regenerates if it cannot hydrate before changes to props/context expire', async () => {
1505 + let suspend = false;
1506 + let promise = new Promise(resolvePromise => {});
1507 + let ref = React.createRef();
1508 + let ClassName = React.createContext(null);
1509 +
1510 + function Child({text}) {
1511 + let className = React.useContext(ClassName);
1512 + if (suspend && className !== 'hi' && text !== 'Hi') {
1513 + // Never suspends on the newer data.
1514 + throw promise;
1515 + } else {
1516 + return (
1517 + <span ref={ref} className={className}>
1518 + {text}
1519 + </span>
1520 + );
1521 + }
1522 + }
1523 +
1524 + function App({text, className}) {
1525 + return (
1526 + <div>
1527 + <Suspense fallback="Loading...">
1528 + <Child text={text} />
1529 + </Suspense>
1530 + </div>
1531 + );
1532 + }
1533 +
1534 + suspend = false;
1535 + let finalHTML = ReactDOMServer.renderToString(
1536 + <ClassName.Provider value={'hello'}>
1537 + <App text="Hello" />
1538 + </ClassName.Provider>,
1539 + );
1540 + let container = document.createElement('div');
1541 + container.innerHTML = finalHTML;
1542 +
1543 + let span = container.getElementsByTagName('span')[0];
1544 +
1545 + // On the client we don't have all data yet but we want to start
1546 + // hydrating anyway.
1547 + suspend = true;
1548 + let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
1549 + root.render(
1550 + <ClassName.Provider value={'hello'}>
1551 + <App text="Hello" />
1552 + </ClassName.Provider>,
1553 + );
1554 + Scheduler.unstable_flushAll();
1555 + jest.runAllTimers();
1556 +
1557 + expect(ref.current).toBe(null);
1558 + expect(span.textContent).toBe('Hello');
1559 +
1560 + // Render an update, which will be higher or the same priority as pinging the hydration.
1561 + // The new update doesn't suspend.
1562 + root.render(
1563 + <ClassName.Provider value={'hi'}>
1564 + <App text="Hi" />
1565 + </ClassName.Provider>,
1566 + );
1567 +
1568 + // Since we're still suspended on the original data, we can't hydrate.
1569 + // This will force all expiration times to flush.
1570 + Scheduler.unstable_flushAll();
1571 + jest.runAllTimers();
1572 +
1573 + // This will now be a new span because we weren't able to hydrate before
1574 + let newSpan = container.getElementsByTagName('span')[0];
1575 + expect(newSpan).not.toBe(span);
1576 +
1577 + // We should now have fully rendered with a ref on the new span.
1578 + expect(ref.current).toBe(newSpan);
1579 + expect(newSpan.textContent).toBe('Hi');
1580 + // If we ended up hydrating the existing content, we won't have properly
1581 + // patched up the tree, which might mean we haven't patched the className.
1582 + expect(newSpan.className).toBe('hi');
1583 + });
1584 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+32 -7
@@ -171,7 +171,9 @@ import {
171 import {
172 markSpawnedWork,
173 requestCurrentTime,
174 - retryTimedOutBoundary,
174 + retryDehydratedSuspenseBoundary,
175 + scheduleWork,
176 + renderDidSuspendDelayIfPossible,
177 } from './ReactFiberWorkLoop';
178
179 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -1476,6 +1478,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
1478
1479 const SUSPENDED_MARKER: SuspenseState = {
1480 dehydrated: null,
1481 + retryTime: Never,
1482 };
1483
1484 function shouldRemainOnFallback(
@@ -1672,6 +1675,7 @@ function updateSuspenseComponent(
1675 current,
1676 workInProgress,
1677 dehydrated,
1678 + prevState,
1679 renderExpirationTime,
1680 );
1681 } else if (
@@ -2004,6 +2008,7 @@ function updateDehydratedSuspenseComponent(
2008 current: Fiber,
2009 workInProgress: Fiber,
2010 suspenseInstance: SuspenseInstance,
2011 + suspenseState: SuspenseState,
2012 renderExpirationTime: ExpirationTime,
2013 ): null | Fiber {
2014 // We should never be hydrating at this point because it is the first pass,
@@ -2033,11 +2038,31 @@ function updateDehydratedSuspenseComponent(
2038 const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
2039 if (didReceiveUpdate || hasContextChanged) {
2040 // This boundary has changed since the first render. This means that we are now unable to
2036 - // hydrate it. We might still be able to hydrate it using an earlier expiration time but
2037 - // during this render we can't. Instead, we're going to delete the whole subtree and
2038 - // instead inject a new real Suspense boundary to take its place, which may render content
2039 - // or fallback. The real Suspense boundary will suspend for a while so we have some time
2040 - // to ensure it can produce real content, but all state and pending events will be lost.
2041 + // hydrate it. We might still be able to hydrate it using an earlier expiration time, if
2042 + // we are rendering at lower expiration than sync.
2043 + if (renderExpirationTime < Sync) {
2044 + if (suspenseState.retryTime <= renderExpirationTime) {
2045 + // This render is even higher pri than we've seen before, let's try again
2046 + // at even higher pri.
2047 + let attemptHydrationAtExpirationTime = renderExpirationTime + 1;
2048 + suspenseState.retryTime = attemptHydrationAtExpirationTime;
2049 + scheduleWork(current, attemptHydrationAtExpirationTime);
2050 + // TODO: Early abort this render.
2051 + } else {
2052 + // We have already tried to ping at a higher priority than we're rendering with
2053 + // so if we got here, we must have failed to hydrate at those levels. We must
2054 + // now give up. Instead, we're going to delete the whole subtree and instead inject
2055 + // a new real Suspense boundary to take its place, which may render content
2056 + // or fallback. This might suspend for a while and if it does we might still have
2057 + // an opportunity to hydrate before this pass commits.
2058 + }
2059 + }
2060 + // If we have scheduled higher pri work above, this will probably just abort the render
2061 + // since we now have higher priority work, but in case it doesn't, we need to prepare to
2062 + // render something, if we time out. Even if that requires us to delete everything and
2063 + // skip hydration.
2064 + // Delay having to do this as long as the suspense timeout allows us.
2065 + renderDidSuspendDelayIfPossible();
2066 return retrySuspenseComponentWithoutHydrating(
2067 current,
2068 workInProgress,
@@ -2059,7 +2084,7 @@ function updateDehydratedSuspenseComponent(
2084 // Register a callback to retry this boundary once the server has sent the result.
2085 registerSuspenseInstanceRetry(
2086 suspenseInstance,
2062 - retryTimedOutBoundary.bind(null, current),
2087 + retryDehydratedSuspenseBoundary.bind(null, current),
2088 );
2089 return null;
2090 } else {
packages/react-reconciler/src/ReactFiberHydrationContext.js
+2
@@ -55,6 +55,7 @@ import {
55 } from './ReactFiberHostConfig';
56 import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
57 import warning from 'shared/warning';
58 +import {Never} from './ReactFiberExpirationTime';
59
60 // The deepest Fiber on the stack involved in a hydration context.
61 // This may have been an insertion or a hydration.
@@ -229,6 +230,7 @@ function tryHydrate(fiber, nextInstance) {
230 if (suspenseInstance !== null) {
231 const suspenseState: SuspenseState = {
232 dehydrated: suspenseInstance,
233 + retryTime: Never,
234 };
235 fiber.memoizedState = suspenseState;
236 // Store the dehydrated fragment as a child fiber.
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+4
@@ -9,6 +9,7 @@
9
10 import type {Fiber} from './ReactFiber';
11 import type {SuspenseInstance} from './ReactFiberHostConfig';
12 +import type {ExpirationTime} from './ReactFiberExpirationTime';
13 import {SuspenseComponent, SuspenseListComponent} from 'shared/ReactWorkTags';
14 import {NoEffect, DidCapture} from 'shared/ReactSideEffectTags';
15 import {
@@ -28,6 +29,9 @@ export type SuspenseState = {|
29 // here to indicate that it is dehydrated (flag) and for quick access
30 // to check things like isSuspenseInstancePending.
31 dehydrated: null | SuspenseInstance,
32 + // Represents the earliest expiration time we should attempt to hydrate
33 + // a dehydrated boundary at. Never is the default.
34 + retryTime: ExpirationTime,
35 |};
36
37 export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+28 -8
@@ -16,6 +16,7 @@ import type {
16 } from './SchedulerWithReactIntegration';
17 import type {Interaction} from 'scheduler/src/Tracing';
18 import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
19 +import type {SuspenseState} from './ReactFiberSuspenseComponent';
20
21 import {
22 warnAboutDeprecatedLifecycles,
@@ -2160,18 +2161,23 @@ export function pingSuspendedRoot(
2161 scheduleCallbackForRoot(root, priorityLevel, suspendedTime);
2162 }
2163
2163 -export function retryTimedOutBoundary(boundaryFiber: Fiber) {
2164 +function retryTimedOutBoundary(
2165 + boundaryFiber: Fiber,
2166 + retryTime: ExpirationTime,
2167 +) {
2168 // The boundary fiber (a Suspense component or SuspenseList component)
2169 // previously was rendered in its fallback state. One of the promises that
2170 // suspended it has resolved, which means at least part of the tree was
2171 // likely unblocked. Try rendering again, at a new expiration time.
2172 const currentTime = requestCurrentTime();
2169 - const suspenseConfig = null; // Retries don't carry over the already committed update.
2170 - const retryTime = computeExpirationForFiber(
2171 - currentTime,
2172 - boundaryFiber,
2173 - suspenseConfig,
2174 - );
2173 + if (retryTime === Never) {
2174 + const suspenseConfig = null; // Retries don't carry over the already committed update.
2175 + retryTime = computeExpirationForFiber(
2176 + currentTime,
2177 + boundaryFiber,
2178 + suspenseConfig,
2179 + );
2180 + }
2181 // TODO: Special case idle priority?
2182 const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime);
2183 const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime);
@@ -2180,12 +2186,26 @@ export function retryTimedOutBoundary(boundaryFiber: Fiber) {
2186 }
2187 }
2188
2189 +export function retryDehydratedSuspenseBoundary(boundaryFiber: Fiber) {
2190 + const suspenseState: null | SuspenseState = boundaryFiber.memoizedState;
2191 + let retryTime = Never;
2192 + if (suspenseState !== null) {
2193 + retryTime = suspenseState.retryTime;
2194 + }
2195 + retryTimedOutBoundary(boundaryFiber, retryTime);
2196 +}
2197 +
2198 export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) {
2199 + let retryTime = Never; // Default
2200 let retryCache: WeakSet<Thenable> | Set<Thenable> | null;
2201 if (enableSuspenseServerRenderer) {
2202 switch (boundaryFiber.tag) {
2203 case SuspenseComponent:
2204 retryCache = boundaryFiber.stateNode;
2205 + const suspenseState: null | SuspenseState = boundaryFiber.memoizedState;
2206 + if (suspenseState !== null) {
2207 + retryTime = suspenseState.retryTime;
2208 + }
2209 break;
2210 default:
2211 invariant(
@@ -2204,7 +2224,7 @@ export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) {
2224 retryCache.delete(thenable);
2225 }
2226
2207 - retryTimedOutBoundary(boundaryFiber);
2227 + retryTimedOutBoundary(boundaryFiber, retryTime);
2228 }
2229
2230 // Computes the next Just Noticeable Difference (JND) boundary.