Skip to content

Commit 02b65fd

Browse files
committed
Allow updates at lower pri without forcing client render
Currently, if a root is updated before the shell has finished hydrating (for example, due to a top-level navigation), we immediately revert to client rendering. This is rare because the root is expected is finish quickly, but not exceedingly rare because the root may be suspended. This adds support for updating the root without forcing a client render as long as the update has lower priority than the initial hydration, i.e. if the update is wrapped in startTransition. To implement this, I had to do some refactoring. The main idea here is to make it closer to how we implement hydration in Suspense boundaries: - I moved isDehydrated from the shared FiberRoot object to the HostRoot's state object. - In the begin phase, I check if the root has received an by comparing the new children to the initial children. If they are different, we revert to client rendering, and set isDehydrated to false using a derived state update (a la getDerivedStateFromProps). - There are a few places where we used to set root.isDehydrated to false as a way to force a client render. Instead, I set the ForceClientRender flag on the root work-in-progress fiber. - Whenever we fall back to client rendering, I log a recoverable error. The overall code structure is almost identical to the corresponding logic for Suspense components. The reason this works is because if the update has lower priority than the initial hydration, it won't be processed during the hydration render, so the children will be the same. We can go even further and allow updates at _higher_ priority (though not sync) by implementing selective hydration at the root, like we do for Suspense boundaries: interrupt the current render, attempt hydration at slightly higher priority than the update, then continue rendering the update. I haven't implemented this yet, but I've structured the code in anticipation of adding this later.
1 parent 83b941a commit 02b65fd

15 files changed

+435
-233
lines changed

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

