diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index 3469c4faf0683..a8f06a3227b16 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -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. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 23991b9ea7b96..2f307e8771464 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -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; @@ -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 @@ -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, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 072850255f188..57371ca36a69f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -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; @@ -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 @@ -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, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index a2fa1f6ae9c3f..59adc4c1b5ad0 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -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 { @@ -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 @@ -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. @@ -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; @@ -2004,7 +2023,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { markRenderStarted(lanes); } - do { + outer: do { try { if ( workInProgressSuspendedReason !== NotSuspended && @@ -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; @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index f08ae039c5b14..101004007e399 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -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 { @@ -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 @@ -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. @@ -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; @@ -2004,7 +2023,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { markRenderStarted(lanes); } - do { + outer: do { try { if ( workInProgressSuspendedReason !== NotSuspended && @@ -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; @@ -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; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 4a3a856d15eaa..03ccbc3e76a25 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -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." }