nuqs icon indicating copy to clipboard operation
nuqs copied to clipboard

State desynchronization when rendering <Navigate /> on first render

Open kouak opened this issue 3 weeks ago • 3 comments

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({}, '', '/');
    },
  };
}

kouak avatar Dec 03 '25 10:12 kouak

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?

franky47 avatar Dec 03 '25 10:12 franky47

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

kouak avatar Dec 03 '25 10:12 kouak

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.

kouak avatar Dec 03 '25 10:12 kouak