+34-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
let JSDOM;
1111
let React;
12+
let startTransition;
1213
let ReactDOMClient;
1314
let Scheduler;
1415
let clientAct;
@@ -33,6 +34,8 @@ describe('ReactDOMFizzShellHydration', () => {
3334
ReactDOMFizzServer = require('react-dom/server');
3435
Stream = require('stream');
3536

37+
startTransition = React.startTransition;
38+
3639
textCache = new Map();
3740

3841
// Test Environment
@@ -214,7 +217,36 @@ describe('ReactDOMFizzShellHydration', () => {
214217
expect(container.textContent).toBe('Shell');
215218
});
216219

217-
test('updating the root before the shell hydrates forces a client render', async () => {
220+
test(
221+
'updating the root at lower priority than initial hydration does not ' +
222+
'force a client render',
223+
async () => {
224+
function App() {
225+
return <Text text="Initial" />;
226+
}
227+
228+
// Server render
229+
await resolveText('Initial');
230+
await serverAct(async () => {
231+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
232+
pipe(writable);
233+
});
234+
expect(Scheduler).toHaveYielded(['Initial']);
235+
236+
await clientAct(async () => {
237+
const root = ReactDOMClient.hydrateRoot(container, <App />);
238+
// This has lower priority than the initial hydration, so the update
239+
// won't be processed until after hydration finishes.
240+
startTransition(() => {
241+
root.render(<Text text="Updated" />);
242+
});
243+
});
244+
expect(Scheduler).toHaveYielded(['Initial', 'Updated']);
245+
expect(container.textContent).toBe('Updated');
246+
},
247+
);
248+
249+
test('updating the root while the shell is suspended forces a client render', async () => {
218250
function App() {
219251
return <AsyncText text="Shell" />;
220252
}
@@ -245,9 +277,9 @@ describe('ReactDOMFizzShellHydration', () => {
245277
root.render(<Text text="New screen" />);
246278
});
247279
expect(Scheduler).toHaveYielded([
280+
'New screen',
248281
'This root received an early update, before anything was able ' +
249282
'hydrate. Switched the entire root to client rendering.',
250-
'New screen',
251283
]);
252284
expect(container.textContent).toBe('New screen');
253285
});

packages/react-reconciler/src/ReactFiberBeginWork.new.js

+115-45
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
* @flow
88
*/
99

10-
import type {ReactProviderType, ReactContext} from 'shared/ReactTypes';
10+
import type {
11+
ReactProviderType,
12+
ReactContext,
13+
ReactNodeList,
14+
} from 'shared/ReactTypes';
1115
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
1216
import type {Fiber, FiberRoot} from './ReactInternalTypes';
1317
import type {TypeOfMode} from './ReactTypeOfMode';
@@ -29,6 +33,7 @@ import type {
2933
SpawnedCachePool,
3034
} from './ReactFiberCacheComponent.new';
3135
import type {UpdateQueue} from './ReactUpdateQueue.new';
36+
import type {RootState} from './ReactFiberRoot.new';
3237
import {
3338
enableSuspenseAvoidThisFallback,
3439
enableCPUSuspense,
@@ -223,7 +228,6 @@ import {
223228
createOffscreenHostContainerFiber,
224229
isSimpleFunctionComponent,
225230
} from './ReactFiber.new';
226-
import {isRootDehydrated} from './ReactFiberShellHydration';
227231
import {
228232
retryDehydratedSuspenseBoundary,
229233
scheduleUpdateOnFiber,
@@ -1312,7 +1316,7 @@ function pushHostRootContext(workInProgress) {
13121316

13131317
function updateHostRoot(current, workInProgress, renderLanes) {
13141318
pushHostRootContext(workInProgress);
1315-
const updateQueue = workInProgress.updateQueue;
1319+
const updateQueue: UpdateQueue<RootState> = (workInProgress.updateQueue: any);
13161320

13171321
if (current === null || updateQueue === null) {
13181322
throw new Error(
@@ -1327,7 +1331,7 @@ function updateHostRoot(current, workInProgress, renderLanes) {
13271331
const prevChildren = prevState.element;
13281332
cloneUpdateQueue(current, workInProgress);
13291333
processUpdateQueue(workInProgress, nextProps, null, renderLanes);
1330-
const nextState = workInProgress.memoizedState;
1334+
const nextState: RootState = workInProgress.memoizedState;
13311335

13321336
const root: FiberRoot = workInProgress.stateNode;
13331337

@@ -1342,64 +1346,130 @@ function updateHostRoot(current, workInProgress, renderLanes) {
13421346
}
13431347

13441348
if (enableTransitionTracing) {
1349+
// FIXME: Slipped past code review. This is not a safe mutation:
1350+
// workInProgress.memoizedState is a shared object. Need to fix before
1351+
// rolling out the Transition Tracing experiment.
13451352
workInProgress.memoizedState.transitions = getWorkInProgressTransitions();
13461353
}
13471354

13481355
// Caution: React DevTools currently depends on this property
13491356
// being called "element".
13501357
const nextChildren = nextState.element;
1351-
if (nextChildren === prevChildren) {
1352-
resetHydrationState();
1353-
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
1354-
}
1355-
if (isRootDehydrated(root) && enterHydrationState(workInProgress)) {
1356-
// If we don't have any current children this might be the first pass.
1357-
// We always try to hydrate. If this isn't a hydration pass there won't
1358-
// be any children to hydrate which is effectively the same thing as
1359-
// not hydrating.
1360-
1361-
if (supportsHydration) {
1362-
const mutableSourceEagerHydrationData =
1363-
root.mutableSourceEagerHydrationData;
1364-
if (mutableSourceEagerHydrationData != null) {
1365-
for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
1366-
const mutableSource = ((mutableSourceEagerHydrationData[
1367-
i
1368-
]: any): MutableSource<any>);
1369-
const version = mutableSourceEagerHydrationData[i + 1];
1370-
setWorkInProgressVersion(mutableSource, version);
1358+
if (supportsHydration && prevState.isDehydrated) {
1359+
// This is a hydration root whose shell has not yet hydrated. We should
1360+
// attempt to hydrate.
1361+
if (workInProgress.flags & ForceClientRender) {
1362+
// Something errored during a previous attempt to hydrate the shell, so we
1363+
// forced a client render.
1364+
const recoverableError = new Error(
1365+
'There was an error while hydrating. Because the error happened outside ' +
1366+
'of a Suspense boundary, the entire root will switch to ' +
1367+
'client rendering.',
1368+
);
1369+
return mountHostRootWithoutHydrating(
1370+
current,
1371+
workInProgress,
1372+
updateQueue,
1373+
nextState,
1374+
nextChildren,
1375+
renderLanes,
1376+
recoverableError,
1377+
);
1378+
} else if (nextChildren !== prevChildren) {
1379+
const recoverableError = new Error(
1380+
'This root received an early update, before anything was able ' +
1381+
'hydrate. Switched the entire root to client rendering.',
1382+
);
1383+
return mountHostRootWithoutHydrating(
1384+
current,
1385+
workInProgress,
1386+
updateQueue,
1387+
nextState,
1388+
nextChildren,
1389+
renderLanes,
1390+
recoverableError,
1391+
);
1392+
} else {
1393+
// The outermost shell has not hydrated yet. Start hydrating.
1394+
enterHydrationState(workInProgress);
1395+
if (supportsHydration) {
1396+
const mutableSourceEagerHydrationData =
1397+
root.mutableSourceEagerHydrationData;
1398+
if (mutableSourceEagerHydrationData != null) {
1399+
for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
1400+
const mutableSource = ((mutableSourceEagerHydrationData[
1401+
i
1402+
]: any): MutableSource<any>);
1403+
const version = mutableSourceEagerHydrationData[i + 1];
1404+
setWorkInProgressVersion(mutableSource, version);
1405+
}
13711406
}
13721407
}
1373-
}
13741408

1375-
const child = mountChildFibers(
1376-
workInProgress,
1377-
null,
1378-
nextChildren,
1379-
renderLanes,
1380-
);
1381-
workInProgress.child = child;
1409+
const child = mountChildFibers(
1410+
workInProgress,
1411+
null,
1412+
nextChildren,
1413+
renderLanes,
1414+
);
1415+
workInProgress.child = child;
13821416

1383-
let node = child;
1384-
while (node) {
1385-
// Mark each child as hydrating. This is a fast path to know whether this
1386-
// tree is part of a hydrating tree. This is used to determine if a child
1387-
// node has fully mounted yet, and for scheduling event replaying.
1388-
// Conceptually this is similar to Placement in that a new subtree is
1389-
// inserted into the React tree here. It just happens to not need DOM
1390-
// mutations because it already exists.
1391-
node.flags = (node.flags & ~Placement) | Hydrating;
1392-
node = node.sibling;
1417+
let node = child;
1418+
while (node) {
1419+
// Mark each child as hydrating. This is a fast path to know whether this
1420+
// tree is part of a hydrating tree. This is used to determine if a child
1421+
// node has fully mounted yet, and for scheduling event replaying.
1422+
// Conceptually this is similar to Placement in that a new subtree is
1423+
// inserted into the React tree here. It just happens to not need DOM
1424+
// mutations because it already exists.
1425+
node.flags = (node.flags & ~Placement) | Hydrating;
1426+
node = node.sibling;
1427+
}
13931428
}
13941429
} else {
1395-
// Otherwise reset hydration state in case we aborted and resumed another
1396-
// root.
1397-
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
1430+
// Root is not dehydrated. Either this is a client-only root, or it
1431+
// already hydrated.
13981432
resetHydrationState();
1433+
if (nextChildren === prevChildren) {
1434+
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
1435+
}
1436+
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
13991437
}
14001438
return workInProgress.child;
14011439
}
14021440

1441+
function mountHostRootWithoutHydrating(
1442+
current: Fiber,
1443+
workInProgress: Fiber,
1444+
updateQueue: UpdateQueue<RootState>,
1445+
nextState: RootState,
1446+
nextChildren: ReactNodeList,
1447+
renderLanes: Lanes,
1448+
recoverableError: Error,
1449+
) {
1450+
// Revert to client rendering.
1451+
resetHydrationState();
1452+
1453+
queueHydrationError(recoverableError);
1454+
1455+
workInProgress.flags |= ForceClientRender;
1456+
1457+
// Flip isDehydrated to false to indicate that when this render
1458+
// finishes, the root will no longer be dehydrated.
1459+
const overrideState: RootState = {
1460+
element: nextChildren,
1461+
isDehydrated: false,
1462+
cache: nextState.cache,
1463+
transitions: nextState.transitions,
1464+
};
1465+
// `baseState` can always be the last state because the root doesn't
1466+
// have reducer functions so it doesn't need rebasing.
1467+
updateQueue.baseState = overrideState;
1468+
workInProgress.memoizedState = overrideState;
1469+
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
1470+
return workInProgress.child;
1471+
}
1472+
14031473
function updateHostComponent(
14041474
current: Fiber | null,
14051475
workInProgress: Fiber,

0 commit comments

Comments
 (0)