Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6fbe630

Browse files
authored
[Partial Hydration] Attempt hydration at a higher pri first if props/context changes (facebook#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.
1 parent e0a521b commit 6fbe630

File tree

6 files changed

+248
-28
lines changed

6 files changed

+248
-28
lines changed

fixtures/ssr/src/components/Chrome.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,16 @@ export default class Chrome extends Component {
2626
<Theme.Provider value={this.state.theme}>
2727
{this.props.children}
2828
<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+
/>
3039
</div>
3140
</Theme.Provider>
3241
<script

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

Lines changed: 172 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ describe('ReactDOMServerPartialHydration', () => {
277277
expect(container.firstChild.children[1].textContent).toBe('After');
278278
});
279279

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 () => {
281281
let suspend = false;
282282
let resolve;
283283
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
@@ -331,14 +331,14 @@ describe('ReactDOMServerPartialHydration', () => {
331331
resolve();
332332
await promise;
333333

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.
336335
Scheduler.unstable_flushAll();
337336
jest.runAllTimers();
338337

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);
342342

343343
// We should now have fully rendered with a ref on the new span.
344344
expect(ref.current).toBe(span);
@@ -562,7 +562,87 @@ describe('ReactDOMServerPartialHydration', () => {
562562
expect(container.textContent).toBe('Hi Hi');
563563
});
564564

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 () => {
566646
let suspend = false;
567647
let resolve;
568648
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
@@ -630,14 +710,13 @@ describe('ReactDOMServerPartialHydration', () => {
630710
resolve();
631711
await promise;
632712

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.
635714
Scheduler.unstable_flushAll();
636715
jest.runAllTimers();
637716

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);
641720

642721
// We should now have fully rendered with a ref on the new span.
643722
expect(ref.current).toBe(span);
@@ -1421,4 +1500,85 @@ describe('ReactDOMServerPartialHydration', () => {
14211500
expect(ref1.current).toBe(span1);
14221501
expect(ref2.current).toBe(span2);
14231502
});
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+
});
14241584
});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ import {
171171
import {
172172
markSpawnedWork,
173173
requestCurrentTime,
174-
retryTimedOutBoundary,
174+
retryDehydratedSuspenseBoundary,
175+
scheduleWork,
176+
renderDidSuspendDelayIfPossible,
175177
} from './ReactFiberWorkLoop';
176178

177179
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -1476,6 +1478,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
14761478

14771479
const SUSPENDED_MARKER: SuspenseState = {
14781480
dehydrated: null,
1481+
retryTime: Never,
14791482
};
14801483

14811484
function shouldRemainOnFallback(
@@ -1672,6 +1675,7 @@ function updateSuspenseComponent(
16721675
current,
16731676
workInProgress,
16741677
dehydrated,
1678+
prevState,
16751679
renderExpirationTime,
16761680
);
16771681
} else if (
@@ -2004,6 +2008,7 @@ function updateDehydratedSuspenseComponent(
20042008
current: Fiber,
20052009
workInProgress: Fiber,
20062010
suspenseInstance: SuspenseInstance,
2011+
suspenseState: SuspenseState,
20072012
renderExpirationTime: ExpirationTime,
20082013
): null | Fiber {
20092014
// We should never be hydrating at this point because it is the first pass,
@@ -2033,11 +2038,31 @@ function updateDehydratedSuspenseComponent(
20332038
const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
20342039
if (didReceiveUpdate || hasContextChanged) {
20352040
// 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();
20412066
return retrySuspenseComponentWithoutHydrating(
20422067
current,
20432068
workInProgress,
@@ -2059,7 +2084,7 @@ function updateDehydratedSuspenseComponent(
20592084
// Register a callback to retry this boundary once the server has sent the result.
20602085
registerSuspenseInstanceRetry(
20612086
suspenseInstance,
2062-
retryTimedOutBoundary.bind(null, current),
2087+
retryDehydratedSuspenseBoundary.bind(null, current),
20632088
);
20642089
return null;
20652090
} else {

packages/react-reconciler/src/ReactFiberHydrationContext.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
} from './ReactFiberHostConfig';
5656
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
5757
import warning from 'shared/warning';
58+
import {Never} from './ReactFiberExpirationTime';
5859

5960
// The deepest Fiber on the stack involved in a hydration context.
6061
// This may have been an insertion or a hydration.
@@ -229,6 +230,7 @@ function tryHydrate(fiber, nextInstance) {
229230
if (suspenseInstance !== null) {
230231
const suspenseState: SuspenseState = {
231232
dehydrated: suspenseInstance,
233+
retryTime: Never,
232234
};
233235
fiber.memoizedState = suspenseState;
234236
// Store the dehydrated fragment as a child fiber.

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type {Fiber} from './ReactFiber';
1111
import type {SuspenseInstance} from './ReactFiberHostConfig';
12+
import type {ExpirationTime} from './ReactFiberExpirationTime';
1213
import {SuspenseComponent, SuspenseListComponent} from 'shared/ReactWorkTags';
1314
import {NoEffect, DidCapture} from 'shared/ReactSideEffectTags';
1415
import {
@@ -28,6 +29,9 @@ export type SuspenseState = {|
2829
// here to indicate that it is dehydrated (flag) and for quick access
2930
// to check things like isSuspenseInstancePending.
3031
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,
3135
|};
3236

3337
export type SuspenseListTailMode = 'collapsed' | 'hidden' | void;

0 commit comments

Comments
 (0)