[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.