diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js index 47ae48eb767bd..24a9c4308a2f9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js @@ -776,6 +776,74 @@ describe('ReactDOMFiberAsync', () => { }); }); + it('regression: useDeferredValue in popState leads to infinite deferral loop', async () => { + // At the time this test was written, it simulated a particular crash that + // was happened due to a combination of very subtle implementation details. + // Rather than couple this test to those implementation details, I've chosen + // to keep it as high-level as possible so that it doesn't break if the + // details change. In the future, it might not be trigger the exact set of + // internal circumstances anymore, but it could be useful for catching + // similar bugs because it represents a realistic real world situation — + // namely, switching tabs repeatedly in an app that uses useDeferredValue. + // + // But don't worry too much about why this test is written the way it is. + + // Represents the browser's current location + let browserPathname = '/path/a'; + + let setPathname; + function App({initialPathname}) { + const [pathname, _setPathname] = React.useState('/path/a'); + setPathname = _setPathname; + + const deferredPathname = React.useDeferredValue(pathname); + + // Attach a popstate listener on mount. Normally this would be in the + // in the router implementation. + React.useEffect(() => { + function onPopstate() { + React.startTransition(() => { + setPathname(browserPathname); + }); + } + window.addEventListener('popstate', onPopstate); + return () => window.removeEventListener('popstate', onPopstate); + }, []); + + return `Current: ${pathname}\nDeferred: ${deferredPathname}`; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + + // Simulate a series of popstate events that toggle back and forth between + // two locations. In the original regression case, a certain combination + // of transition lanes would cause React to fall into an infinite deferral + // loop — specifically, when the spawned by the useDeferredValue hook was + // assigned a "higher" bit value than the one assigned to the "popstate". + + // For alignment reasons, call this once to advance the internal variable + // that assigns transition lanes. Because this is a no-op update, it will + // bump the counter, but it won't trigger the useDeferredValue hook. + setPathname(browserPathname); + + // Trigger enough popstate events that the scenario occurs for every + // possible transition lane. + for (let i = 0; i < 50; i++) { + await act(async () => { + // Simulate a popstate event + browserPathname = browserPathname === '/path/a' ? '/path/b' : '/path/a'; + const popStateEvent = new Event('popstate'); + window.event = popStateEvent; + window.dispatchEvent(popStateEvent); + await waitForMicrotasks(); + window.event = undefined; + }); + } + }); + it('regression: infinite deferral loop caused by unstable useDeferredValue input', async () => { function Text({text}) { Scheduler.log(text); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 6499c4ec7f6be..967be9be94fae 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -3033,7 +3033,9 @@ function updateDeferredValueImpl( return resultValue; } - const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes); + const shouldDeferValue = + !includesOnlyNonUrgentLanes(renderLanes) && + !includesSomeLane(renderLanes, DeferredLane); if (shouldDeferValue) { // This is an urgent update. Since the value has changed, keep using the // previous value and spawn a deferred render to update it later.