diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 0e42d00847995..519484f27222e 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -583,8 +583,29 @@ export function getCurrentEventPriority(): EventPriority { return getEventPriority(currentEvent.type); } +let currentPopstateTransitionEvent: Event | null = null; export function shouldAttemptEagerTransition(): boolean { - return window.event && window.event.type === 'popstate'; + const event = window.event; + if (event && event.type === 'popstate') { + // This is a popstate event. Attempt to render any transition during this + // event synchronously. Unless we already attempted during this event. + if (event === currentPopstateTransitionEvent) { + // We already attempted to render this popstate transition synchronously. + // Any subsequent attempts must have happened as the result of a derived + // update, like startTransition inside useEffect, or useDV. Switch back to + // the default behavior for all remaining transitions during the current + // popstate event. + return false; + } else { + // Cache the current event in case a derived transition is scheduled. + // (Refer to previous branch.) + currentPopstateTransitionEvent = event; + return true; + } + } + // We're not inside a popstate event. + currentPopstateTransitionEvent = null; + return false; } export const isPrimaryRenderer = true; diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js index b8b8847a9dff5..87c9cfe627dc6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js @@ -746,5 +746,68 @@ describe('ReactDOMFiberAsync', () => { }); assertLog([]); expect(div.textContent).toBe('/path/b'); + await act(() => { + root.unmount(); + }); + }); + + it('regression: infinite deferral loop caused by unstable useDeferredValue input', async () => { + function Text({text}) { + Scheduler.log(text); + return text; + } + + let i = 0; + function App() { + const [pathname, setPathname] = React.useState('/path/a'); + // This is an unstable input, so it will always cause a deferred render. + const {value: deferredPathname} = React.useDeferredValue({ + value: pathname, + }); + if (i++ > 100) { + throw new Error('Infinite loop detected'); + } + React.useEffect(() => { + function onPopstate() { + React.startTransition(() => { + setPathname('/path/b'); + }); + } + window.addEventListener('popstate', onPopstate); + return () => window.removeEventListener('popstate', onPopstate); + }, []); + + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + assertLog(['/path/a']); + expect(container.textContent).toBe('/path/a'); + + // Simulate a popstate event + await act(async () => { + const popStateEvent = new Event('popstate'); + + // Simulate a popstate event + window.event = popStateEvent; + window.dispatchEvent(popStateEvent); + await waitForMicrotasks(); + window.event = undefined; + + // The transition lane is attempted synchronously (in a microtask). + // Because the input to useDeferredValue is referentially unstable, it + // will spawn a deferred task at transition priority. However, even + // though it was spawned during a transition event, the spawned task + // not also be upgraded to sync. + assertLog(['/path/a']); + }); + assertLog(['/path/b']); + expect(container.textContent).toBe('/path/b'); + await act(() => { + root.unmount(); + }); }); });