Skip to content

Commit 6d07635

Browse files
acdlitetyao1
authored andcommitted
Force unwind work loop during selective hydration (#25695)
When an update flows into a dehydrated boundary, React cannot apply the update until the boundary has finished hydrating. The way this currently works is by scheduling a slightly higher priority task on the boundary, using a special lane that's reserved only for this purpose. Because the task is slightly higher priority, on the next turn of the work loop, the Scheduler will force the work loop to yield (i.e. shouldYield starts returning `true` because there's a higher priority task). The downside of this approach is that it only works when time slicing is enabled. It doesn't work for synchronous updates, because the synchronous work loop does not consult the Scheduler on each iteration. We plan to add support for selective hydration during synchronous updates, too, so we need to model this some other way. I've added a special internal exception that can be thrown to force the work loop to interrupt the work-in-progress tree. Because it's thrown from a React-only execution stack, throwing isn't strictly necessary — we could instead modify some internal work loop state. But using an exception means we don't need to check for this case on every iteration of the work loop. So doing it this way moves the check out of the fast path. The ideal implementation wouldn't need to unwind the stack at all — we should be able to hydrate the subtree and then apply the update all within a single render phase. This is how we intend to implement it in the future, but this requires a refactor to how we handle "stack" variables, which are currently pushed to a per-render array. We need to make this stack resumable, like how context works in Flight and Fizz.
1 parent d4bc16a commit 6d07635

File tree

3 files changed

+69
-18
lines changed

3 files changed

