@samitouri / QOS-React-2 / commits / 7efa9e5970

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')