diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js index c1dc8a33cce..52a9ae3516b 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 26b62552276..4047d4c5e9e 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 d055b271ad7..e882753623e 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