Skip to content

Commit defe814

Browse files
acdliteAndyPengc12
authored andcommitted
useDeferredValue has higher priority than partial hydration (facebook#27550)
By default, partial hydration is given the lowest possible priority, because until a tree is updated, the server-rendered HTML is assumed to match the final resolved HTML. However, this isn't completely true because a component may choose to "upgrade" itself upon hydration. The simplest example is a component that calls setState in a useEffect to switch to a richer implementation of the UI. Another example is a component that doesn't have a server- rendered implementation, so it intentionally suspends to force a client- only render. useDeferredValue is an example, too: the server only renders the first pass (the initialValue) argument, and relies on the client to upgrade to the final value. What we should really do in these cases is emit some information into the Fizz stream so that Fiber knows to prioritize the hydration of certain trees. We plan to add a mechanism for this in the future. In the meantime, though, we can at least ensure that the priority of the upgrade task is correct once it's "discovered" during hydration. In this case, the priority of the task spawned by useDeferredValue should have Transition priority, not Offscreen priority.
1 parent 74c1f2a commit defe814

File tree

3 files changed

+141
-10
lines changed

3 files changed

+141
-10
lines changed

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

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,39 @@
99

1010
'use strict';
1111

12-
import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils';
12+
import {
13+
insertNodesAndExecuteScripts,
14+
getVisibleChildren,
15+
} from '../test-utils/FizzTestUtils';
1316

1417
// Polyfills for test environment
1518
global.ReadableStream =
1619
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
1720
global.TextEncoder = require('util').TextEncoder;
1821

1922
let act;
23+
let assertLog;
24+
let waitForPaint;
2025
let container;
2126
let React;
27+
let Scheduler;
2228
let ReactDOMServer;
2329
let ReactDOMClient;
2430
let useDeferredValue;
31+
let Suspense;
2532

2633
describe('ReactDOMFizzForm', () => {
2734
beforeEach(() => {
2835
jest.resetModules();
2936
React = require('react');
37+
Scheduler = require('scheduler');
3038
ReactDOMServer = require('react-dom/server.browser');
3139
ReactDOMClient = require('react-dom/client');
32-
useDeferredValue = require('react').useDeferredValue;
40+
useDeferredValue = React.useDeferredValue;
41+
Suspense = React.Suspense;
3342
act = require('internal-test-utils').act;
43+
assertLog = require('internal-test-utils').assertLog;
44+
waitForPaint = require('internal-test-utils').waitForPaint;
3445
container = document.createElement('div');
3546
document.body.appendChild(container);
3647
});
@@ -54,6 +65,11 @@ describe('ReactDOMFizzForm', () => {
5465
insertNodesAndExecuteScripts(temp, container, null);
5566
}
5667

68+
function Text({text}) {
69+
Scheduler.log(text);
70+
return text;
71+
}
72+
5773
// @gate enableUseDeferredValueInitialArg
5874
it('returns initialValue argument, if provided', async () => {
5975
function App() {
@@ -68,4 +84,106 @@ describe('ReactDOMFizzForm', () => {
6884
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
6985
expect(container.textContent).toEqual('Final');
7086
});
87+
88+
// @gate enableUseDeferredValueInitialArg
89+
it(
90+
'useDeferredValue during hydration has higher priority than remaining ' +
91+
'incremental hydration',
92+
async () => {
93+
function B() {
94+
const text = useDeferredValue('B [Final]', 'B [Initial]');
95+
return <Text text={text} />;
96+
}
97+
98+
function App() {
99+
return (
100+
<div>
101+
<span>
102+
<Text text="A" />
103+
</span>
104+
<Suspense fallback={<Text text="Loading..." />}>
105+
<span>
106+
<B />
107+
</span>
108+
<div>
109+
<Suspense fallback={<Text text="Loading..." />}>
110+
<span id="C" ref={cRef}>
111+
<Text text="C" />
112+
</span>
113+
</Suspense>
114+
</div>
115+
</Suspense>
116+
</div>
117+
);
118+
}
119+
120+
const cRef = React.createRef();
121+
122+
// The server renders using the "initial" value for B.
123+
const stream = await ReactDOMServer.renderToReadableStream(<App />);
124+
await readIntoContainer(stream);
125+
assertLog(['A', 'B [Initial]', 'C']);
126+
expect(getVisibleChildren(container)).toEqual(
127+
<div>
128+
<span>A</span>
129+
<span>B [Initial]</span>
130+
<div>
131+
<span id="C">C</span>
132+
</div>
133+
</div>,
134+
);
135+
136+
const serverRenderedC = document.getElementById('C');
137+
138+
// On the client, we first hydrate the initial value, then upgrade
139+
// to final.
140+
await act(async () => {
141+
ReactDOMClient.hydrateRoot(container, <App />);
142+
143+
// First the outermost Suspense boundary hydrates.
144+
await waitForPaint(['A']);
145+
expect(cRef.current).toBe(null);
146+
147+
// Then the next level hydrates. This level includes a useDeferredValue,
148+
// so we should prioritize upgrading it before we proceed to hydrating
149+
// additional levels.
150+
await waitForPaint(['B [Initial]']);
151+
expect(getVisibleChildren(container)).toEqual(
152+
<div>
153+
<span>A</span>
154+
<span>B [Initial]</span>
155+
<div>
156+
<span id="C">C</span>
157+
</div>
158+
</div>,
159+
);
160+
expect(cRef.current).toBe(null);
161+
162+
// This paint should only update B. C should still be dehydrated.
163+
await waitForPaint(['B [Final]']);
164+
expect(getVisibleChildren(container)).toEqual(
165+
<div>
166+
<span>A</span>
167+
<span>B [Final]</span>
168+
<div>
169+
<span id="C">C</span>
170+
</div>
171+
</div>,
172+
);
173+
expect(cRef.current).toBe(null);
174+
});
175+
// Finally we can hydrate C
176+
assertLog(['C']);
177+
expect(getVisibleChildren(container)).toEqual(
178+
<div>
179+
<span>A</span>
180+
<span>B [Final]</span>
181+
<div>
182+
<span id="C">C</span>
183+
</div>
184+
</div>,
185+
);
186+
expect(cRef.current).toBe(serverRenderedC);
187+
},
188+
);
71189
});

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,8 +730,10 @@ function markSpawnedDeferredLane(
730730
root.entanglements[spawnedLaneIndex] |=
731731
DeferredLane |
732732
// If the parent render task suspended, we must also entangle those lanes
733-
// with the spawned task.
734-
entangledLanes;
733+
// with the spawned task, so that the deferred task includes all the same
734+
// updates that the parent task did. We can exclude any lane that is not
735+
// used for updates (e.g. Offscreen).
736+
(entangledLanes & UpdateLanes);
735737
}
736738

737739
export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ import {
8383
resetWorkInProgress,
8484
} from './ReactFiber';
8585
import {isRootDehydrated} from './ReactFiberShellHydration';
86-
import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext';
86+
import {
87+
getIsHydrating,
88+
didSuspendOrErrorWhileHydratingDEV,
89+
} from './ReactFiberHydrationContext';
8790
import {
8891
NoMode,
8992
ProfileMode,
@@ -690,13 +693,21 @@ export function requestDeferredLane(): Lane {
690693
// If there are multiple useDeferredValue hooks in the same render, the
691694
// tasks that they spawn should all be batched together, so they should all
692695
// receive the same lane.
693-
if (includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)) {
696+
697+
// Check the priority of the current render to decide the priority of the
698+
// deferred task.
699+
700+
// OffscreenLane is used for prerendering, but we also use OffscreenLane
701+
// for incremental hydration. It's given the lowest priority because the
702+
// initial HTML is the same as the final UI. But useDeferredValue during
703+
// hydration is an exception — we need to upgrade the UI to the final
704+
// value. So if we're currently hydrating, we treat it like a transition.
705+
const isPrerendering =
706+
includesSomeLane(workInProgressRootRenderLanes, OffscreenLane) &&
707+
!getIsHydrating();
708+
if (isPrerendering) {
694709
// There's only one OffscreenLane, so if it contains deferred work, we
695710
// should just reschedule using the same lane.
696-
// TODO: We also use OffscreenLane for hydration, on the basis that the
697-
// initial HTML is the same as the hydrated UI, but since the deferred
698-
// task will change the UI, it should be treated like an update. Use
699-
// TransitionHydrationLane to trigger selective hydration.
700711
workInProgressDeferredLane = OffscreenLane;
701712
} else {
702713
// Everything else is spawned as a transition.

0 commit comments

Comments
 (0)