Skip to content

App Router: a queued navigation transition can overwrite a synchronous popstate restore and corrupt the URL #3013

Description

@NriotHrreion

Summary

When a user presses Back immediately after a client-side navigation becomes visible, the previous navigation's queued React transition can commit after vinext has synchronously restored the history snapshot.

This can leave the visible router state, rendered page tree, and URL hooks describing different navigations. A client effect that synchronizes state into the URL can then issue a replace() using the stale destination pathname, producing a URL that was never present in history.

This is reproducible with vinext@1.0.0-beta.7. It also reproduces in the same application with vinext@1.0.0-beta.6.

Minimal reproduction

Create two App Router pages. Page A normalizes a default value into its query string, while page B can be an otherwise empty page.

// app/a/page.tsx
"use client";

import { useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";

export default function PageA() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const { replace } = useRouter();

  useEffect(() => {
    if (searchParams.get("value") === "default") return;

    const next = new URLSearchParams(searchParams.toString());
    next.set("value", "default");
    replace(`${pathname}?${next.toString()}`);
  }, [pathname, replace, searchParams]);

  return <Link href="/b">Go to B</Link>;
}
// app/b/page.tsx
export default function PageB() {
  return <div>Page B</div>;
}

Steps

  1. Start the app with vinext dev.
  2. Open /a and wait for it to normalize to /a?value=default.
  3. Perform a client-side navigation to /b.
  4. As soon as /b appears in the address bar/UI, press the browser Back button.
  5. CPU throttling makes the timing window easier to hit, although it is consistently reproducible in our full application without throttling.

Expected behavior

Back atomically restores page A and its history entry:

/a?value=default

No state belonging to the superseded navigation to B should become visible afterward.

Actual behavior

The A history snapshot is initially restored correctly, but the queued B transition commits shortly afterward. Page A's URL synchronization effect can then run with the B navigation snapshot and issue a new replace navigation:

/b?value=default

That URL was never an original history entry.

In the real application the sequence is:

A: /panel/gamerules?dim=overworld
B: /panel/dashboard
Back
Actual final URL: /panel/dashboard?dim=overworld
Expected final URL: /panel/gamerules?dim=overworld

The query parameter is not copied by the browser. It is regenerated by the still-mounted page A effect against the wrong pathname/router snapshot.

Captured timeline

The following was captured while reproducing the issue in Chromium through CDP:

Page A committed:
  location = /panel/gamerules?dim=overworld
  history index = 1

Page B URL visible, then Back immediately:
  location = /panel/dashboard

Immediately after popstate:
  location = /panel/gamerules?dim=overworld
  history index = 1
  pending pathname = /panel/dashboard

~30 ms later:
  location = /panel/dashboard?dim=overworld
  history index = 1

Waiting for the B navigation to settle completely before pressing Back avoids the issue. The failure occurs specifically when Back lands after the B visible update has been scheduled but before its React commit lifecycle has completed.

Environment

  • vinext: 1.0.0-beta.7
  • React / React DOM: 19.2.8
  • Vite: 8.2.0
  • Node.js: 22.22.1
  • Browser: Chromium
  • OS: Windows
  • App Router
  • Development mode (vinext dev)

Possible root cause

This section is based on instrumenting the beta.7 browser runtime and inspecting its distributed code.

renderNavigationPayload() approves B and schedules its visible RouterState update using startTransition:

startTransition(() => {
  const committedState = captureCandidateState(
    applyApprovedVisibleCommit(getBrowserRouterState(), commit),
  );
  setter(committedState);
});

If a popstate occurs after this update is queued, vinext starts a newer navigation and synchronously restores A's history snapshot. However, that synchronous restore does not invalidate or prevent the already queued B React update from committing afterward.

Beta.7 does re-check the navigation ID in the pre-paint history effect:

if (!browserNavigationController.isCurrentNavigation(navId)) {
  commitClientNavigationState(undefined, { releaseSnapshot: true });
  return;
}

That prevents the stale navigation from directly writing its own URL/history entry, but it happens after the stale RouterState has been allowed to become visible. Consequently, URL/history ownership and visible React state do not commit as one transaction.

The render snapshot remains active across this interval, and usePathname() / useSearchParams() prefer that render snapshot while a navigation snapshot is active. A preserved client page/effect can therefore observe the destination snapshot even though Back has restored the source history entry.

In short, the existing navigation-ID checks protect parts of the commit lifecycle, but they do not guard the actual visible React state commit from a newer synchronous traversal.

Possible direction

A queued visible commit should be rejected at the point it can become visible if its operation has been superseded by a newer traversal. The RouterState update, render snapshot, URL/history mutation, and snapshot release likely need to share one commit authority/version rather than relying on checks distributed before and after the React transition.

This appears closely related to the lifecycle/visible commit barrier described in:

In particular, Back/Forward traversal needs to supersede any older visible navigation transaction, including React state work that has already been queued but has not committed yet.

Notes

I found this bug in my app built with vinext, and this issue is written with the assistant of GPT-5.6 sol.

Temporary workaround

useEffect(() => {
- if (searchParams.get("value") === "default") return;
+ if (searchParams.get("value") === "default" || !pathname.startsWith("/a")) return;

  const next = new URLSearchParams(searchParams.toString());
  next.set("value", "default");
  replace(`${pathname}?${next.toString()}`);
}, [pathname, replace, searchParams]);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions