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>
Tianyu Yao committed
Dec 15, 2022 at 11:47 UTC
7efa9e59707b341f10fab79724e0fca373187925
3 files changed
+164
-40
packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
+71
-5
@@ -1496,12 +1496,10 @@ describe('ReactDOMServerSelectiveHydration', () => {
1496
// Start rendering. This will force the first boundary to hydrate
1497
// by scheduling it at one higher pri than Idle.
1498
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.
1499
'App',
1504
- 'AA',
1500
+
1501
+ // Start hydrating A
1502
+ 'A',
1503
]);
1504
1505
// Hover over A which (could) schedule at one higher pri than Idle.
@@ -1772,4 +1770,72 @@ describe('ReactDOMServerSelectiveHydration', () => {
1770
1771
document.body.removeChild(container);
1772
});
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
+ });
1841
});
packages/react-reconciler/src/ReactFiberBeginWork.js
+26
-6
@@ -282,6 +282,14 @@ import {
282
283
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
284
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
+
293
let didReceiveUpdate: boolean = false;
294
295
let didWarnAboutBadClass;
@@ -2861,6 +2869,16 @@ function updateDehydratedSuspenseComponent(
2869
attemptHydrationAtLane,
2870
eventTime,
2871
);
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;
2882
} else {
2883
// We have already tried to ping at a higher priority than we're rendering with
2884
// so if we got here, we must have failed to hydrate at those levels. We must
@@ -2871,15 +2889,17 @@ function updateDehydratedSuspenseComponent(
2889
}
2890
}
2891
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.
2899
// TODO: We should ideally have a sync hydration lane that we can apply to do
2900
// a pass where we hydrate this subtree in place using the previous Context and then
2901
// reapply the update afterwards.
2902
+ renderDidSuspendDelayIfPossible();
2903
return retrySuspenseComponentWithoutHydrating(
2904
current,
2905
workInProgress,
packages/react-reconciler/src/ReactFiberWorkLoop.js
+67
-29
@@ -175,6 +175,7 @@ import {
175
} from './ReactEventPriorities';
176
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
177
import {
178
+ SelectiveHydrationException,
179
beginWork as originalBeginWork,
180
replayFunctionComponent,
181
} from './ReactFiberBeginWork';
@@ -316,13 +317,14 @@ let workInProgress: Fiber | null = null;
317
// The lanes we're rendering
318
let workInProgressRootRenderLanes: Lanes = NoLanes;
319
319
-opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5;
320
+opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6;
321
const NotSuspended: SuspendedReason = 0;
322
const SuspendedOnError: SuspendedReason = 1;
323
const SuspendedOnData: SuspendedReason = 2;
324
const SuspendedOnImmediate: SuspendedReason = 3;
325
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 4;
326
const SuspendedAndReadyToUnwind: SuspendedReason = 5;
327
+const SuspendedOnHydration: SuspendedReason = 6;
328
329
// When this is true, the work-in-progress fiber just suspended (or errored) and
330
// 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 {
1703
return renderLanes;
1704
}
1705
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
+
1731
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1732
root.finishedWork = null;
1733
root.finishedLanes = NoLanes;
@@ -1714,28 +1741,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1741
cancelTimeout(timeoutHandle);
1742
}
1743
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();
1745
workInProgressRoot = root;
1746
const rootWorkInProgress = createWorkInProgress(root.current, null);
1747
workInProgress = rootWorkInProgress;
@@ -1797,6 +1803,17 @@ function handleThrow(root, thrownValue): void {
1803
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
1804
? SuspendedOnData
1805
: 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;
1817
} else {
1818
// This is a regular error.
1819
const isWakeable =
@@ -2038,7 +2055,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2055
markRenderStarted(lanes);
2056
}
2057
2041
- do {
2058
+ outer: do {
2059
try {
2060
if (
2061
workInProgressSuspendedReason !== NotSuspended &&
@@ -2054,11 +2071,23 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2071
// function and fork the behavior some other way.
2072
const unitOfWork = workInProgress;
2073
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
+ }
2091
}
2092
workLoopSync();
2093
break;
@@ -2216,6 +2245,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2245
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2246
break;
2247
}
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
+ }
2256
default: {
2257
throw new Error(
2258
'Unexpected SuspendedReason. This is a bug in React.',
@@ -3741,6 +3778,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
3778
if (
3779
didSuspendOrErrorWhileHydratingDEV() ||
3780
originalError === SuspenseException ||
3781
+ originalError === SelectiveHydrationException ||
3782
(originalError !== null &&
3783
typeof originalError === 'object' &&
3784
typeof originalError.then === 'function')