#1563·nuqs

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

Author: ptmkennyCreated Aug 23, 2026Updated Aug 23, 2026
Labelsbugadapters/react-routerfeature/time-safety

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:

typescript
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/[email protected], the version [email protected] depends on):

typescript
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:

bash
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.