Skip to content

Commit

Permalink
Force unwind work loop during selective hydration (#25695)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Nov 17, 2022
1 parent 7b17f7b commit 44c4e6f
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1496,12 +1496,10 @@ describe('ReactDOMServerSelectiveHydration', () => {
// Start rendering. This will force the first boundary to hydrate
// by scheduling it at one higher pri than Idle.
expect(Scheduler).toFlushAndYieldThrough([
// An update was scheduled to force hydrate the boundary, but React will
// continue rendering at Idle until the next time React yields. This is
// fine though because it will switch to the hydration level when it
// re-enters the work loop.
'App',
'AA',

// Start hydrating A
'A',
]);

// Hover over A which (could) schedule at one higher pri than Idle.
Expand Down
32 changes: 26 additions & 6 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,14 @@ import {

const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;

// A special exception that's used to unwind the stack when an update flows
// into a dehydrated boundary.
export const SelectiveHydrationException: mixed = new Error(
"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.',
);

let didReceiveUpdate: boolean = false;

let didWarnAboutBadClass;
Expand Down Expand Up @@ -2810,6 +2818,16 @@ function updateDehydratedSuspenseComponent(
attemptHydrationAtLane,
eventTime,
);

// Throw a special object that signals to the work loop that it should
// interrupt the current render.
//
// Because we're inside a React-only execution stack, we don't
// strictly need to throw here — 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.
throw SelectiveHydrationException;
} else {
// We have already tried to ping at a higher priority than we're rendering with
// so if we got here, we must have failed to hydrate at those levels. We must
Expand All @@ -2820,15 +2838,17 @@ function updateDehydratedSuspenseComponent(
}
}

// If we have scheduled higher pri work above, this will just abort the render
// since we now have higher priority work. We'll try to infinitely suspend until
// we yield. TODO: We could probably just force yielding earlier instead.
renderDidSuspendDelayIfPossible();
// If we rendered synchronously, we won't yield so have to render something.
// This will cause us to delete any existing content.
// If we did not selectively hydrate, we'll continue rendering without
// hydrating. Mark this tree as suspended to prevent it from committing
// outside a transition.
//
// This path should only happen if the hydration lane already suspended.
// Currently, it also happens during sync updates because there is no
// hydration lane for sync updates.
// TODO: We should ideally have a sync hydration lane that we can apply to do
// a pass where we hydrate this subtree in place using the previous Context and then
// reapply the update afterwards.
renderDidSuspendDelayIfPossible();
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
Expand Down
32 changes: 26 additions & 6 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,14 @@ import {

const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;

// A special exception that's used to unwind the stack when an update flows
// into a dehydrated boundary.
export const SelectiveHydrationException: mixed = new Error(
"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.',
);

let didReceiveUpdate: boolean = false;

let didWarnAboutBadClass;
Expand Down Expand Up @@ -2810,6 +2818,16 @@ function updateDehydratedSuspenseComponent(
attemptHydrationAtLane,
eventTime,
);

// Throw a special object that signals to the work loop that it should
// interrupt the current render.
//
// Because we're inside a React-only execution stack, we don't
// strictly need to throw here — 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.
throw SelectiveHydrationException;
} else {
// We have already tried to ping at a higher priority than we're rendering with
// so if we got here, we must have failed to hydrate at those levels. We must
Expand All @@ -2820,15 +2838,17 @@ function updateDehydratedSuspenseComponent(
}
}

// If we have scheduled higher pri work above, this will just abort the render
// since we now have higher priority work. We'll try to infinitely suspend until
// we yield. TODO: We could probably just force yielding earlier instead.
renderDidSuspendDelayIfPossible();
// If we rendered synchronously, we won't yield so have to render something.
// This will cause us to delete any existing content.
// If we did not selectively hydrate, we'll continue rendering without
// hydrating. Mark this tree as suspended to prevent it from committing
// outside a transition.
//
// This path should only happen if the hydration lane already suspended.
// Currently, it also happens during sync updates because there is no
// hydration lane for sync updates.
// TODO: We should ideally have a sync hydration lane that we can apply to do
// a pass where we hydrate this subtree in place using the previous Context and then
// reapply the update afterwards.
renderDidSuspendDelayIfPossible();
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
Expand Down
55 changes: 47 additions & 8 deletions packages/react-reconciler/src/ReactFiberWorkLoop.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ import {
lanesToEventPriority,
} from './ReactEventPriorities.new';
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.new';
import {
SelectiveHydrationException,
beginWork as originalBeginWork,
} from './ReactFiberBeginWork.new';
import {completeWork} from './ReactFiberCompleteWork.new';
import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.new';
import {
Expand Down Expand Up @@ -316,12 +319,13 @@ let workInProgress: Fiber | null = null;
// The lanes we're rendering
let workInProgressRootRenderLanes: Lanes = NoLanes;

opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5;
const NotSuspended: SuspendedReason = 0;
const SuspendedOnError: SuspendedReason = 1;
const SuspendedOnData: SuspendedReason = 2;
const SuspendedOnImmediate: SuspendedReason = 3;
const SuspendedAndReadyToUnwind: SuspendedReason = 4;
const SuspendedOnHydration: SuspendedReason = 5;

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

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

do {
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
Expand All @@ -2020,11 +2039,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
// function and fork the behavior some other way.
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);

// Continue with the normal work loop.
switch (workInProgressSuspendedReason) {
case SuspendedOnHydration: {
// Selective hydration. An update flowed into a dehydrated tree.
// Interrupt the current render so the work loop can switch to the
// hydration lane.
workInProgress = null;
workInProgressRootExitStatus = RootDidNotComplete;
break outer;
}
default: {
// Continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
break;
}
}
}
workLoopSync();
break;
Expand Down Expand Up @@ -2160,6 +2191,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
break outer;
}
case SuspendedOnHydration: {
// Selective hydration. An update flowed into a dehydrated tree.
// Interrupt the current render so the work loop can switch to the
// hydration lane.
workInProgress = null;
workInProgressRootExitStatus = RootDidNotComplete;
break outer;
}
default: {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
Expand Down
55 changes: 47 additions & 8 deletions packages/react-reconciler/src/ReactFiberWorkLoop.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ import {
lanesToEventPriority,
} from './ReactEventPriorities.old';
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
import {beginWork as originalBeginWork} from './ReactFiberBeginWork.old';
import {
SelectiveHydrationException,
beginWork as originalBeginWork,
} from './ReactFiberBeginWork.old';
import {completeWork} from './ReactFiberCompleteWork.old';
import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.old';
import {
Expand Down Expand Up @@ -316,12 +319,13 @@ let workInProgress: Fiber | null = null;
// The lanes we're rendering
let workInProgressRootRenderLanes: Lanes = NoLanes;

opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5;
const NotSuspended: SuspendedReason = 0;
const SuspendedOnError: SuspendedReason = 1;
const SuspendedOnData: SuspendedReason = 2;
const SuspendedOnImmediate: SuspendedReason = 3;
const SuspendedAndReadyToUnwind: SuspendedReason = 4;
const SuspendedOnHydration: SuspendedReason = 5;

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

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

do {
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
Expand All @@ -2020,11 +2039,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
// function and fork the behavior some other way.
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);

// Continue with the normal work loop.
switch (workInProgressSuspendedReason) {
case SuspendedOnHydration: {
// Selective hydration. An update flowed into a dehydrated tree.
// Interrupt the current render so the work loop can switch to the
// hydration lane.
workInProgress = null;
workInProgressRootExitStatus = RootDidNotComplete;
break outer;
}
default: {
// Continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
break;
}
}
}
workLoopSync();
break;
Expand Down Expand Up @@ -2160,6 +2191,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
break outer;
}
case SuspendedOnHydration: {
// Selective hydration. An update flowed into a dehydrated tree.
// Interrupt the current render so the work loop can switch to the
// hydration lane.
workInProgress = null;
workInProgressRootExitStatus = RootDidNotComplete;
break outer;
}
default: {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
Expand Down
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -445,5 +445,6 @@
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
"458": "Currently React only supports one RSC renderer at a time.",
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
"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`"
"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`",
"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."
}

0 comments on commit 44c4e6f

Please sign in to comment.