react router: `history: 'push'` write with `shallow: false` does not advance the history index · Issue #1563 · 47ng/nuqs · GitHub
Skip to content

react router: history: 'push' write with shallow: false does not advance the history index #1563

Open

Description

@ptmkenny

Context

What's your version of nuqs?

    "nuqs": "^2.9.6",

What framework are you using?

  • ❌ Next.js (app router)
  • ❌ Next.js (pages router)
  • ❌ React SPA (no router)
  • ❌ Remix
  • ✅ React Router
  • ❌ Other (please specify)

Which version of your framework are you using?

    "react": "19.0.0",
    "react-dom": "19.0.0",
    "react-router": "^6.30.2",
    "react-router-dom": "^6.30.2",

Description

(This is my first time posting here, and I am not sure about your AI disclosure policy. I used Opus to help me prepare the reproduction and the description below. I have verified the bug manually before submission.)

A useQueryState write with { history: 'push', shallow: false } adds a browser history
entry and then tells react-router it was a replace.

packages/nuqs/src/adapters/lib/react-router.ts#L66-L89:

const updateMethod =
  options.history === 'push' ? history.pushState : history.replaceState
setQueueResetMutex(options.shallow ? 1 : 2)
updateMethod.call(
  history,
  history.state, // Maintain the history state
  historyUpdateMarker,
  url
)
let navigationSettled: Promise<void> | undefined
if (options.shallow === false) {
  const maybePromise = navigate(
    {
      // Somehow passing the full URL object here strips the search params
      // when accessing the request.url in loaders.
      hash: url.hash,
      search: url.search
    },
    {
      replace: true,
      preventScrollReset: true,
      state: history.state?.usr
    }
  )

updateMethod.call (L69) really pushes; navigate(..., { replace: true }) (L77-L85) then
tells the router nothing was pushed. Both cannot be true, and the router acts on the
second. Passing history.state through (L71) also gives the new entry the idx of the
entry below it — react-router's own index, which it writes on every push and reads back to
size a traversal.

packages/router/history.ts#L623-L645
(@remix-run/router@1.23.3, the version react-router-dom@6.30.x depends on):

function getIndex(): number {
  let state = globalHistory.state || { idx: null };
  return state.idx;
}

function handlePop() {
  action = Action.Pop;
  let nextIndex = getIndex();
  let delta = nextIndex == null ? null : nextIndex - index;   // 0 after a nuqs push
  ...
}

function push(to: To, state?: any) {
  ...
  index = getIndex() + 1;                                     // what nuqs skips

After one such write the router's index is permanently one behind the browser stack, and a
genuine back reports delta: 0 instead of -1. react-router warns about exactly this
desync — "navigating outside the router via window.history.pushState" — in
packages/router/router.ts#L1038-L1046,
except here the adapter is what navigated.

Expected: the router's index still matches the browser stack, as it does after
navigate(to). Either let react-router perform the push
(navigate(..., { replace: false })) or write a state with idx + 1. The reproduction's
control step shows navigate() producing idx: 1 where the nuqs write produces idx: 0.

shallow: true is not a workaround: react-router is then never told at all and its
location goes stale. Neither setting leaves the router consistent with the browser.

Downstream effect on Ionic

@ionic/react-router builds its own LocationHistory from react-router's action stream.
Told "replace", it drops the entry for the current page (LocationHistory._replace pops,
then adds) while both entries still exist in the browser. The next back therefore arrives
at a pathname it does not expect, and it force-navigates to what it believes came before.

My app keeps its open-modal stack in a query parameter, pushed so that back closes the
modal. Closing one instead sends the user a page backwards, in some flows several pages.
Measured across a single modal open: history.length 16 → 17, history.state.idx
unchanged at 10. Ionic's recovery is aggressive, but it is reacting to being told a push
was a replace.

Reproduction

Example: Steps to reproduce the behavior:

git clone -b nuqs-push-history-idx https://github.com/ptmkenny/ionic-react-router-6-test
cd ionic-react-router-6-test
npm install
npm run dev

Open http://localhost:5173/ and click the four buttons in order. Each click appends a row
to the table.

Activity

  1. franky47 commented on Aug 23, 2026

    @franky47
    Member

    Thanks, the use of AI for investigative work is fine (I also feed that into my agent so the extra context is appreciated).

    The initial reason for having a History API replace/push before the router API call was to have an optimistic URL update when an async loader takes its time, so that the URL update doesn't lag until the loader resolves. But I didn't know about that internal .idx property on the state so I see how that might break it.

    I'm gonna see what our options are (dropping that optimistic update breaks a few other behaviours), and will report back.

  2. added this to the 🪵 Backlog milestone on Aug 23, 2026
  3. added
    feature/time-safetyThrottle, debounce, anything related to keeping URL updates time-safe.
    on Aug 23, 2026
  4. franky47 commented on Aug 23, 2026

    @franky47
    Member

    I have a possible fix in #1564, could you give the preview build a try please?

    pnpm add https://pkg.pr.new/nuqs@1564
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    adapters/react-routerUses the React Router adapterbugSomething isn't workingfeature/time-safetyThrottle, debounce, anything related to keeping URL updates time-safe.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions