Skip to content

Commit 7efa9e5

Browse files
tyao1acdlite
andauthored
Fix unwinding context during selective hydration (#25876)
This PR includes the previously reverted #25695 and #25754, and the fix for the regression test added in #25867. Tested internally with a previous failed test, and it's passing now. Co-authored-by: Andrew Clark <git@andrewclark.io>
1 parent 84a0a17 commit 7efa9e5

File tree

3 files changed

+164
-40
lines changed

3 files changed

+164
-40
lines changed

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

Lines changed: 71 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.
@@ -1772,4 +1770,72 @@ describe('ReactDOMServerSelectiveHydration', () => {
17721770

17731771
document.body.removeChild(container);
17741772
});
1773+
1774+
// @gate experimental || www
1775+
it('regression test: can unwind context on selective hydration interruption', async () => {
1776+
const Context = React.createContext('DefaultContext');
1777+
1778+
function ContextReader(props) {
1779+
const value = React.useContext(Context);
1780+
Scheduler.unstable_yieldValue(value);
1781+
return null;
1782+
}
1783+
1784+
function Child({text}) {
1785+
Scheduler.unstable_yieldValue(text);
1786+
return <span>{text}</span>;
1787+
}
1788+
const ChildWithBoundary = React.memo(function({text}) {
1789+
return (
1790+
<Suspense fallback="Loading...">
1791+
<Child text={text} />
1792+
</Suspense>
1793+
);
1794+
});
1795+
1796+
function App({a}) {
1797+
Scheduler.unstable_yieldValue('App');
1798+
React.useEffect(() => {
1799+
Scheduler.unstable_yieldValue('Commit');
1800+
});
1801+
return (
1802+
<>
1803+
<Context.Provider value="SiblingContext">
1804+
<ChildWithBoundary text={a} />
1805+
</Context.Provider>
1806+
<ContextReader />
1807+
</>
1808+
);
1809+
}
1810+
const finalHTML = ReactDOMServer.renderToString(<App a="A" />);
1811+
expect(Scheduler).toHaveYielded(['App', 'A', 'DefaultContext']);
1812+
const container = document.createElement('div');
1813+
container.innerHTML = finalHTML;
1814+
document.body.appendChild(container);
1815+
1816+
const spanA = container.getElementsByTagName('span')[0];
1817+
1818+
await act(async () => {
1819+
const root = ReactDOMClient.hydrateRoot(container, <App a="A" />);
1820+
expect(Scheduler).toFlushAndYieldThrough([
1821+
'App',
1822+
'DefaultContext',
1823+
'Commit',
1824+
]);
1825+
1826+
TODO_scheduleIdleDOMSchedulerTask(() => {
1827+
root.render(<App a="AA" />);
1828+
});
1829+
expect(Scheduler).toFlushAndYieldThrough(['App', 'A']);
1830+
1831+
dispatchClickEvent(spanA);
1832+
expect(Scheduler).toHaveYielded(['A']);
1833+
expect(Scheduler).toFlushAndYield([
1834+
'App',
1835+
'AA',
1836+
'DefaultContext',
1837+
'Commit',
1838+
]);
1839+
});
1840+
});
17751841
});

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

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

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 67 additions & 29 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
@@ -1701,6 +1703,31 @@ export function getRenderLanes(): Lanes {
17011703
return renderLanes;
17021704
}
17031705

1706+
function resetWorkInProgressStack() {
1707+
if (workInProgress === null) return;
1708+
let interruptedWork;
1709+
if (workInProgressSuspendedReason === NotSuspended) {
1710+
// Normal case. Work-in-progress hasn't started yet. Unwind all
1711+
// its parents.
1712+
interruptedWork = workInProgress.return;
1713+
} else {
1714+
// Work-in-progress is in suspended state. Reset the work loop and unwind
1715+
// both the suspended fiber and all its parents.
1716+
resetSuspendedWorkLoopOnUnwind();
1717+
interruptedWork = workInProgress;
1718+
}
1719+
while (interruptedWork !== null) {
1720+
const current = interruptedWork.alternate;
1721+
unwindInterruptedWork(
1722+
current,
1723+
interruptedWork,
1724+
workInProgressRootRenderLanes,
1725+
);
1726+
interruptedWork = interruptedWork.return;
1727+
}
1728+
workInProgress = null;
1729+
}
1730+
17041731
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
17051732
root.finishedWork = null;
17061733
root.finishedLanes = NoLanes;
@@ -1714,28 +1741,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
17141741
cancelTimeout(timeoutHandle);
17151742
}
17161743