+69
-18
lines changed

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,12 +1496,10 @@ describe('ReactDOMServerSelectiveHydration', () => {
14961496
// Start rendering. This will force the first boundary to hydrate
14971497
// by scheduling it at one higher pri than Idle.
14981498
expect(Scheduler).toFlushAndYieldThrough([
1499-
// An update was scheduled to force hydrate the boundary, but React will
1500-
// continue rendering at Idle until the next time React yields. This is
1501-
// fine though because it will switch to the hydration level when it
1502-
// re-enters the work loop.
15031499
'App',
1504-
'AA',
1500+
1501+
// Start hydrating A
1502+
'A',
15051503
]);
15061504

15071505
// Hover over A which (could) schedule at one higher pri than Idle.

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,14 @@ import {
282282

283283
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
284284

285+
// A special exception that's used to unwind the stack when an update flows
286+
// into a dehydrated boundary.
287+
export const SelectiveHydrationException: mixed = new Error(
288+
"This is not a real error. It's an implementation detail of React's " +
289+
"selective hydration feature. If this leaks into userspace, it's a bug in " +
290+
'React. Please file an issue.',
291+
);
292+
285293
let didReceiveUpdate: boolean = false;
286294

287295
let didWarnAboutBadClass;
@@ -2860,6 +2868,16 @@ function updateDehydratedSuspenseComponent(
28602868
attemptHydrationAtLane,
28612869
eventTime,
28622870
);
2871+
2872+
// Throw a special object that signals to the work loop that it should
2873+
// interrupt the current render.
2874+
//
2875+
// Because we're inside a React-only execution stack, we don't
2876+
// strictly need to throw here — we could instead modify some internal
2877+
// work loop state. But using an exception means we don't need to
2878+
// check for this case on every iteration of the work loop. So doing
2879+
// it this way moves the check out of the fast path.
2880+
throw SelectiveHydrationException;
28632881
} else {
28642882
// We have already tried to ping at a higher priority than we're rendering with
28652883
// so if we got here, we must have failed to hydrate at those levels. We must
@@ -2870,15 +2888,17 @@ function updateDehydratedSuspenseComponent(
28702888
}
28712889
}
28722890

2873-
// If we have scheduled higher pri work above, this will just abort the render
2874-
// since we now have higher priority work. We'll try to infinitely suspend until
2875-
// we yield. TODO: We could probably just force yielding earlier instead.
2876-
renderDidSuspendDelayIfPossible();
2877-
// If we rendered synchronously, we won't yield so have to render something.
2878-
// This will cause us to delete any existing content.
2891+
// If we did not selectively hydrate, we'll continue rendering without
2892+
// hydrating. Mark this tree as suspended to prevent it from committing
2893+
// outside a transition.
2894+
//
2895+
// This path should only happen if the hydration lane already suspended.
2896+
// Currently, it also happens during sync updates because there is no
2897+
// hydration lane for sync updates.
28792898
// TODO: We should ideally have a sync hydration lane that we can apply to do
28802899
// a pass where we hydrate this subtree in place using the previous Context and then
28812900
// reapply the update afterwards.
2901+
renderDidSuspendDelayIfPossible();
28822902
return retrySuspenseComponentWithoutHydrating(
28832903
current,
28842904
workInProgress,

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ import {
175175
} from './ReactEventPriorities';
176176
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
177177
import {
178+
SelectiveHydrationException,
178179
beginWork as originalBeginWork,
179180
replayFunctionComponent,
180181
} from './ReactFiberBeginWork';
@@ -316,13 +317,14 @@ let workInProgress: Fiber | null = null;
316317
// The lanes we're rendering
317318
let workInProgressRootRenderLanes: Lanes = NoLanes;
318319

319-
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5;
320+
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6;
320321
const NotSuspended: SuspendedReason = 0;
321322
const SuspendedOnError: SuspendedReason = 1;
322323
const SuspendedOnData: SuspendedReason = 2;
323324
const SuspendedOnImmediate: SuspendedReason = 3;
324325
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 4;
325326
const SuspendedAndReadyToUnwind: SuspendedReason = 5;
327+
const SuspendedOnHydration: SuspendedReason = 6;
326328

327329
// When this is true, the work-in-progress fiber just suspended (or errored) and
328330
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -1797,6 +1799,17 @@ function handleThrow(root, thrownValue): void {
17971799
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
17981800
? SuspendedOnData
17991801
: SuspendedOnImmediate;
1802+
} else if (thrownValue === SelectiveHydrationException) {
1803+
// An update flowed into a dehydrated boundary. Before we can apply the
1804+
// update, we need to finish hydrating. Interrupt the work-in-progress
1805+
// render so we can restart at the hydration lane.
1806+
//
1807+
// The ideal implementation would be able to switch contexts without
1808+
// unwinding the current stack.
1809+
//
1810+
// We could name this something more general but as of now it's the only
1811+
// case where we think this should happen.
1812+
workInProgressSuspendedReason = SuspendedOnHydration;
18001813
} else {
18011814
// This is a regular error.
18021815
const isWakeable =
@@ -2038,7 +2051,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20382051
markRenderStarted(lanes);
20392052
}
20402053

2041-
do {
2054+
outer: do {
20422055
try {
20432056
if (
20442057
workInProgressSuspendedReason !== NotSuspended &&
@@ -2054,11 +2067,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20542067
// function and fork the behavior some other way.
20552068
const unitOfWork = workInProgress;
20562069
const thrownValue = workInProgressThrownValue;
2057-
workInProgressSuspendedReason = NotSuspended;
2058-
workInProgressThrownValue = null;
2059-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2060-
2061-
// Continue with the normal work loop.
2070+
switch (workInProgressSuspendedReason) {
2071+
case SuspendedOnHydration: {
2072+
// Selective hydration. An update flowed into a dehydrated tree.
2073+
// Interrupt the current render so the work loop can switch to the
2074+
// hydration lane.
2075+
workInProgress = null;
2076+
workInProgressRootExitStatus = RootDidNotComplete;
2077+
break outer;
2078+
}
2079+
default: {
2080+
// Continue with the normal work loop.
2081+
workInProgressSuspendedReason = NotSuspended;
2082+
workInProgressThrownValue = null;
2083+
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2084+
break;
2085+
}
2086+
}
20622087
}
20632088
workLoopSync();
20642089
break;
@@ -2216,6 +2241,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
22162241
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
22172242
break;
22182243
}
2244+
case SuspendedOnHydration: {
2245+
// Selective hydration. An update flowed into a dehydrated tree.
2246+
// Interrupt the current render so the work loop can switch to the
2247+
// hydration lane.
2248+
workInProgress = null;
2249+
workInProgressRootExitStatus = RootDidNotComplete;
2250+
break outer;
2251+
}
22192252
default: {
22202253
throw new Error(
22212254
'Unexpected SuspendedReason. This is a bug in React.',

0 commit comments

Comments
 (0)