react router: `history: 'push'` write with `shallow: false` does not advance the history index
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/[email protected], the version [email protected] 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 skipsAfter 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 devOpen http://localhost:5173/ and click the four buttons in order. Each click appends a row to the table.
Source: 47ng/nuqs