1717-
if (workInProgress !== null) {
1718-
let interruptedWork;
1719-
if (workInProgressSuspendedReason === NotSuspended) {
1720-
// Normal case. Work-in-progress hasn't started yet. Unwind all
1721-
// its parents.
1722-
interruptedWork = workInProgress.return;
1723-
} else {
1724-
// Work-in-progress is in suspended state. Reset the work loop and unwind
1725-
// both the suspended fiber and all its parents.
1726-
resetSuspendedWorkLoopOnUnwind();
1727-
interruptedWork = workInProgress;
1728-
}
1729-
while (interruptedWork !== null) {
1730-
const current = interruptedWork.alternate;
1731-
unwindInterruptedWork(
1732-
current,
1733-
interruptedWork,
1734-
workInProgressRootRenderLanes,
1735-
);
1736-
interruptedWork = interruptedWork.return;
1737-
}
1738-
}
1744+
resetWorkInProgressStack();
17391745
workInProgressRoot = root;
17401746
const rootWorkInProgress = createWorkInProgress(root.current, null);
17411747
workInProgress = rootWorkInProgress;
@@ -1797,6 +1803,17 @@ function handleThrow(root, thrownValue): void {
17971803
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
17981804
? SuspendedOnData
17991805
: SuspendedOnImmediate;
1806+
} else if (thrownValue === SelectiveHydrationException) {
1807+
// An update flowed into a dehydrated boundary. Before we can apply the
1808+
// update, we need to finish hydrating. Interrupt the work-in-progress
1809+
// render so we can restart at the hydration lane.
1810+
//
1811+
// The ideal implementation would be able to switch contexts without
1812+
// unwinding the current stack.
1813+
//
1814+
// We could name this something more general but as of now it's the only
1815+
// case where we think this should happen.
1816+
workInProgressSuspendedReason = SuspendedOnHydration;
18001817
} else {
18011818
// This is a regular error.
18021819
const isWakeable =
@@ -2038,7 +2055,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20382055
markRenderStarted(lanes);
20392056
}
20402057

2041-
do {
2058+
outer: do {
20422059
try {
20432060
if (
20442061
workInProgressSuspendedReason !== NotSuspended &&
@@ -2054,11 +2071,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20542071
// function and fork the behavior some other way.
20552072
const unitOfWork = workInProgress;
20562073
const thrownValue = workInProgressThrownValue;
2057-
workInProgressSuspendedReason = NotSuspended;
2058-
workInProgressThrownValue = null;
2059-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2060-
2061-
// Continue with the normal work loop.
2074+
switch (workInProgressSuspendedReason) {
2075+
case SuspendedOnHydration: {
2076+
// Selective hydration. An update flowed into a dehydrated tree.
2077+
// Interrupt the current render so the work loop can switch to the
2078+
// hydration lane.
2079+
resetWorkInProgressStack();
2080+
workInProgressRootExitStatus = RootDidNotComplete;
2081+
break outer;
2082+
}
2083+
default: {
2084+
// Continue with the normal work loop.
2085+
workInProgressSuspendedReason = NotSuspended;
2086+
workInProgressThrownValue = null;
2087+
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2088+
break;
2089+
}
2090+
}
20622091
}
20632092
workLoopSync();
20642093
break;
@@ -2216,6 +2245,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
22162245
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
22172246
break;
22182247
}
2248+
case SuspendedOnHydration: {
2249+
// Selective hydration. An update flowed into a dehydrated tree.
2250+
// Interrupt the current render so the work loop can switch to the
2251+
// hydration lane.
2252+
resetWorkInProgressStack();
2253+
workInProgressRootExitStatus = RootDidNotComplete;
2254+
break outer;
2255+
}
22192256
default: {
22202257
throw new Error(
22212258
'Unexpected SuspendedReason. This is a bug in React.',
@@ -3741,6 +3778,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
37413778
if (
37423779
didSuspendOrErrorWhileHydratingDEV() ||
37433780
originalError === SuspenseException ||
3781+
originalError === SelectiveHydrationException ||
37443782
(originalError !== null &&
37453783
typeof originalError === 'object' &&
37463784
typeof originalError.then === 'function')

0 commit comments

Comments
 (0)