From fa63c137cd44a3cff1b7fd562cc0126241ea2e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Wed, 8 Jan 2025 15:07:22 +0100 Subject: [PATCH] perf: Optimise render counts (#849) --- .../cypress/e2e/shared/render-count.cy.ts | 57 +++++++++++++ packages/e2e/next/package.json | 2 +- .../[history]/[startTransition]/page.tsx | 47 +++++++++++ .../[history]/[startTransition]/index.tsx | 25 ++++++ .../v6/cypress/e2e/shared/render-count.cy.ts | 80 ++++++++++++++++++ .../e2e/react-router/v6/src/react-router.tsx | 5 ++ ...$history.$startTransition.async-loader.tsx | 22 +++++ ...ow.$history.$startTransition.no-loader.tsx | 8 ++ ....$history.$startTransition.sync-loader.tsx | 12 +++ packages/e2e/react-router/v7/app/routes.ts | 3 + ...$history.$startTransition.async-loader.tsx | 20 +++++ ...ow.$history.$startTransition.no-loader.tsx | 8 ++ ....$history.$startTransition.sync-loader.tsx | 12 +++ .../v7/cypress/e2e/shared/render-count.cy.ts | 77 ++++++++++++++++++ .../cypress/e2e/shared/render-count.cy.ts | 27 +++++++ packages/e2e/react/src/routes.tsx | 17 ++++ .../e2e/react/src/routes/render-count.tsx | 12 +++ ...$history.$startTransition.async-loader.tsx | 21 +++++ ...ow.$history.$startTransition.no-loader.tsx | 8 ++ ....$history.$startTransition.sync-loader.tsx | 12 +++ .../cypress/e2e/shared/render-count.cy.ts | 77 ++++++++++++++++++ packages/e2e/shared/create-test.ts | 12 ++- packages/e2e/shared/cypress.config.ts | 6 +- packages/e2e/shared/specs/render-count.cy.ts | 81 +++++++++++++++++++ .../e2e/shared/specs/render-count.params.ts | 29 +++++++ packages/e2e/shared/specs/render-count.tsx | 47 +++++++++++ .../nuqs/src/adapters/lib/patch-history.ts | 3 +- packages/nuqs/src/adapters/next/impl.app.ts | 52 ++++++------ packages/nuqs/src/useQueryStates.ts | 35 +++++--- turbo.json | 4 +- 30 files changed, 779 insertions(+), 42 deletions(-) create mode 100644 packages/e2e/next/cypress/e2e/shared/render-count.cy.ts create mode 100644 packages/e2e/next/src/app/app/(shared)/render-count/[hook]/[shallow]/[history]/[startTransition]/page.tsx create mode 100644 packages/e2e/next/src/pages/pages/render-count/[hook]/[shallow]/[history]/[startTransition]/index.tsx create mode 100644 packages/e2e/react-router/v6/cypress/e2e/shared/render-count.cy.ts create mode 100644 packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx create mode 100644 packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx create mode 100644 packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx create mode 100644 packages/e2e/react-router/v7/cypress/e2e/shared/render-count.cy.ts create mode 100644 packages/e2e/react/cypress/e2e/shared/render-count.cy.ts create mode 100644 packages/e2e/react/src/routes/render-count.tsx create mode 100644 packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx create mode 100644 packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx create mode 100644 packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx create mode 100644 packages/e2e/remix/cypress/e2e/shared/render-count.cy.ts create mode 100644 packages/e2e/shared/specs/render-count.cy.ts create mode 100644 packages/e2e/shared/specs/render-count.params.ts create mode 100644 packages/e2e/shared/specs/render-count.tsx diff --git a/packages/e2e/next/cypress/e2e/shared/render-count.cy.ts b/packages/e2e/next/cypress/e2e/shared/render-count.cy.ts new file mode 100644 index 00000000..9c247161 --- /dev/null +++ b/packages/e2e/next/cypress/e2e/shared/render-count.cy.ts @@ -0,0 +1,57 @@ +import { testRenderCount } from 'e2e-shared/specs/render-count.cy' + +const hooks = ['useQueryState', 'useQueryStates'] as const +const shallows = [true, false] as const +const histories = ['replace', 'push'] as const + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + for (const delay of shallow === false ? [0, 50] : [0]) { + testRenderCount({ + path: `/app/render-count/${hook}/${shallow}/${history}/${startTransition}?delay=${delay}`, + hook, + props: { + shallow, + history, + startTransition, + delay + }, + expected: { + mount: 1, + update: shallow === false ? 3 : 2 + }, + nextJsRouter: 'app' + }) + } + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + for (const delay of shallow === false ? [0, 50] : [0]) { + testRenderCount({ + path: `/pages/render-count/${hook}/${shallow}/${history}/${startTransition}?delay=${delay}`, + hook, + props: { + shallow, + history, + startTransition, + delay + }, + expected: { + mount: 1, + update: 2 + }, + nextJsRouter: 'pages' + }) + } + } + } + } +} diff --git a/packages/e2e/next/package.json b/packages/e2e/next/package.json index de57c125..3428cde3 100644 --- a/packages/e2e/next/package.json +++ b/packages/e2e/next/package.json @@ -16,7 +16,7 @@ "start": "NODE_OPTIONS='--enable-source-maps=true' next start --port 3001", "pretest": "cypress install", "test": "start-server-and-test start http://localhost:3001${BASE_PATH} cypress:run", - "cypress:open": "cypress open", + "cypress:open": "cypress open --e2e --browser electron", "cypress:run": "cypress run --headless" }, "dependencies": { diff --git a/packages/e2e/next/src/app/app/(shared)/render-count/[hook]/[shallow]/[history]/[startTransition]/page.tsx b/packages/e2e/next/src/app/app/(shared)/render-count/[hook]/[shallow]/[history]/[startTransition]/page.tsx new file mode 100644 index 00000000..c68ad60d --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/render-count/[hook]/[shallow]/[history]/[startTransition]/page.tsx @@ -0,0 +1,47 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { + loadParams, + loadSearchParams +} from 'e2e-shared/specs/render-count.params' +import { setTimeout } from 'node:timers/promises' +import { type SearchParams } from 'nuqs/server' +import { Suspense } from 'react' + +export const dynamic = 'force-dynamic' + +type PageProps = { + params: Promise, string>> + searchParams: Promise +} + +export default async function Page({ + params, + searchParams +}: PageProps & { searchParams: Promise }) { + const { hook, shallow, history, startTransition } = await loadParams(params) + const { delay } = await loadSearchParams(searchParams) + if (delay) { + await setTimeout(delay) + } + return ( + + + + ) +} + +export async function generateStaticParams() { + const hooks = ['useQueryState', 'useQueryStates'] + const shallow = [true, false] + const history = ['push', 'replace'] + return hooks.flatMap(hook => + shallow.flatMap(shallow => + history.map(history => ({ hook, shallow: shallow.toString(), history })) + ) + ) +} diff --git a/packages/e2e/next/src/pages/pages/render-count/[hook]/[shallow]/[history]/[startTransition]/index.tsx b/packages/e2e/next/src/pages/pages/render-count/[hook]/[shallow]/[history]/[startTransition]/index.tsx new file mode 100644 index 00000000..7bfc466d --- /dev/null +++ b/packages/e2e/next/src/pages/pages/render-count/[hook]/[shallow]/[history]/[startTransition]/index.tsx @@ -0,0 +1,25 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { + loadParams, + loadSearchParams, + type Params +} from 'e2e-shared/specs/render-count.params' +import { GetServerSideProps } from 'next' +import { setTimeout } from 'node:timers/promises' + +export default RenderCount + +// We need SSR to get the correct initial render counts +// otherwise with SSG we get at least one extra render for hydration. +export const getServerSideProps: GetServerSideProps = async ({ + params, + query +}) => { + const { delay } = loadSearchParams(query) + if (delay) { + await setTimeout(delay) + } + return { + props: loadParams(params!) + } +} diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/render-count.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/render-count.cy.ts new file mode 100644 index 00000000..8c62752a --- /dev/null +++ b/packages/e2e/react-router/v6/cypress/e2e/shared/render-count.cy.ts @@ -0,0 +1,80 @@ +import { testRenderCount } from 'e2e-shared/specs/render-count.cy' + +const hooks = ['useQueryState', 'useQueryStates'] as const +const shallows = [true, false] as const +const histories = ['replace', 'push'] as const + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/no-loader`, + description: 'no loader', + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? (startTransition ? 0 : 1) : 0) + } + }) + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/sync-loader`, + description: 'sync loader', + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? 1 : 0) + } + }) + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + for (const delay of shallow === false ? [0, 50] : [0]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/async-loader?delay=${delay}`, + description: 'async loader', + hook, + props: { + shallow, + history, + startTransition, + delay + }, + expected: { + mount: 1, + update: + 2 + + (shallow === false ? 1 : 0) + + (!startTransition && delay ? 1 : 0) + } + }) + } + } + } + } +} diff --git a/packages/e2e/react-router/v6/src/react-router.tsx b/packages/e2e/react-router/v6/src/react-router.tsx index 95a2647a..702acce0 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -41,6 +41,11 @@ const router = createBrowserRouter( + + + + + {/* Reproductions */} diff --git a/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx b/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx new file mode 100644 index 00000000..fee940d3 --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx @@ -0,0 +1,22 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { + loadParams, + loadSearchParams +} from 'e2e-shared/specs/render-count.params' + +import { useParams, type LoaderFunctionArgs } from 'react-router-dom' + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +export async function loader({ request }: LoaderFunctionArgs) { + const { delay } = loadSearchParams(request) + if (delay) { + await wait(delay) + } + return null +} + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx b/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx new file mode 100644 index 00000000..05c2fdfb --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx @@ -0,0 +1,8 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' +import { useParams } from 'react-router-dom' + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx b/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx new file mode 100644 index 00000000..161e9e44 --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx @@ -0,0 +1,12 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' +import { useParams } from 'react-router-dom' + +export function loader() { + return null +} + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts index 03299a3a..d55ccfbc 100644 --- a/packages/e2e/react-router/v7/app/routes.ts +++ b/packages/e2e/react-router/v7/app/routes.ts @@ -24,6 +24,9 @@ export default [ route('/form/useQueryStates', './routes/form.useQueryStates.tsx'), route('/referential-stability/useQueryState', './routes/referential-stability.useQueryState.tsx'), route('/referential-stability/useQueryStates', './routes/referential-stability.useQueryStates.tsx'), + route('/render-count/:hook/:shallow/:history/:startTransition/no-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx'), + route('/render-count/:hook/:shallow/:history/:startTransition/sync-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx'), + route('/render-count/:hook/:shallow/:history/:startTransition/async-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx'), // Reproductions route('/repro-839', './routes/repro-839.tsx'), ]) diff --git a/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx b/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx new file mode 100644 index 00000000..2e3aeae0 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx @@ -0,0 +1,20 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { + loadParams, + loadSearchParams +} from 'e2e-shared/specs/render-count.params' +import { setTimeout } from 'node:timers/promises' +import { useParams, type LoaderFunctionArgs } from 'react-router' + +export async function loader({ request }: LoaderFunctionArgs) { + const { delay } = loadSearchParams(request) + if (delay) { + await setTimeout(delay) + } + return null +} + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx b/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx new file mode 100644 index 00000000..fdf13661 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx @@ -0,0 +1,8 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' +import { useParams } from 'react-router' + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx b/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx new file mode 100644 index 00000000..52e5c57f --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx @@ -0,0 +1,12 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' +import { useParams } from 'react-router' + +export function loader() { + return null +} + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/render-count.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/render-count.cy.ts new file mode 100644 index 00000000..bde329c3 --- /dev/null +++ b/packages/e2e/react-router/v7/cypress/e2e/shared/render-count.cy.ts @@ -0,0 +1,77 @@ +import { testRenderCount } from 'e2e-shared/specs/render-count.cy' + +const hooks = ['useQueryState', 'useQueryStates'] as const +const shallows = [true, false] as const +const histories = ['replace', 'push'] as const + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/no-loader`, + description: 'no loader', + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? 2 : 0) + } + }) + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/sync-loader`, + description: 'sync loader', + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? 2 : 0) + } + }) + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + for (const delay of shallow === false ? [0, 50] : [0]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/async-loader?delay=${delay}`, + description: 'async loader', + hook, + props: { + shallow, + history, + startTransition, + delay + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? 2 : 0) + } + }) + } + } + } + } +} diff --git a/packages/e2e/react/cypress/e2e/shared/render-count.cy.ts b/packages/e2e/react/cypress/e2e/shared/render-count.cy.ts new file mode 100644 index 00000000..7b2a60af --- /dev/null +++ b/packages/e2e/react/cypress/e2e/shared/render-count.cy.ts @@ -0,0 +1,27 @@ +import { testRenderCount } from 'e2e-shared/specs/render-count.cy' + +const hooks = ['useQueryState', 'useQueryStates'] as const +const shallows = [true, false] as const +const histories = ['replace', 'push'] as const + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}`, + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + } + }) + } + } + } +} diff --git a/packages/e2e/react/src/routes.tsx b/packages/e2e/react/src/routes.tsx index ae11fb1c..3cd6f61f 100644 --- a/packages/e2e/react/src/routes.tsx +++ b/packages/e2e/react/src/routes.tsx @@ -21,6 +21,23 @@ const routes: Record JSX.Element>> = { '/form/useQueryStates': lazy(() => import('./routes/form.useQueryStates')), '/referential-stability/useQueryState': lazy(() => import('./routes/referential-stability.useQueryState')), '/referential-stability/useQueryStates': lazy(() => import('./routes/referential-stability.useQueryStates')), + + '/render-count/useQueryState/true/replace/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/true/replace/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/true/push/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/true/push/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/false/replace/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/false/replace/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/false/push/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryState/false/push/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/true/replace/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/true/replace/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/true/push/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/true/push/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/false/replace/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/false/replace/true': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/false/push/false': lazy(() => import('./routes/render-count')), + '/render-count/useQueryStates/false/push/true': lazy(() => import('./routes/render-count')), } export function Router() { diff --git a/packages/e2e/react/src/routes/render-count.tsx b/packages/e2e/react/src/routes/render-count.tsx new file mode 100644 index 00000000..aef97b89 --- /dev/null +++ b/packages/e2e/react/src/routes/render-count.tsx @@ -0,0 +1,12 @@ +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' +import { useMemo } from 'react' + +export default function Page() { + const params = useMemo(() => { + const [_, hook, shallow, history, startTransition] = + location.pathname.split('/') + return loadParams({ hook, shallow, history, startTransition }) + }, []) + return +} diff --git a/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx b/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx new file mode 100644 index 00000000..f0d36762 --- /dev/null +++ b/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx @@ -0,0 +1,21 @@ +import { LoaderFunctionArgs } from '@remix-run/node' +import { useParams } from '@remix-run/react' +import { RenderCount } from 'e2e-shared/specs/render-count' +import { + loadParams, + loadSearchParams +} from 'e2e-shared/specs/render-count.params' +import { setTimeout } from 'node:timers/promises' + +export async function loader({ request }: LoaderFunctionArgs) { + const { delay } = loadSearchParams(request) + if (delay) { + await setTimeout(delay) + } + return null +} + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx b/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx new file mode 100644 index 00000000..cd527907 --- /dev/null +++ b/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx @@ -0,0 +1,8 @@ +import { useParams } from '@remix-run/react' +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx b/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx new file mode 100644 index 00000000..32de9b56 --- /dev/null +++ b/packages/e2e/remix/app/routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx @@ -0,0 +1,12 @@ +import { useParams } from '@remix-run/react' +import { RenderCount } from 'e2e-shared/specs/render-count' +import { loadParams } from 'e2e-shared/specs/render-count.params' + +export function loader() { + return null +} + +export default function Page() { + const params = loadParams(useParams()) + return +} diff --git a/packages/e2e/remix/cypress/e2e/shared/render-count.cy.ts b/packages/e2e/remix/cypress/e2e/shared/render-count.cy.ts new file mode 100644 index 00000000..692bc4f7 --- /dev/null +++ b/packages/e2e/remix/cypress/e2e/shared/render-count.cy.ts @@ -0,0 +1,77 @@ +import { testRenderCount } from 'e2e-shared/specs/render-count.cy' + +const hooks = ['useQueryState', 'useQueryStates'] as const +const shallows = [true, false] as const +const histories = ['replace', 'push'] as const + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/no-loader`, + description: 'no loader', + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + } + }) + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/sync-loader`, + description: 'sync loader', + hook, + props: { + shallow, + history, + startTransition + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? 1 : 0) + } + }) + } + } + } +} + +for (const hook of hooks) { + for (const shallow of shallows) { + for (const history of histories) { + for (const startTransition of [false, true]) { + for (const delay of shallow === false ? [0, 50] : [0]) { + testRenderCount({ + path: `/render-count/${hook}/${shallow}/${history}/${startTransition}/async-loader?delay=${delay}`, + description: 'async loader', + hook, + props: { + shallow, + history, + startTransition, + delay + }, + expected: { + mount: 1, + update: 2 + (shallow === false ? 1 : 0) + } + }) + } + } + } + } +} diff --git a/packages/e2e/shared/create-test.ts b/packages/e2e/shared/create-test.ts index 21e03dd0..20a8b4f2 100644 --- a/packages/e2e/shared/create-test.ts +++ b/packages/e2e/shared/create-test.ts @@ -6,12 +6,20 @@ export type TestConfig = { } export function createTest( - label: string, + firstArg: string | { label: string; variants: string }, implementation: (config: TestConfig) => void ) { return (config: TestConfig) => { + const label = typeof firstArg === 'string' ? firstArg : firstArg.label + const variants = typeof firstArg === 'string' ? null : firstArg.variants const router = config.nextJsRouter ? `${config.nextJsRouter} router` : null - const describeLabel = [label, config.hook, router, config.description] + const describeLabel = [ + label, + router, + config.hook, + variants, + config.description + ] .filter(Boolean) .join(' - ') describe(describeLabel, implementation.bind(null, config)) diff --git a/packages/e2e/shared/cypress.config.ts b/packages/e2e/shared/cypress.config.ts index cbff8023..27382fdd 100644 --- a/packages/e2e/shared/cypress.config.ts +++ b/packages/e2e/shared/cypress.config.ts @@ -20,7 +20,11 @@ export function defineConfig(config: Config) { openMode: 0, runMode: process.env.CI ? 1 : 0 }, - ...config + ...config, + env: { + ...config.env, + CI: Boolean(process.env.CI) + } } }) } diff --git a/packages/e2e/shared/specs/render-count.cy.ts b/packages/e2e/shared/specs/render-count.cy.ts new file mode 100644 index 00000000..1150d85c --- /dev/null +++ b/packages/e2e/shared/specs/render-count.cy.ts @@ -0,0 +1,81 @@ +import { createTest, type TestConfig } from '../create-test' + +type TestRenderCountConfig = TestConfig & { + props: { + shallow: boolean + history: 'push' | 'replace' + startTransition: boolean + delay?: number + } + expected: { + mount: number + update: number + } +} + +const stubConsoleLog = { + onBeforeLoad(window: any) { + cy.stub(window.console, 'log').as('consoleLog') + } +} + +function assertLogCount(message: string, expectedCount: number) { + cy.get('@consoleLog').then(spy => { + // @ts-ignore + const matchingLogs = spy.args.filter(args => args[0] === message) + expect(matchingLogs.length).to.equal(expectedCount) + }) +} + +export function testRenderCount({ + props, + expected, + ...config +}: TestRenderCountConfig) { + const test = createTest( + { + label: 'Render count', + variants: + `shallow: ${props.shallow}, history: ${props.history}, startTransition: ${props.startTransition}` + + (props.delay ? `, delay: ${props.delay}ms` : '') + }, + ({ path }) => { + it( + `should render ${times(expected.mount)} on mount`, + { + ...(Cypress.env('CI') ? { retries: 4 } : undefined) + }, + () => { + cy.visit(path, stubConsoleLog) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + assertLogCount('render', expected.mount) + } + ) + it( + `should then render ${times(expected.update)} on updates`, + { + ...(Cypress.env('CI') ? { retries: 4 } : undefined) + }, + () => { + cy.visit(path, stubConsoleLog) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('button').click() + if (props.delay) { + cy.wait(props.delay) + } + assertLogCount('render', expected.mount + expected.update) + cy.get('#state').should('have.text', 'pass') + cy.location('search').should('contain', 'test=pass') + } + ) + } + ) + return test(config) +} + +function times(n: number) { + if (n === 1) { + return 'once' + } + return `${n} times` +} diff --git a/packages/e2e/shared/specs/render-count.params.ts b/packages/e2e/shared/specs/render-count.params.ts new file mode 100644 index 00000000..0f49f190 --- /dev/null +++ b/packages/e2e/shared/specs/render-count.params.ts @@ -0,0 +1,29 @@ +import { + createLoader, + type inferParserType, + parseAsBoolean, + parseAsInteger, + parseAsStringLiteral +} from 'nuqs/server' + +const params = { + hook: parseAsStringLiteral([ + 'useQueryState', + 'useQueryStates' + ] as const).withDefault('useQueryState'), + shallow: parseAsBoolean.withDefault(true), + history: parseAsStringLiteral(['push', 'replace'] as const).withDefault( + 'replace' + ), + startTransition: parseAsBoolean.withDefault(false) +} + +const searchParams = { + delay: parseAsInteger.withDefault(0) +} + +export type Params = inferParserType +export type SearchParams = inferParserType + +export const loadParams = createLoader(params) +export const loadSearchParams = createLoader(searchParams) diff --git a/packages/e2e/shared/specs/render-count.tsx b/packages/e2e/shared/specs/render-count.tsx new file mode 100644 index 00000000..9279909a --- /dev/null +++ b/packages/e2e/shared/specs/render-count.tsx @@ -0,0 +1,47 @@ +'use client' + +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { startTransition as reactStartTransition } from 'react' + +type RenderCountProps = { + hook: 'useQueryState' | 'useQueryStates' + shallow: boolean + history: 'push' | 'replace' + startTransition: boolean +} + +export function RenderCount({ + hook, + shallow, + history, + startTransition: enableStartTransition +}: RenderCountProps) { + console.log('render') + const startTransition = enableStartTransition + ? reactStartTransition + : undefined + let runTest = () => {} + let state = null + if (hook === 'useQueryState') { + const [testState, setState] = useQueryState('test', { + shallow, + history, + startTransition + }) + runTest = () => setState('pass') + state = testState + } + if (hook === 'useQueryStates') { + const [{ test }, setState] = useQueryStates({ + test: parseAsString.withOptions({ shallow, history, startTransition }) + }) + runTest = () => setState({ test: 'pass' }) + state = test + } + return ( + <> + +
{state}
+ + ) +} diff --git a/packages/nuqs/src/adapters/lib/patch-history.ts b/packages/nuqs/src/adapters/lib/patch-history.ts index 22c12b6a..71033a68 100644 --- a/packages/nuqs/src/adapters/lib/patch-history.ts +++ b/packages/nuqs/src/adapters/lib/patch-history.ts @@ -54,7 +54,8 @@ export function patchHistory( let lastSearchSeen = typeof location === 'object' ? location.search : '' emitter.on('update', search => { - lastSearchSeen = search.toString() + const searchString = search.toString() + lastSearchSeen = searchString.length ? '?' + searchString : '' }) debug( diff --git a/packages/nuqs/src/adapters/next/impl.app.ts b/packages/nuqs/src/adapters/next/impl.app.ts index 43214c49..8aea3b0b 100644 --- a/packages/nuqs/src/adapters/next/impl.app.ts +++ b/packages/nuqs/src/adapters/next/impl.app.ts @@ -12,32 +12,34 @@ export function useNuqsNextAppRouterAdapter(): AdapterInterface { const updateUrl: UpdateUrlFunction = useCallback((search, options) => { // App router startTransition(() => { - setOptimisticSearchParams(search) + if (!options.shallow) { + setOptimisticSearchParams(search) + } + const url = renderURL(location.origin + location.pathname, search) + debug('[nuqs queue (app)] Updating url: %s', url) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + // In next@14.1.0, useSearchParams becomes reactive to shallow updates, + // but only if passing `null` as the history state. + null, + '', + url + ) + if (options.scroll) { + window.scrollTo(0, 0) + } + if (!options.shallow) { + // Call the Next.js router to perform a network request + // and re-render server components. + router.replace(url, { + scroll: false + }) + } }) - const url = renderURL(location.origin + location.pathname, search) - debug('[nuqs queue (app)] Updating url: %s', url) - // First, update the URL locally without triggering a network request, - // this allows keeping a reactive URL if the network is slow. - const updateMethod = - options.history === 'push' ? history.pushState : history.replaceState - updateMethod.call( - history, - // In next@14.1.0, useSearchParams becomes reactive to shallow updates, - // but only if passing `null` as the history state. - null, - '', - url - ) - if (options.scroll) { - window.scrollTo(0, 0) - } - if (!options.shallow) { - // Call the Next.js router to perform a network request - // and re-render server components. - router.replace(url, { - scroll: false - }) - } }, []) return { searchParams: optimisticSearchParams, diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 505ea3e8..330958ae 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -88,7 +88,12 @@ export function useQueryStates( const queryRef = useRef>({}) // Initialise the queryRef with the initial values if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) { - queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? []) + queryRef.current = Object.fromEntries( + Object.values(resolvedUrlKeys).map(urlKey => [ + urlKey, + initialSearchParams?.get(urlKey) ?? null + ]) + ) } const defaultValues = useMemo( () => @@ -104,7 +109,7 @@ export function useQueryStates( const [internalState, setInternalState] = useState(() => { const source = initialSearchParams ?? new URLSearchParams() - return parseMap(keyMap, urlKeys, source) + return parseMap(keyMap, urlKeys, source).state }) const stateRef = useRef(internalState) @@ -116,18 +121,20 @@ export function useQueryStates( ) useEffect(() => { - const state = parseMap( + const { state, hasChanged } = parseMap( keyMap, urlKeys, initialSearchParams, queryRef.current, stateRef.current ) - stateRef.current = state - setInternalState(state) + if (hasChanged) { + stateRef.current = state + setInternalState(state) + } }, [ Object.values(resolvedUrlKeys) - .map(key => initialSearchParams?.get(key)) + .map(key => `${key}=${initialSearchParams?.get(key)}`) .join('&') ]) @@ -180,7 +187,7 @@ export function useQueryStates( emitter.off(urlKey, handlers[stateKey]) } } - }, [keyMap, resolvedUrlKeys]) + }, [stateKeys, resolvedUrlKeys]) const update = useCallback>( (stateUpdater, callOptions = {}) => { @@ -261,8 +268,12 @@ function parseMap( searchParams: URLSearchParams, cachedQuery?: Record, cachedState?: NullableValues -): NullableValues { - return Object.keys(keyMap).reduce((out, stateKey) => { +): { + state: NullableValues + hasChanged: boolean +} { + let hasChanged = false + const state = Object.keys(keyMap).reduce((out, stateKey) => { const urlKey = urlKeys?.[stateKey] ?? stateKey const { parse } = keyMap[stateKey]! const queuedQuery = getQueuedValue(urlKey) @@ -270,10 +281,13 @@ function parseMap( queuedQuery === undefined ? (searchParams?.get(urlKey) ?? null) : queuedQuery - if (cachedQuery && cachedState && cachedQuery[urlKey] === query) { + if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) { + // Cache hit out[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null return out } + // Cache miss + hasChanged = true const value = query === null ? null : safeParse(parse, query, stateKey) out[stateKey as keyof KeyMap] = value ?? null if (cachedQuery) { @@ -281,6 +295,7 @@ function parseMap( } return out }, {} as NullableValues) + return { state, hasChanged } } function applyDefaultValues( diff --git a/turbo.json b/turbo.json index 00d74eff..ade23ed6 100644 --- a/turbo.json +++ b/turbo.json @@ -17,7 +17,7 @@ "env": ["BASE_PATH", "REACT_COMPILER", "E2E_NO_CACHE_ON_RERUN"] }, "e2e-remix#build": { - "outputs": ["dist/**", "cypress/**"], + "outputs": ["build/**", "cypress/**"], "dependsOn": ["^build"], "env": ["REACT_COMPILER", "E2E_NO_CACHE_ON_RERUN"] }, @@ -27,7 +27,7 @@ "env": ["REACT_COMPILER", "E2E_NO_CACHE_ON_RERUN"] }, "e2e-react-router-v7#build": { - "outputs": ["dist/**", "cypress/**"], + "outputs": [".react-router/**", "build/**", "cypress/**"], "dependsOn": ["^build"], "env": ["REACT_COMPILER", "E2E_NO_CACHE_ON_RERUN"] },