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