State desynchronization when rendering <Navigate /> on first render
Context
What's your version of nuqs?
"nuqs": "2.8.2",
What framework are you using?
- ❌ Next.js (app router)
- ❌ Next.js (pages router)
- ✅ React SPA (no router)
- ❌ Remix
- ✅ React Router
Which version of your framework are you using?
"react-router": "^6.30.2",
"react-router-dom": "^6.30.2",
Description
When rendering a <Navigate /> component which updates the search params on first render, nuqs state will never synchronize its state back.
Reproduction
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { parseAsString, useQueryStates } from 'nuqs';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6';
import { BrowserRouter, Link, Navigate } from 'react-router-dom';
import { describe, expect, test } from 'vitest';
function TestComponent() {
const [nuqsState] = useQueryStates({
search: parseAsString.withDefault(''),
});
if (nuqsState.search === 'REDIRECT') {
return (
<Navigate
to={{
search: '?search=foo',
}}
/>
);
}
return (
<>
<span>{nuqsState.search}</span>
<Link to={{ search: '?search=REDIRECT' }}>Trigger redirect</Link>
</>
);
}
describe('nuqs repro', () => {
test('with initial search param', async () => {
using _ = withInitialUrl('/?search=foo');
const { container } = render(
<NuqsAdapter>
<BrowserRouter>
<TestComponent />
</BrowserRouter>
</NuqsAdapter>,
);
expect(container).toHaveTextContent('foo');
});
test('with a click on a link which triggers the rendering of <Navigate />', async () => {
using _ = withInitialUrl('/?search=foo');
const user = userEvent.setup();
const { container, getByRole } = render(
<NuqsAdapter>
<BrowserRouter>
<TestComponent />
</BrowserRouter>
</NuqsAdapter>,
);
const redirectButton = getByRole('link', { name: 'Trigger redirect' });
await user.click(redirectButton);
expect(container).toHaveTextContent('foo');
});
test('with initial search param', async () => {
/**
* With this URL, <TestComponent /> will render <Navigate /> on first render.
*/
using _ = withInitialUrl('/?search=REDIRECT');
const { container } = render(
<NuqsAdapter>
<BrowserRouter>
<TestComponent />
</BrowserRouter>
</NuqsAdapter>,
);
await expect.poll(() => container).toHaveTextContent('foo');
});
});
function withInitialUrl(url: string) {
window.history.pushState({}, '', url);
return {
[Symbol.dispose]: () => {
window.history.pushState({}, '', '/');
},
};
}
Thanks for the repro, I'll have a look.
Does it happen only in tests (for which I would usually recommend the testing adapter instead of a real adapter), or in "production" code too?
We encountered it in production code, and I worked my way to a minimal jsdom + vitest reproduction.
Let me know if this reproduction is enough for you :)
BTW, I've implemented the tests using <BrowserRouter /> and the matching RR adapter on purpose to try and match the behavior we've encountered in the browser.
As I said, this reproduction runs in vitest + jsdom as I don't have a working vitest browser mode setup available.