Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text text={pathname} />;
}

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 (
<React.Suspense fallback={<Text text="Loading..." />}>
<SuspendingChild pathname={pathname} />
</React.Suspense>
);
}

const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
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.
Expand Down
12 changes: 12 additions & 0 deletions packages/react-reconciler/src/ReactFiberRootScheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 22 additions & 1 deletion packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ import {
flushSyncWorkOnAllRoots,
flushSyncWorkOnLegacyRootsOnly,
requestTransitionLane,
isCurrentSyncFlushPopstateTransition,
} from './ReactFiberRootScheduler';
import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext';
import {logUncaughtError} from './ReactFiberErrorLogger';
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading