From 7ad96ed8a5f13a6de947b827091956996eb668ad Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 7 Mar 2026 22:40:25 +0000 Subject: [PATCH] fix: startTransition in popstate should not show Suspense fallback When startTransition is called inside a popstate event handler, the popstate eager transition path adds an artificial SyncLane to the render lanes. This caused includesOnlyTransitions() to return false, which prevented the suspension from being handled like a normal transition - showing the Suspense fallback instead of keeping the previous UI visible. Add a flag (workInProgressRootIsEagerPopstateTransition) that tracks when the current render was initiated as a popstate eager transition. Use this flag in renderDidSuspendDelayIfPossible and finishConcurrentRender to treat the render like a transition despite the artificial SyncLane, falling back to async behavior when the component suspends. Fixes: #35966 Signed-off-by: Ubuntu --- .../src/__tests__/ReactDOMFiberAsync-test.js | 84 +++++++++++++++++++ .../src/ReactFiberRootScheduler.js | 12 +++ .../src/ReactFiberWorkLoop.js | 23 ++++- 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js index c1dc8a33cceb..52a9ae3516b8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js @@ -777,6 +777,90 @@ describe('ReactDOMFiberAsync', () => { }); }); + it('popstate transition with Suspense boundary should not show fallback', async () => { + // When startTransition is called inside a popstate event and the component + // suspends inside a Suspense boundary, the previous UI should remain + // visible instead of showing the fallback. This matches the behavior of + // startTransition outside of popstate events. + let resolvePromise; + const promise = new Promise(res => { + resolvePromise = res; + }); + + function Text({text}) { + Scheduler.log(text); + return text; + } + + function SuspendingChild({pathname}) { + if (pathname !== '/path/a') { + try { + React.use(promise); + } catch (e) { + Scheduler.log(`Suspend! [${pathname}]`); + throw e; + } + } + return ; + } + + function App() { + const [pathname, setPathname] = React.useState('/path/a'); + + 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(async () => { + root.render(); + }); + assertLog(['/path/a']); + expect(container.textContent).toBe('/path/a'); + + // Simulate a popstate event + await act(async () => { + const popStateEvent = new Event('popstate'); + + window.event = popStateEvent; + window.dispatchEvent(popStateEvent); + await waitForMicrotasks(); + window.event = undefined; + + // The transition lane should have been attempted synchronously (in + // a microtask). It suspended inside the Suspense boundary. + assertLog(['Suspend! [/path/b]']); + // The previous UI should remain visible - no fallback shown. + expect(container.textContent).toBe('/path/a'); + }); + // pre-warming also renders the fallback tree (but does not commit it) + assertLog(['Suspend! [/path/b]', 'Loading...']); + // Still showing previous UI, not the fallback + expect(container.textContent).toBe('/path/a'); + + await act(async () => { + resolvePromise(); + }); + assertLog(['/path/b']); + expect(container.textContent).toBe('/path/b'); + await act(() => { + root.unmount(); + }); + }); + 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. diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index 26b625522762..4047d4c5e9e1 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -113,6 +113,15 @@ let isFlushingWork: boolean = false; let currentEventTransitionLane: Lane = NoLane; +// Tracks whether the current sync flush is a popstate eager transition. +// Used by the work loop to determine whether a suspended transition should +// fall back to async behavior instead of showing a Suspense fallback. +let currentSyncTransitionIsPopstate: boolean = false; + +export function isCurrentSyncFlushPopstateTransition(): boolean { + return currentSyncTransitionIsPopstate; +} + export function ensureRootIsScheduled(root: FiberRoot): void { // This function is called whenever a root receives an update. It does two // things 1) it ensures the root is in the root schedule, and 2) it ensures @@ -274,6 +283,7 @@ function processRootScheduleInMicrotask() { // render it synchronously anyway. We do this during a popstate event to // preserve the scroll position of the previous page. syncTransitionLanes = currentEventTransitionLane; + currentSyncTransitionIsPopstate = true; } else if (enableDefaultTransitionIndicator) { // If we have a Transition scheduled by this event it might be paired // with Default lane scheduled loading indicators. To unbatch it from @@ -340,6 +350,8 @@ function processRootScheduleInMicrotask() { flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false); } + currentSyncTransitionIsPopstate = false; + if (currentEventTransitionLane !== NoLane) { // Reset Event Transition Lane so that we allocate a new one next time. currentEventTransitionLane = NoLane; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index d055b271ad77..e882753623e0 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -403,6 +403,7 @@ import { flushSyncWorkOnAllRoots, flushSyncWorkOnLegacyRootsOnly, requestTransitionLane, + isCurrentSyncFlushPopstateTransition, } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext'; import {logUncaughtError} from './ReactFiberErrorLogger'; @@ -467,6 +468,12 @@ let workInProgressRootDidSkipSuspendedSiblings: boolean = false; // something suspends. let workInProgressRootIsPrerendering: boolean = false; +// Whether the current render was initiated as a popstate eager transition. +// When true, the render lanes include an artificial SyncLane but the actual +// update is a transition. This flag ensures that suspension is handled like +// a normal transition (keeping previous UI) instead of showing fallbacks. +let workInProgressRootIsEagerPopstateTransition: boolean = false; + // Whether a ping listener was attached during this render. This is slightly // different that whether something suspended, because we don't add multiple // listeners to a promise we've already seen (per root and lane). @@ -1406,7 +1413,15 @@ function finishConcurrentRender( throw new Error('Root did not complete. This is a bug in React.'); } case RootSuspendedWithDelay: { - if (!includesOnlyTransitions(lanes) && !includesOnlyRetries(lanes)) { + if ( + !includesOnlyTransitions(lanes) && + !includesOnlyRetries(lanes) && + // A popstate eager transition includes an artificial SyncLane but + // the actual update is a transition. If it suspended, don't commit + // the fallback — fall through to suspend indefinitely like a + // normal transition. + !workInProgressRootIsEagerPopstateTransition + ) { // Commit the placeholder. break; } @@ -2233,6 +2248,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressThrownValue = null; workInProgressRootDidSkipSuspendedSiblings = false; workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes); + workInProgressRootIsEagerPopstateTransition = + isCurrentSyncFlushPopstateTransition(); workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootSkippedLanes = NoLanes; @@ -2532,6 +2549,10 @@ export function renderDidSuspendDelayIfPossible(): void { // we should only set the exit status to RootSuspendedWithDelay if this // condition is true? And remove the equivalent checks elsewhere. (includesOnlyTransitions(workInProgressRootRenderLanes) || + // A popstate eager transition includes an artificial SyncLane but the + // actual update is a transition. If it suspends, fall back to async + // behavior instead of showing a Suspense fallback. + workInProgressRootIsEagerPopstateTransition || getSuspenseHandler() === null) ) { // This render may not have originally been scheduled as a prerender, but