Skip to content

fix: dispatch ACTION_RESTORE synchronously after browser back/forward navigation#94139

Open
arab971 wants to merge 1 commit into
vercel:canaryfrom
arab971:fix/popstate-useeffect-deferred-v2
Open

fix: dispatch ACTION_RESTORE synchronously after browser back/forward navigation#94139
arab971 wants to merge 1 commit into
vercel:canaryfrom
arab971:fix/popstate-useeffect-deferred-v2

Conversation

@arab971
Copy link
Copy Markdown

@arab971 arab971 commented May 27, 2026

What?

Fixes browser back/forward navigation leaving the page visually restored but functionally frozen — no JavaScript executing, no errors in console, no interactivity. Fixes #93905.

Why?

Three nested deferral mechanisms prevented React from committing the fiber tree after popstate:

  1. startTransition wrapping in popstate/pushReplace handlers (app-router.tsx) — marks the ACTION_RESTORE dispatch as a low-priority transition, deferring the React fiber commit until idle time
  2. Dev-mode appDevRenderingIndicator (use-action-queue.ts) — re-wraps every dispatch in another startTransition via useTransition, adding a second deferral layer in development mode
  3. async function runAction (app-router-instance.ts) — forces synchronous reducer results through .then() microtask, deferring setState even when the outer startTransition wrappers are absent

The browser bfcache restores the page visually and immediately, but React useEffect hooks dont fire until the fiber tree commits. With three layers of deferral, the commit is indefinitely delayed — the page looks correct but is a frozen snapshot.

How?

  • Remove startTransition wrapper from onPopState and applyUrlFromHistoryPushReplace — dispatch ACTION_RESTORE synchronously
  • In dev mode, bypass appDevRenderingIndicator for ACTION_RESTORE actions — dispatch directly to actionQueue.dispatch
  • Remove async from runAction and the action function — synchronous reducers now return plain objects, isThenable returns false, and handleResult/setState execute synchronously

The dispatchAction fast-path at line 160 already skips startTransition and deferred promise for ACTION_RESTORE — these fixes ensure that fast-path is actually reached without being defeated by outer wrappers.

Verification

  • pnpm test-dev-turbo test/e2e/app-dir/popstate-useeffect-timing/popstate-useeffect-timing.test.ts — 2 tests passing
    • MutationObserver timing: asserts useEffect fires within 500ms of history.back()
    • Repeated back/forward cycle: asserts timing on second back navigation

@arab971 arab971 marked this pull request as ready for review May 27, 2026 00:18
@arab971 arab971 force-pushed the fix/popstate-useeffect-deferred-v2 branch from 85fec09 to 4b22b75 Compare May 27, 2026 02:03
… navigation

Three nested deferral mechanisms caused useEffect to fire late (or never)
after browser back/forward, leaving the page visually restored but
functionally frozen with no errors in console:

1. `startTransition` wrapping in popstate/pushReplace handlers defers the
   React fiber commit as a low-priority transition
2. Dev-mode `appDevRenderingIndicator` re-wraps in `startTransition`
3. `async function runAction` forces synchronous reducer results through
   `.then()` microtask, deferring setState

Fix: remove startTransition wrappers for ACTION_RESTORE, bypass
appDevRenderingIndicator for restore actions, and make runAction synchronous.

Fixes vercel#93905
@arab971 arab971 force-pushed the fix/popstate-useeffect-deferred-v2 branch from 4b22b75 to fe60e80 Compare May 27, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Scripts disabled due to cache error when navigating back via browser history (Regression) Next JS 16.2.6

1 participant