Skip to content

Commit 44c4e6f

Browse files
authored
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 7b17f7b commit 44c4e6f

File tree

6 files changed

+151
-34
lines changed

6 files changed

+151
-34
lines changed

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

+3-5
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.new.js

+26-6
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,14 @@ import {
280280

281281
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
282282

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

285293
let didWarnAboutBadClass;
@@ -2810,6 +2818,16 @@ function updateDehydratedSuspenseComponent(
28102818
attemptHydrationAtLane,
28112819
eventTime,
28122820
);
2821+
2822+
// Throw a special object that signals to the work loop that it should
2823+
// interrupt the current render.
2824+
//
2825+
// Because we're inside a React-only execution stack, we don't
2826+
// strictly need to throw here — we could instead modify some internal
2827+
// work loop state. But using an exception means we don't need to
2828+
// check for this case on every iteration of the work loop. So doing
2829+
// it this way moves the check out of the fast path.
2830+
throw SelectiveHydrationException;
28132831
} else {
28142832
// We have already tried to ping at a higher priority than we're rendering with
28152833
// so if we got here, we must have failed to hydrate at those levels. We must
@@ -2820,15 +2838,17 @@ function updateDehydratedSuspenseComponent(
28202838
}
28212839
}
28222840

2823-
// If we have scheduled higher pri work above, this will just abort the render
2824-
// since we now have higher priority work. We'll try to infinitely suspend until
2825-
// we yield. TODO: We could probably just force yielding earlier instead.
2826-
renderDidSuspendDelayIfPossible();
2827-
// If we rendered synchronously, we won't yield so have to render something.
2828-
// This will cause us to delete any existing content.
2841+
// If we did not selectively hydrate, we'll continue rendering without
2842+
// hydrating. Mark this tree as suspended to prevent it from committing
2843+
// outside a transition.
2844+
//
2845+
// This path should only happen if the hydration lane already suspended.
2846+
// Currently, it also happens during sync updates because there is no
2847+
// hydration lane for sync updates.
28292848
// TODO: We should ideally have a sync hydration lane that we can apply to do
28302849
// a pass where we hydrate this subtree in place using the previous Context and then
28312850
// reapply the update afterwards.
2851+
renderDidSuspendDelayIfPossible();
28322852
return retrySuspenseComponentWithoutHydrating(
28332853
current,
28342854
workInProgress,

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

+26-6
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,14 @@ import {
280280

281281
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
282282

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

285293
let didWarnAboutBadClass;
@@ -2810,6 +2818,16 @@ function updateDehydratedSuspenseComponent(
28102818
attemptHydrationAtLane,
28112819
eventTime,
28122820
);
2821+
2822+
// Throw a special object that signals to the work loop that it should
2823+
// interrupt the current render.
2824+
//
2825+
// Because we're inside a React-only execution stack, we don't
2826+
// strictly need to throw here — we could instead modify some internal
2827+
// work loop state. But using an exception means we don't need to
2828+
// check for this case on every iteration of the work loop. So doing
2829+
// it this way moves the check out of the fast path.
2830+
throw SelectiveHydrationException;
28132831
} else {
28142832
// We have already tried to ping at a higher priority than we're rendering with
28152833
// so if we got here, we must have failed to hydrate at those levels. We must
@@ -2820,15 +2838,17 @@ function updateDehydratedSuspenseComponent(
28202838
}
28212839
}
28222840

2823-
// If we have scheduled higher pri work above, this will just abort the render
2824-
// since we now have higher priority work. We'll try to infinitely suspend until
2825-
// we yield. TODO: We could probably just force yielding earlier instead.
2826-
renderDidSuspendDelayIfPossible();
2827-
// If we rendered synchronously, we won't yield so have to render something.
2828-
// This will cause us to delete any existing content.
2841+
// If we did not selectively hydrate, we'll continue rendering without
2842+
// hydrating. Mark this tree as suspended to prevent it from committing
2843+
// outside a transition.
2844+
//
2845+
// This path should only happen if the hydration lane already suspended.
2846+
// Currently, it also happens during sync updates because there is no
2847+
// hydration lane for sync updates.
28292848
// TODO: We should ideally have a sync hydration lane that we can apply to do
28302849
// a pass where we hydrate this subtree in place using the previous Context and then
28312850
// reapply the update afterwards.
2851+
renderDidSuspendDelayIfPossible();
28322852
return retrySuspenseComponentWithoutHydrating(
28332853
current,
28342854
workInProgress,

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

+47-8
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ import {
178178
lanesToEventPriority,
179179
} from './ReactEventPriorities.new';
180180
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
181-
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.new';
181+
import {
182+
SelectiveHydrationException,
183+
beginWork as originalBeginWork,
184+
} from './ReactFiberBeginWork.new';
182185
import {completeWork} from './ReactFiberCompleteWork.new';
183186
import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.new';
184187
import {
@@ -316,12 +319,13 @@ let workInProgress: Fiber | null = null;
316319
// The lanes we're rendering
317320
let workInProgressRootRenderLanes: Lanes = NoLanes;
318321

319-
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
322+
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5;
320323
const NotSuspended: SuspendedReason = 0;
321324
const SuspendedOnError: SuspendedReason = 1;
322325
const SuspendedOnData: SuspendedReason = 2;
323326
const SuspendedOnImmediate: SuspendedReason = 3;
324327
const SuspendedAndReadyToUnwind: SuspendedReason = 4;
328+
const SuspendedOnHydration: SuspendedReason = 5;
325329

326330
// When this is true, the work-in-progress fiber just suspended (or errored) and
327331
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -1775,6 +1779,18 @@ function handleThrow(root, thrownValue): void {
17751779
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
17761780
? SuspendedOnData
17771781
: SuspendedOnImmediate;
1782+
} else if (thrownValue === SelectiveHydrationException) {
1783+
// An update flowed into a dehydrated boundary. Before we can apply the
1784+
// update, we need to finish hydrating. Interrupt the work-in-progress
1785+
// render so we can restart at the hydration lane.
1786+
//
1787+
// The ideal implementation would be able to switch contexts without
1788+
// unwinding the current stack.
1789+
//
1790+
// We could name this something more general but as of now it's the only
1791+
// case where we think this should happen.
1792+
workInProgressSuspendedThenableState = null;
1793+
workInProgressSuspendedReason = SuspendedOnHydration;
17781794
} else {
17791795
// This is a regular error. If something earlier in the component already
17801796
// suspended, we must clear the thenable state to unblock the work loop.
@@ -1965,6 +1981,9 @@ export function renderHasNotSuspendedYet(): boolean {
19651981
return workInProgressRootExitStatus === RootInProgress;
19661982
}
19671983

1984+
// TODO: Over time, this function and renderRootConcurrent have become more
1985+
// and more similar. Not sure it makes sense to maintain forked paths. Consider
1986+
// unifying them again.
19681987
function renderRootSync(root: FiberRoot, lanes: Lanes) {
19691988
const prevExecutionContext = executionContext;
19701989
executionContext |= RenderContext;
@@ -2004,7 +2023,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20042023
markRenderStarted(lanes);
20052024
}
20062025

2007-
do {
2026+
outer: do {
20082027
try {
20092028
if (
20102029
workInProgressSuspendedReason !== NotSuspended &&
@@ -2020,11 +2039,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20202039
// function and fork the behavior some other way.
20212040
const unitOfWork = workInProgress;
20222041
const thrownValue = workInProgressThrownValue;
2023-
workInProgressSuspendedReason = NotSuspended;
2024-
workInProgressThrownValue = null;
2025-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2026-
2027-
// Continue with the normal work loop.
2042+
switch (workInProgressSuspendedReason) {
2043+
case SuspendedOnHydration: {
2044+
// Selective hydration. An update flowed into a dehydrated tree.
2045+
// Interrupt the current render so the work loop can switch to the
2046+
// hydration lane.
2047+
workInProgress = null;
2048+
workInProgressRootExitStatus = RootDidNotComplete;
2049+
break outer;
2050+
}
2051+
default: {
2052+
// Continue with the normal work loop.
2053+
workInProgressSuspendedReason = NotSuspended;
2054+
workInProgressThrownValue = null;
2055+
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2056+
break;
2057+
}
2058+
}
20282059
}
20292060
workLoopSync();
20302061
break;
@@ -2160,6 +2191,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
21602191
workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
21612192
break outer;
21622193
}
2194+
case SuspendedOnHydration: {
2195+
// Selective hydration. An update flowed into a dehydrated tree.
2196+
// Interrupt the current render so the work loop can switch to the
2197+
// hydration lane.
2198+
workInProgress = null;
2199+
workInProgressRootExitStatus = RootDidNotComplete;
2200+
break outer;
2201+
}
21632202
default: {
21642203
workInProgressSuspendedReason = NotSuspended;
21652204
workInProgressThrownValue = null;

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

+47-8
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ import {
178178
lanesToEventPriority,
179179
} from './ReactEventPriorities.old';
180180
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
181-
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.old';
181+
import {
182+
SelectiveHydrationException,
183+
beginWork as originalBeginWork,
184+
} from './ReactFiberBeginWork.old';
182185
import {completeWork} from './ReactFiberCompleteWork.old';
183186
import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.old';
184187
import {
@@ -316,12 +319,13 @@ let workInProgress: Fiber | null = null;
316319
// The lanes we're rendering
317320
let workInProgressRootRenderLanes: Lanes = NoLanes;
318321

319-
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
322+
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5;
320323
const NotSuspended: SuspendedReason = 0;
321324
const SuspendedOnError: SuspendedReason = 1;
322325
const SuspendedOnData: SuspendedReason = 2;
323326
const SuspendedOnImmediate: SuspendedReason = 3;
324327
const SuspendedAndReadyToUnwind: SuspendedReason = 4;
328+
const SuspendedOnHydration: SuspendedReason = 5;
325329

326330
// When this is true, the work-in-progress fiber just suspended (or errored) and
327331
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -1775,6 +1779,18 @@ function handleThrow(root, thrownValue): void {
17751779
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
17761780
? SuspendedOnData
17771781
: SuspendedOnImmediate;
1782+
} else if (thrownValue === SelectiveHydrationException) {
1783+
// An update flowed into a dehydrated boundary. Before we can apply the
1784+
// update, we need to finish hydrating. Interrupt the work-in-progress
1785+
// render so we can restart at the hydration lane.
1786+
//
1787+
// The ideal implementation would be able to switch contexts without
1788+
// unwinding the current stack.
1789+
//
1790+
// We could name this something more general but as of now it's the only
1791+
// case where we think this should happen.
1792+
workInProgressSuspendedThenableState = null;
1793+
workInProgressSuspendedReason = SuspendedOnHydration;
17781794
} else {
17791795
// This is a regular error. If something earlier in the component already
17801796
// suspended, we must clear the thenable state to unblock the work loop.
@@ -1965,6 +1981,9 @@ export function renderHasNotSuspendedYet(): boolean {
19651981
return workInProgressRootExitStatus === RootInProgress;
19661982
}
19671983

1984+
// TODO: Over time, this function and renderRootConcurrent have become more
1985+
// and more similar. Not sure it makes sense to maintain forked paths. Consider
1986+
// unifying them again.
19681987
function renderRootSync(root: FiberRoot, lanes: Lanes) {
19691988
const prevExecutionContext = executionContext;
19701989
executionContext |= RenderContext;
@@ -2004,7 +2023,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20042023
markRenderStarted(lanes);
20052024
}
20062025

2007-
do {
2026+
outer: do {
20082027
try {
20092028
if (
20102029
workInProgressSuspendedReason !== NotSuspended &&
@@ -2020,11 +2039,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20202039
// function and fork the behavior some other way.
20212040
const unitOfWork = workInProgress;
20222041
const thrownValue = workInProgressThrownValue;
2023-
workInProgressSuspendedReason = NotSuspended;
2024-
workInProgressThrownValue = null;
2025-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2026-
2027-
// Continue with the normal work loop.
2042+
switch (workInProgressSuspendedReason) {
2043+
case SuspendedOnHydration: {
2044+
// Selective hydration. An update flowed into a dehydrated tree.
2045+
// Interrupt the current render so the work loop can switch to the
2046+
// hydration lane.
2047+
workInProgress = null;
2048+
workInProgressRootExitStatus = RootDidNotComplete;
2049+
break outer;
2050+
}
2051+
default: {
2052+
// Continue with the normal work loop.
2053+
workInProgressSuspendedReason = NotSuspended;
2054+
workInProgressThrownValue = null;
2055+
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2056+
break;
2057+
}
2058+
}
20282059
}
20292060
workLoopSync();
20302061
break;
@@ -2160,6 +2191,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
21602191
workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
21612192
break outer;
21622193
}
2194+
case SuspendedOnHydration: {
2195+
// Selective hydration. An update flowed into a dehydrated tree.
2196+
// Interrupt the current render so the work loop can switch to the
2197+
// hydration lane.
2198+
workInProgress = null;
2199+
workInProgressRootExitStatus = RootDidNotComplete;
2200+
break outer;
2201+
}
21632202
default: {
21642203
workInProgressSuspendedReason = NotSuspended;
21652204
workInProgressThrownValue = null;

scripts/error-codes/codes.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -445,5 +445,6 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`"
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
449+
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue."
449450
}

0 commit comments

Comments
 (0)