From a6f44fff9087da0267b36f52c55d07476be595fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 14 Feb 2025 04:48:37 +0100 Subject: [PATCH 01/10] test: Add multitenancy test for Next.js Should fail to preserve the router pathname in the pages router with shallow: true. --- .../e2e/next/cypress/e2e/multitenant.cy.ts | 66 +++++++++++++++++++ .../multitenant/[tenant]/client-tenant.tsx | 14 ++++ .../src/app/app/multitenant/[tenant]/page.tsx | 46 +++++++++++++ packages/e2e/next/src/middleware.ts | 22 +++++++ .../e2e/next/src/pages/pages/middleware.tsx | 8 +++ .../src/pages/pages/multitenant/[tenant].tsx | 45 +++++++++++++ 6 files changed, 201 insertions(+) create mode 100644 packages/e2e/next/cypress/e2e/multitenant.cy.ts create mode 100644 packages/e2e/next/src/app/app/multitenant/[tenant]/client-tenant.tsx create mode 100644 packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx create mode 100644 packages/e2e/next/src/middleware.ts create mode 100644 packages/e2e/next/src/pages/pages/middleware.tsx create mode 100644 packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx diff --git a/packages/e2e/next/cypress/e2e/multitenant.cy.ts b/packages/e2e/next/cypress/e2e/multitenant.cy.ts new file mode 100644 index 00000000..76fa52e2 --- /dev/null +++ b/packages/e2e/next/cypress/e2e/multitenant.cy.ts @@ -0,0 +1,66 @@ +import { createTest, type TestConfig } from 'e2e-shared/create-test' +import { getShallowUrl } from 'e2e-shared/specs/shallow.defs' + +function testMultiTenant(options: TestConfig) { + const factory = createTest('Multitenant', ({ path }) => { + for (const shallow of [true, false]) { + for (const history of ['replace', 'push'] as const) { + it(`Updates with ({ shallow: ${shallow}, history: ${history} })`, () => { + cy.visit(getShallowUrl(path, { shallow, history })) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#client-state').should('be.empty') + cy.get('#server-state').should('be.empty') + cy.get('#client-tenant').should('have.text', 'david') + cy.get('#server-tenant').should('have.text', 'david') + cy.get('#router-pathname').should( + 'have.text', + options.nextJsRouter === 'pages' + ? '/pages/multitenant/[tenant]' + : '/app/multitenant' + ) + cy.get('button').click() + cy.get('#client-state').should('have.text', 'pass') + cy.get('#client-tenant').should('have.text', 'david') + cy.get('#server-tenant').should('have.text', 'david') + cy.get('#router-pathname').should( + 'have.text', + options.nextJsRouter === 'pages' + ? '/pages/multitenant/[tenant]' + : '/app/multitenant' + ) + if (shallow === false) { + cy.get('#server-state').should('have.text', 'pass') + } else { + cy.get('#server-state').should('be.empty') + } + if (history !== 'push') { + return + } + cy.go('back') + cy.get('#client-tenant').should('have.text', 'david') + cy.get('#server-tenant').should('have.text', 'david') + cy.get('#client-state').should('be.empty') + cy.get('#server-state').should('be.empty') + cy.get('#router-pathname').should( + 'have.text', + options.nextJsRouter === 'pages' + ? '/pages/multitenant/[tenant]' + : '/app/multitenant' + ) + }) + } + } + }) + + return factory(options) +} + +testMultiTenant({ + path: '/app/multitenant', + nextJsRouter: 'app' +}) + +testMultiTenant({ + path: '/pages/multitenant', + nextJsRouter: 'pages' +}) diff --git a/packages/e2e/next/src/app/app/multitenant/[tenant]/client-tenant.tsx b/packages/e2e/next/src/app/app/multitenant/[tenant]/client-tenant.tsx new file mode 100644 index 00000000..620c989c --- /dev/null +++ b/packages/e2e/next/src/app/app/multitenant/[tenant]/client-tenant.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useParams, usePathname } from 'next/navigation' + +export function TenantClient() { + const params = useParams() + const pathname = usePathname() + return ( + <> +

{params?.tenant}

+

{pathname}

+ + ) +} diff --git a/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx b/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx new file mode 100644 index 00000000..4232b5e7 --- /dev/null +++ b/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx @@ -0,0 +1,46 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' +import { + createSearchParamsCache, + parseAsString, + type SearchParams +} from 'nuqs/server' +import { Suspense } from 'react' +import { TenantClient } from './client-tenant' + +type PageProps = { + params: Promise<{ tenant: string }> + searchParams: Promise +} + +const cache = createSearchParamsCache( + { + state: parseAsString + }, + { + urlKeys: { + state: 'test' + } + } +) + +export default async function TenantPage({ params, searchParams }: PageProps) { + const { tenant } = await params + if (!tenant) { + return
Error: Tenant not found.
+ } + await cache.parse(searchParams) + + return ( + <> + + + + +

{tenant}

+ + + + + ) +} diff --git a/packages/e2e/next/src/middleware.ts b/packages/e2e/next/src/middleware.ts new file mode 100644 index 00000000..6297ce64 --- /dev/null +++ b/packages/e2e/next/src/middleware.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const config = { + matcher: ['/app/multitenant', '/pages/multitenant'] +} + +export default async function middleware(req: NextRequest) { + // https://media1.tenor.com/m/YrcMb6KRczsAAAAC/doctor-who-dr-who.gif + const tenant = 'david' + const pathname = req.nextUrl.pathname + if (pathname === '/app/multitenant') { + const url = new URL(`/app/multitenant/${tenant}`, req.url) + url.search = req.nextUrl.search + return NextResponse.rewrite(url) + } + if (pathname === '/pages/multitenant') { + const url = new URL(`/pages/multitenant/${tenant}`, req.url) + url.search = req.nextUrl.search + return NextResponse.rewrite(url) + } + return NextResponse.next() +} diff --git a/packages/e2e/next/src/pages/pages/middleware.tsx b/packages/e2e/next/src/pages/pages/middleware.tsx new file mode 100644 index 00000000..718e36f0 --- /dev/null +++ b/packages/e2e/next/src/pages/pages/middleware.tsx @@ -0,0 +1,8 @@ +export default function MiddlewarePage() { + return ( +
+

Client

+

Pages router

+
+ ) +} diff --git a/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx b/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx new file mode 100644 index 00000000..54b6922c --- /dev/null +++ b/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx @@ -0,0 +1,45 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' +import { useParams } from 'next/navigation' +import { useRouter } from 'next/router' + +type Props = { + serverState: string | null + tenant: string | null +} + +export default function Page({ serverState, tenant }: Props) { + const params = useParams() + const router = useRouter() + return ( + <> + + +

{tenant}

+

{params?.tenant}

+

{router.pathname}

+ + ) +} + +export function getServerSideProps( + ctx: GetServerSidePropsContext +): GetServerSidePropsResult { + const tenant = ctx.params?.tenant as string | null + + if (!tenant) { + return { + notFound: true + } + } + + const serverState = (ctx.query.test as string) ?? null + + return { + props: { + serverState, + tenant + } + } +} From 123d877434d3e17ee0180207da15c4f74fe9f3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 14 Feb 2025 04:59:47 +0100 Subject: [PATCH 02/10] test: Handle basePath --- packages/e2e/next/src/middleware.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/e2e/next/src/middleware.ts b/packages/e2e/next/src/middleware.ts index 6297ce64..9e5a17c3 100644 --- a/packages/e2e/next/src/middleware.ts +++ b/packages/e2e/next/src/middleware.ts @@ -9,12 +9,18 @@ export default async function middleware(req: NextRequest) { const tenant = 'david' const pathname = req.nextUrl.pathname if (pathname === '/app/multitenant') { - const url = new URL(`/app/multitenant/${tenant}`, req.url) + const url = new URL( + `${req.nextUrl.basePath}/app/multitenant/${tenant}`, + req.url + ) url.search = req.nextUrl.search return NextResponse.rewrite(url) } if (pathname === '/pages/multitenant') { - const url = new URL(`/pages/multitenant/${tenant}`, req.url) + const url = new URL( + `${req.nextUrl.basePath}/pages/multitenant/${tenant}`, + req.url + ) url.search = req.nextUrl.search return NextResponse.rewrite(url) } From 18d8ab472d107120be3109a8f358ee15f6dbcf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 14 Feb 2025 17:42:33 +0100 Subject: [PATCH 03/10] fix: Found the secret sauce! --- packages/nuqs/src/adapters/next/impl.pages.ts | 93 +++++++++++++++++-- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 6b98eaa9..6f5faf5c 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -1,6 +1,6 @@ -import { useSearchParams } from 'next/navigation.js' +import { useRouter } from 'next/compat/router' import type { NextRouter } from 'next/router' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { debug } from '../../debug' import { createAdapterProvider } from '../lib/context' import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' @@ -23,7 +23,24 @@ export function isPagesRouter(): boolean { } export function useNuqsNextPagesRouterAdapter(): AdapterInterface { - const searchParams = useSearchParams() + const router = useRouter() + const searchParams = useMemo(() => { + const searchParams = new URLSearchParams() + if (router === null) { + return searchParams + } + for (const [key, value] of Object.entries(router.query)) { + if (typeof value === 'string') { + searchParams.set(key, value) + } else if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(key, v) + } + } + } + return searchParams + }, [JSON.stringify(router?.query)]) + const updateUrl: UpdateUrlFunction = useCallback((search, options) => { // While the Next.js team doesn't recommend using internals like this, // we need access to the pages router here to let it know about non-shallow @@ -32,21 +49,77 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { // The router adapter imported from next/navigation also doesn't support // passing an asPath, causing issues in dynamic routes in the pages router. const nextRouter = window.next?.router! - const url = renderURL(nextRouter.state.asPath.split('?')[0] ?? '', search) - debug('[nuqs queue (pages)] Updating url: %s', url) + const urlParams = extractDynamicUrlParams( + nextRouter.pathname, + nextRouter.query + ) + const query = Object.fromEntries(search.entries()) + const asPath = renderURL( + nextRouter.state.asPath.split('?')[0] ?? '', + search + ) + debug('[nuqs queue (pages)] Updating url: %s', asPath) const method = options.history === 'push' ? nextRouter.push : nextRouter.replace - method.call(nextRouter, url, url, { - scroll: options.scroll, - shallow: options.shallow - }) + + method.call( + nextRouter, + { + pathname: nextRouter.pathname, + query: { + ...urlParams, + ...query + }, + hash: location.hash + }, + { + pathname: nextRouter.state.asPath.split('?')[0] ?? '', + query, + hash: location.hash + }, + { + scroll: options.scroll, + shallow: options.shallow + } + ) }, []) return { searchParams, updateUrl, // See: https://github.com/47ng/nuqs/issues/603#issuecomment-2317057128 - rateLimitFactor: 2 + rateLimitFactor: 1 } } export const NuqsAdapter = createAdapterProvider(useNuqsNextPagesRouterAdapter) + +function extractDynamicUrlParams( + pathname: string, + values: Record +): Record { + const paramNames = new Set() + const dynamicRegex = /\[([^\]]+)\]/g + const catchAllRegex = /\[\.{3}([^\]]+)\]$/ + const optionalCatchAllRegex = /\[\[\.{3}([^\]]+)\]\]$/ + + let match + while ((match = dynamicRegex.exec(pathname)) !== null) { + const paramName = match[1] + if (paramName) { + paramNames.add(paramName) + } + } + const dynamicValues = Object.fromEntries( + Object.entries(values).filter(([key]) => paramNames.has(key)) + ) + const matchCatchAll = catchAllRegex.exec(pathname) + const matchOptionalCatchAll = optionalCatchAllRegex.exec(pathname) + if (matchCatchAll) { + dynamicValues[matchCatchAll[1]!] = values[matchCatchAll[1]!] ?? [] + } + if (matchOptionalCatchAll) { + dynamicValues[matchOptionalCatchAll[1]!] = + values[matchOptionalCatchAll[1]!] ?? [] + } + return dynamicValues +} From be04d1048e09d6623800afa7703477ca2c1a329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 16 Feb 2025 16:28:03 +0100 Subject: [PATCH 04/10] test: Add test for dynamic segments --- .../cypress/e2e/shared/dynamic-segments.cy.ts | 77 +++++++++++++ .../catch-all/[...segments]/client.tsx | 16 +++ .../catch-all/[...segments]/page.tsx | 34 ++++++ .../dynamic/[segment]/client.tsx | 16 +++ .../dynamic/[segment]/page.tsx | 34 ++++++ .../[[...segments]]/client.tsx | 16 +++ .../[[...segments]]/page.tsx | 34 ++++++ .../(shared)/shallow/useQueryState/page.tsx | 4 +- .../(shared)/shallow/useQueryStates/page.tsx | 4 +- .../catch-all/[...segments].tsx | 38 +++++++ .../dynamic-segments/dynamic/[segment].tsx | 38 +++++++ .../optional-catch-all/[[...segments]].tsx | 42 +++++++ .../src/pages/pages/shallow/useQueryState.tsx | 4 +- .../pages/pages/shallow/useQueryStates.tsx | 4 +- .../v6/src/routes/shallow.useQueryState.tsx | 4 +- .../v6/src/routes/shallow.useQueryStates.tsx | 4 +- .../v7/app/routes/shallow.useQueryState.tsx | 4 +- .../v7/app/routes/shallow.useQueryStates.tsx | 4 +- .../app/routes/shallow.useQueryState.tsx | 4 +- .../app/routes/shallow.useQueryStates.tsx | 4 +- packages/e2e/shared/components/display.tsx | 13 +++ packages/e2e/shared/lib/options.ts | 14 +++ .../e2e/shared/specs/dynamic-segments.cy.ts | 54 +++++++++ .../e2e/shared/specs/dynamic-segments.tsx | 41 +++++++ packages/e2e/shared/specs/shallow-display.tsx | 8 -- packages/e2e/shared/specs/shallow.cy.ts | 4 +- packages/e2e/shared/specs/shallow.defs.ts | 8 -- packages/e2e/shared/specs/shallow.tsx | 12 +- .../nuqs/src/adapters/next/impl.pages.test.ts | 104 ++++++++++++++++++ packages/nuqs/src/adapters/next/impl.pages.ts | 79 ++++++++----- 30 files changed, 648 insertions(+), 74 deletions(-) create mode 100644 packages/e2e/next/cypress/e2e/shared/dynamic-segments.cy.ts create mode 100644 packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/client.tsx create mode 100644 packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/page.tsx create mode 100644 packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/client.tsx create mode 100644 packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/page.tsx create mode 100644 packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/client.tsx create mode 100644 packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/page.tsx create mode 100644 packages/e2e/next/src/pages/pages/dynamic-segments/catch-all/[...segments].tsx create mode 100644 packages/e2e/next/src/pages/pages/dynamic-segments/dynamic/[segment].tsx create mode 100644 packages/e2e/next/src/pages/pages/dynamic-segments/optional-catch-all/[[...segments]].tsx create mode 100644 packages/e2e/shared/components/display.tsx create mode 100644 packages/e2e/shared/lib/options.ts create mode 100644 packages/e2e/shared/specs/dynamic-segments.cy.ts create mode 100644 packages/e2e/shared/specs/dynamic-segments.tsx delete mode 100644 packages/e2e/shared/specs/shallow-display.tsx delete mode 100644 packages/e2e/shared/specs/shallow.defs.ts create mode 100644 packages/nuqs/src/adapters/next/impl.pages.test.ts diff --git a/packages/e2e/next/cypress/e2e/shared/dynamic-segments.cy.ts b/packages/e2e/next/cypress/e2e/shared/dynamic-segments.cy.ts new file mode 100644 index 00000000..b32665ff --- /dev/null +++ b/packages/e2e/next/cypress/e2e/shared/dynamic-segments.cy.ts @@ -0,0 +1,77 @@ +import { testDynamicSegments } from 'e2e-shared/specs/dynamic-segments.cy' + +testDynamicSegments({ + path: '/app/dynamic-segments/dynamic/segment', + expectedSegments: ['segment'], + nextJsRouter: 'app' +}) + +testDynamicSegments({ + path: '/pages/dynamic-segments/dynamic/segment', + expectedSegments: ['segment'], + nextJsRouter: 'pages' +}) + +// Catch-all -- + +testDynamicSegments({ + path: '/app/dynamic-segments/catch-all/foo', + expectedSegments: ['foo'], + nextJsRouter: 'app' +}) + +testDynamicSegments({ + path: '/pages/dynamic-segments/catch-all/foo', + expectedSegments: ['foo'], + nextJsRouter: 'pages' +}) + +testDynamicSegments({ + path: '/app/dynamic-segments/catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'], + nextJsRouter: 'app' +}) + +testDynamicSegments({ + path: '/pages/dynamic-segments/catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'], + nextJsRouter: 'pages' +}) + +// Optional catch-all -- + +testDynamicSegments({ + path: '/app/dynamic-segments/optional-catch-all', // no segments + expectedSegments: [], + nextJsRouter: 'app' +}) + +testDynamicSegments({ + path: '/pages/dynamic-segments/optional-catch-all', // no segments + expectedSegments: [], + nextJsRouter: 'pages' +}) + +testDynamicSegments({ + path: '/app/dynamic-segments/optional-catch-all/foo', + expectedSegments: ['foo'], + nextJsRouter: 'app' +}) + +testDynamicSegments({ + path: '/pages/dynamic-segments/optional-catch-all/foo', + expectedSegments: ['foo'], + nextJsRouter: 'pages' +}) + +testDynamicSegments({ + path: '/app/dynamic-segments/optional-catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'], + nextJsRouter: 'app' +}) + +testDynamicSegments({ + path: '/pages/dynamic-segments/optional-catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'], + nextJsRouter: 'pages' +}) diff --git a/packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/client.tsx b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/client.tsx new file mode 100644 index 00000000..02648858 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/client.tsx @@ -0,0 +1,16 @@ +'use client' + +import { DisplaySegments } from 'e2e-shared/specs/dynamic-segments' +import { useParams } from 'next/navigation' +import { ReactNode } from 'react' + +export function ClientSegment({ children }: { children?: ReactNode }) { + const params = useParams() + const segments = params?.segments as string[] + return ( + <> + {children} + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/page.tsx b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/page.tsx new file mode 100644 index 00000000..a4d14081 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/catch-all/[...segments]/page.tsx @@ -0,0 +1,34 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString, type SearchParams } from 'nuqs/server' +import { Suspense } from 'react' +import { ClientSegment } from './client' + +type PageProps = { + params: Promise<{ segments: string[] }> + searchParams: Promise +} + +const loadTest = createLoader({ + test: parseAsString +}) + +export default async function DynamicPage(props: PageProps) { + const searchParams = await props.searchParams + const { segments } = await props.params + const { test: serverState } = loadTest(searchParams) + return ( + <> + + + + + + + + + + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/client.tsx b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/client.tsx new file mode 100644 index 00000000..83c0a4b4 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/client.tsx @@ -0,0 +1,16 @@ +'use client' + +import { DisplaySegments } from 'e2e-shared/specs/dynamic-segments' +import { useParams } from 'next/navigation' +import { ReactNode } from 'react' + +export function ClientSegment({ children }: { children?: ReactNode }) { + const params = useParams() + const segment = params?.segment as string + return ( + <> + {children} + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/page.tsx b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/page.tsx new file mode 100644 index 00000000..f961cc24 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/dynamic/[segment]/page.tsx @@ -0,0 +1,34 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString, type SearchParams } from 'nuqs/server' +import { Suspense } from 'react' +import { ClientSegment } from './client' + +type PageProps = { + params: Promise<{ segment: string }> + searchParams: Promise +} + +const loadTest = createLoader({ + test: parseAsString +}) + +export default async function DynamicPage(props: PageProps) { + const searchParams = await props.searchParams + const { segment } = await props.params + const { test: serverState } = loadTest(searchParams) + return ( + <> + + + + + + + + + + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/client.tsx b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/client.tsx new file mode 100644 index 00000000..02648858 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/client.tsx @@ -0,0 +1,16 @@ +'use client' + +import { DisplaySegments } from 'e2e-shared/specs/dynamic-segments' +import { useParams } from 'next/navigation' +import { ReactNode } from 'react' + +export function ClientSegment({ children }: { children?: ReactNode }) { + const params = useParams() + const segments = params?.segments as string[] + return ( + <> + {children} + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/page.tsx b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/page.tsx new file mode 100644 index 00000000..a4d14081 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/dynamic-segments/optional-catch-all/[[...segments]]/page.tsx @@ -0,0 +1,34 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString, type SearchParams } from 'nuqs/server' +import { Suspense } from 'react' +import { ClientSegment } from './client' + +type PageProps = { + params: Promise<{ segments: string[] }> + searchParams: Promise +} + +const loadTest = createLoader({ + test: parseAsString +}) + +export default async function DynamicPage(props: PageProps) { + const searchParams = await props.searchParams + const { segments } = await props.params + const { test: serverState } = loadTest(searchParams) + return ( + <> + + + + + + + + + + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx index 3c5d4f82..a0f23b75 100644 --- a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx +++ b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { createSearchParamsCache, parseAsString, @@ -29,7 +29,7 @@ export default async function Page({ searchParams }: PageProps) { - + ) } diff --git a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx index 221be41f..17414323 100644 --- a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx +++ b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { createSearchParamsCache, parseAsString, @@ -29,7 +29,7 @@ export default async function Page({ searchParams }: PageProps) { - + ) } diff --git a/packages/e2e/next/src/pages/pages/dynamic-segments/catch-all/[...segments].tsx b/packages/e2e/next/src/pages/pages/dynamic-segments/catch-all/[...segments].tsx new file mode 100644 index 00000000..74ee81cd --- /dev/null +++ b/packages/e2e/next/src/pages/pages/dynamic-segments/catch-all/[...segments].tsx @@ -0,0 +1,38 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' +import { useRouter } from 'next/router' + +type Props = { + serverState: string | null + serverSegments: string[] +} + +export default function Page({ serverState, serverSegments }: Props) { + const router = useRouter() + return ( + <> + + + + + + + ) +} + +export function getServerSideProps( + ctx: GetServerSidePropsContext +): GetServerSidePropsResult { + const serverState = (ctx.query.test as string) ?? null + const serverSegments = ctx.params?.segments as string[] + return { + props: { + serverState, + serverSegments + } + } +} diff --git a/packages/e2e/next/src/pages/pages/dynamic-segments/dynamic/[segment].tsx b/packages/e2e/next/src/pages/pages/dynamic-segments/dynamic/[segment].tsx new file mode 100644 index 00000000..6f007620 --- /dev/null +++ b/packages/e2e/next/src/pages/pages/dynamic-segments/dynamic/[segment].tsx @@ -0,0 +1,38 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' +import { useRouter } from 'next/router' + +type Props = { + serverState: string | null + serverSegments: string[] +} + +export default function Page({ serverState, serverSegments }: Props) { + const router = useRouter() + return ( + <> + + + + + + + ) +} + +export function getServerSideProps( + ctx: GetServerSidePropsContext +): GetServerSidePropsResult { + const serverState = (ctx.query.test as string) ?? null + const serverSegments = [ctx.params?.segment as string] + return { + props: { + serverState, + serverSegments + } + } +} diff --git a/packages/e2e/next/src/pages/pages/dynamic-segments/optional-catch-all/[[...segments]].tsx b/packages/e2e/next/src/pages/pages/dynamic-segments/optional-catch-all/[[...segments]].tsx new file mode 100644 index 00000000..dc0c9d06 --- /dev/null +++ b/packages/e2e/next/src/pages/pages/dynamic-segments/optional-catch-all/[[...segments]].tsx @@ -0,0 +1,42 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' +import { useRouter } from 'next/router' + +type Props = { + serverState: string | null + serverSegments: string[] | null +} + +export default function Page({ serverState, serverSegments }: Props) { + const router = useRouter() + console.dir({ client: router.query.segments, server: serverSegments }) + return ( + <> + + + + + + + ) +} + +export function getServerSideProps( + ctx: GetServerSidePropsContext +): GetServerSidePropsResult { + const serverState = (ctx.query.test as string) ?? null + const serverSegments = (ctx.params?.segments as string[]) ?? null // Can't serialize undefined + return { + props: { + serverState, + serverSegments + } + } +} diff --git a/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx b/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx index 6d440b4f..51c66315 100644 --- a/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx +++ b/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' type Props = { @@ -10,7 +10,7 @@ export default function Page({ serverState }: Props) { return ( <> - + ) } diff --git a/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx b/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx index 83196cca..0cb3b3f7 100644 --- a/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx +++ b/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' type Props = { @@ -10,7 +10,7 @@ export default function Page({ serverState }: Props) { return ( <> - + ) } diff --git a/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx b/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx index c63329d7..4922089d 100644 --- a/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx +++ b/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom' export async function loader({ request }: LoaderFunctionArgs) { @@ -14,7 +14,7 @@ export default function Page() { return ( <> - + ) } diff --git a/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx b/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx index fb3df68c..538410af 100644 --- a/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx +++ b/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom' export async function loader({ request }: LoaderFunctionArgs) { @@ -14,7 +14,7 @@ export default function Page() { return ( <> - + ) } diff --git a/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx b/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx index d30cdaa4..8004029a 100644 --- a/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx +++ b/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { useLoaderData, type LoaderFunctionArgs } from 'react-router' export async function loader({ request }: LoaderFunctionArgs) { @@ -14,7 +14,7 @@ export default function Page() { return ( <> - + ) } diff --git a/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx b/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx index aa821083..e4d4d60e 100644 --- a/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx +++ b/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { useLoaderData, type LoaderFunctionArgs } from 'react-router' export async function loader({ request }: LoaderFunctionArgs) { @@ -14,7 +14,7 @@ export default function Page() { return ( <> - + ) } diff --git a/packages/e2e/remix/app/routes/shallow.useQueryState.tsx b/packages/e2e/remix/app/routes/shallow.useQueryState.tsx index adb32fff..90ddb11c 100644 --- a/packages/e2e/remix/app/routes/shallow.useQueryState.tsx +++ b/packages/e2e/remix/app/routes/shallow.useQueryState.tsx @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) @@ -15,7 +15,7 @@ export default function Page() { return ( <> - + ) } diff --git a/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx b/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx index 3980cbad..dfd8b7ff 100644 --- a/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx +++ b/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) @@ -15,7 +15,7 @@ export default function Page() { return ( <> - + ) } diff --git a/packages/e2e/shared/components/display.tsx b/packages/e2e/shared/components/display.tsx new file mode 100644 index 00000000..1e82ea91 --- /dev/null +++ b/packages/e2e/shared/components/display.tsx @@ -0,0 +1,13 @@ +export type DisplayProps = { + environment: 'client' | 'server' + target?: string + state: string | null +} + +export function Display({ + state, + environment, + target = 'state' +}: DisplayProps) { + return
{state}
+} diff --git a/packages/e2e/shared/lib/options.ts b/packages/e2e/shared/lib/options.ts new file mode 100644 index 00000000..3b981fe4 --- /dev/null +++ b/packages/e2e/shared/lib/options.ts @@ -0,0 +1,14 @@ +import { + createLoader, + createSerializer, + parseAsBoolean, + parseAsStringLiteral +} from 'nuqs' + +export const optionsSearchParams = { + shallow: parseAsBoolean.withDefault(true), + history: parseAsStringLiteral(['replace', 'push']).withDefault('replace') +} + +export const getOptionsUrl = createSerializer(optionsSearchParams) +export const loadOptions = createLoader(optionsSearchParams) diff --git a/packages/e2e/shared/specs/dynamic-segments.cy.ts b/packages/e2e/shared/specs/dynamic-segments.cy.ts new file mode 100644 index 00000000..92d2408a --- /dev/null +++ b/packages/e2e/shared/specs/dynamic-segments.cy.ts @@ -0,0 +1,54 @@ +import { createTest, type TestConfig } from '../create-test' +import { getOptionsUrl } from '../lib/options' + +type TestDynamicSegmentsOptions = TestConfig & { + expectedSegments: string[] +} + +export function testDynamicSegments({ + expectedSegments, + ...options +}: TestDynamicSegmentsOptions) { + function expectSegments(environment: 'client' | 'server') { + if (expectedSegments.length === 0) { + cy.get(`#${environment}-segments`).should('be.empty') + } else { + cy.get(`#${environment}-segments`).should( + 'have.text', + JSON.stringify(expectedSegments) + ) + } + } + const factory = createTest('Dynamic segments', ({ path }) => { + for (const shallow of [true, false]) { + for (const history of ['replace', 'push'] as const) { + it(`${path}: Updates with ({ shallow: ${shallow}, history: ${history} })`, () => { + cy.visit(getOptionsUrl(path, { shallow, history })) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#client-state').should('be.empty') + cy.get('#server-state').should('be.empty') + expectSegments('server') + expectSegments('client') + cy.get('button').click() + cy.get('#client-state').should('have.text', 'pass') + expectSegments('server') + expectSegments('client') + if (shallow === false) { + cy.get('#server-state').should('have.text', 'pass') + } else { + cy.get('#server-state').should('be.empty') + } + if (history !== 'push') { + return + } + cy.go('back') + cy.get('#client-state').should('be.empty') + cy.get('#server-state').should('be.empty') + expectSegments('server') + expectSegments('client') + }) + } + } + }) + return factory(options) +} diff --git a/packages/e2e/shared/specs/dynamic-segments.tsx b/packages/e2e/shared/specs/dynamic-segments.tsx new file mode 100644 index 00000000..08730149 --- /dev/null +++ b/packages/e2e/shared/specs/dynamic-segments.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useQueryState, useQueryStates } from 'nuqs' +import type { ReactNode } from 'react' +import { Display, type DisplayProps } from '../components/display' +import { optionsSearchParams } from '../lib/options' + +type UrlControlsProps = { + children?: ReactNode +} + +export function UrlControls({ children }: UrlControlsProps) { + const [{ shallow, history }] = useQueryStates(optionsSearchParams) + const [state, setState] = useQueryState('test', { shallow, history }) + return ( + <> + + {children} + + + ) +} + +// -- + +type DisplaySegmentsProps = Pick & { + segments: string[] | undefined +} + +export function DisplaySegments({ + segments, + environment +}: DisplaySegmentsProps) { + return ( + + ) +} diff --git a/packages/e2e/shared/specs/shallow-display.tsx b/packages/e2e/shared/specs/shallow-display.tsx deleted file mode 100644 index 90a71c8d..00000000 --- a/packages/e2e/shared/specs/shallow-display.tsx +++ /dev/null @@ -1,8 +0,0 @@ -type ShallowDisplayProps = { - environment: 'client' | 'server' - state: string | null -} - -export function ShallowDisplay({ state, environment }: ShallowDisplayProps) { - return
{state}
-} diff --git a/packages/e2e/shared/specs/shallow.cy.ts b/packages/e2e/shared/specs/shallow.cy.ts index 938140ba..a2462ad6 100644 --- a/packages/e2e/shared/specs/shallow.cy.ts +++ b/packages/e2e/shared/specs/shallow.cy.ts @@ -1,5 +1,5 @@ import { createTest, type TestConfig } from '../create-test' -import { getShallowUrl } from './shallow.defs' +import { getOptionsUrl } from '../lib/options' type TestShallowOptions = TestConfig & { supportsSSR?: boolean @@ -17,7 +17,7 @@ export function testShallow({ for (const shallow of shallowOptions) { for (const history of historyOptions) { it(`Updates with ({ shallow: ${shallow}, history: ${history} })`, () => { - cy.visit(getShallowUrl(path, { shallow, history })) + cy.visit(getOptionsUrl(path, { shallow, history })) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('#client-state').should('be.empty') if (supportsSSR) { diff --git a/packages/e2e/shared/specs/shallow.defs.ts b/packages/e2e/shared/specs/shallow.defs.ts deleted file mode 100644 index 925776ec..00000000 --- a/packages/e2e/shared/specs/shallow.defs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createSerializer, parseAsBoolean, parseAsStringLiteral } from 'nuqs' - -export const shallowSearchParams = { - shallow: parseAsBoolean.withDefault(true), - history: parseAsStringLiteral(['replace', 'push']).withDefault('replace') -} - -export const getShallowUrl = createSerializer(shallowSearchParams) diff --git a/packages/e2e/shared/specs/shallow.tsx b/packages/e2e/shared/specs/shallow.tsx index c7d6d96e..ff705275 100644 --- a/packages/e2e/shared/specs/shallow.tsx +++ b/packages/e2e/shared/specs/shallow.tsx @@ -1,22 +1,22 @@ 'use client' import { parseAsString, useQueryState, useQueryStates } from 'nuqs' -import { ShallowDisplay } from './shallow-display' -import { shallowSearchParams } from './shallow.defs' +import { Display } from '../components/display' +import { optionsSearchParams } from '../lib/options' export function ShallowUseQueryState() { - const [{ shallow, history }] = useQueryStates(shallowSearchParams) + const [{ shallow, history }] = useQueryStates(optionsSearchParams) const [state, setState] = useQueryState('test', { shallow, history }) return ( <> - + ) } export function ShallowUseQueryStates() { - const [{ shallow, history }] = useQueryStates(shallowSearchParams) + const [{ shallow, history }] = useQueryStates(optionsSearchParams) const [{ state }, setSearchParams] = useQueryStates( { state: parseAsString.withOptions({ shallow, history }) @@ -30,7 +30,7 @@ export function ShallowUseQueryStates() { return ( <> - + ) } diff --git a/packages/nuqs/src/adapters/next/impl.pages.test.ts b/packages/nuqs/src/adapters/next/impl.pages.test.ts new file mode 100644 index 00000000..54005bd6 --- /dev/null +++ b/packages/nuqs/src/adapters/next/impl.pages.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { extractDynamicUrlParams, getAsPathPathname } from './impl.pages' + +describe('Next Pages Adapter: getAsPathPathname', () => { + it('returns a pure pathname', () => { + expect(getAsPathPathname('')).toBe('') + expect(getAsPathPathname('/')).toBe('/') + expect(getAsPathPathname('/a/b/c')).toBe('/a/b/c') + }) + it('strips search params', () => { + expect(getAsPathPathname('/a/b/c?foo=bar')).toBe('/a/b/c') + expect(getAsPathPathname('/a/b/c?foo=bar&baz=qux')).toBe('/a/b/c') + }) + it('strips hash', () => { + expect(getAsPathPathname('/a/b/c#foo')).toBe('/a/b/c') + expect(getAsPathPathname('/a/b/c#foo/bar')).toBe('/a/b/c') + }) + it('strips both search params and hash', () => { + expect(getAsPathPathname('/a/b/c?foo=bar#baz')).toBe('/a/b/c') + expect(getAsPathPathname('/a/b/c?foo=bar&baz=qux#quux')).toBe('/a/b/c') + }) +}) + +describe('Next Pages Adapter: extractDynamicUrlParams', () => { + it('returns an empty object when no dynamic params are present', () => { + const result = extractDynamicUrlParams('/path/without/params', { + ignored: 'gone' // ignored + }) + expect(result).toEqual({}) + }) + + it('returns an object with dynamic params', () => { + const result = extractDynamicUrlParams('/path/[foo]/[bar]', { + foo: 'bar', + bar: 'baz', + ignored: 'gone' + }) + expect(result).toEqual({ foo: 'bar', bar: 'baz' }) + }) + + it('maps missing dynamic params to undefined', () => { + const result = extractDynamicUrlParams('/path/[foo]/[bar]', { + bar: 'baz' // foo is missing + }) + expect(result).toEqual({ foo: undefined, bar: 'baz' }) + }) + + // -- + + it('returns an array for catch-all params', () => { + const result = extractDynamicUrlParams('/path/[...params]', { + params: ['foo', 'bar'], + ignored: 'gone' + }) + expect(result).toEqual({ params: ['foo', 'bar'] }) + }) + + // -- + + it('returns an empty array for optional catch-all params without values', () => { + const result = extractDynamicUrlParams('/path/[[...params]]', { + ignored: 'gone' + }) + expect(result).toEqual({ params: [] }) + }) + + it('returns an array with a single item for optional catch-all params with a single value', () => { + const result = extractDynamicUrlParams('/path/[[...params]]', { + params: ['foo'], + ignored: 'gone' + }) + expect(result).toEqual({ params: ['foo'] }) + }) + + it('returns an array with multiple items for optional catch-all params with multiple values', () => { + const result = extractDynamicUrlParams('/path/[[...params]]', { + params: ['foo', 'bar'], + ignored: 'gone' + }) + expect(result).toEqual({ params: ['foo', 'bar'] }) + }) + + // -- + + it('supports a combination of dynamic and catch-all params', () => { + const result = extractDynamicUrlParams('/path/[foo]/[bar]/[...params]', { + foo: 'a', + bar: 'b', + params: ['baz'], + ignored: 'gone' + }) + expect(result).toEqual({ foo: 'a', bar: 'b', params: ['baz'] }) + }) + + it('supports a combination of dynamic and optional catch-all params', () => { + const result = extractDynamicUrlParams('/path/[foo]/[bar]/[[...params]]', { + foo: 'a', + bar: 'b', + params: ['baz'], + ignored: 'gone' + }) + expect(result).toEqual({ foo: 'a', bar: 'b', params: ['baz'] }) + }) +}) diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 6f5faf5c..4b8273c9 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -2,9 +2,9 @@ import { useRouter } from 'next/compat/router' import type { NextRouter } from 'next/router' import { useCallback, useMemo } from 'react' import { debug } from '../../debug' +import { renderQueryString } from '../../url-encoding' import { createAdapterProvider } from '../lib/context' import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' -import { renderURL } from './shared' declare global { interface Window { @@ -43,40 +43,41 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { const updateUrl: UpdateUrlFunction = useCallback((search, options) => { // While the Next.js team doesn't recommend using internals like this, - // we need access to the pages router here to let it know about non-shallow - // updates, as going through the window.history API directly will make it - // miss pushed history updates. - // The router adapter imported from next/navigation also doesn't support - // passing an asPath, causing issues in dynamic routes in the pages router. + // we need direct access to the pages router, as a bound/closured version from + // useRouter may be out of date by the time the updateUrl function is called, + // and would also cause updateUrl to not be referentially stable. const nextRouter = window.next?.router! const urlParams = extractDynamicUrlParams( nextRouter.pathname, nextRouter.query ) - const query = Object.fromEntries(search.entries()) - const asPath = renderURL( - nextRouter.state.asPath.split('?')[0] ?? '', - search - ) + const asPath = + getAsPathPathname(nextRouter.asPath) + + renderQueryString(search) + + location.hash debug('[nuqs queue (pages)] Updating url: %s', asPath) const method = options.history === 'push' ? nextRouter.push : nextRouter.replace - method.call( nextRouter, + // This is what makes the URL work (mapping dynamic segments placeholders + // in pathname to their values in query, plus search params in query too). { pathname: nextRouter.pathname, query: { - ...urlParams, - ...query - }, - hash: location.hash - }, - { - pathname: nextRouter.state.asPath.split('?')[0] ?? '', - query, - hash: location.hash + // Note: we put search params first so that one that conflicts + // with dynamic params will be overwritten. + // todo: Test this in practice. + ...Object.fromEntries(search.entries()), + ...urlParams + } + // For some reason we don't need to pass the hash here, + // it's preserved when passed as part of the asPath. }, + // This is what makes the URL pretty (resolved dynamic segments + // and nuqs-formatted search params). + asPath, // todo: Test formatting + // And these are the options that are passed to the router. { scroll: options.scroll, shallow: options.shallow @@ -85,15 +86,28 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { }, []) return { searchParams, - updateUrl, - // See: https://github.com/47ng/nuqs/issues/603#issuecomment-2317057128 - rateLimitFactor: 1 + updateUrl } } export const NuqsAdapter = createAdapterProvider(useNuqsNextPagesRouterAdapter) -function extractDynamicUrlParams( +export function getAsPathPathname(asPath: string): string { + return asPath + .replace(/#.*$/, '') // Remove hash + .replace(/\?.*$/, '') // Remove search +} + +/** + * Next.js pages router merges dynamic URL params with search params in its + * internal state. + * However, we need to pass just the URL params to the href part of the router + * update functions. + * This function finds the dynamic URL params placeholders in the pathname + * (eg: `/path/[foo]/[bar]`) and extracts the corresponding values from the + * query state object, leaving out any other search params. + */ +export function extractDynamicUrlParams( pathname: string, values: Record ): Record { @@ -114,12 +128,17 @@ function extractDynamicUrlParams( ) const matchCatchAll = catchAllRegex.exec(pathname) const matchOptionalCatchAll = optionalCatchAllRegex.exec(pathname) - if (matchCatchAll) { - dynamicValues[matchCatchAll[1]!] = values[matchCatchAll[1]!] ?? [] + if (matchCatchAll && matchCatchAll[1]) { + const key = matchCatchAll[1] + dynamicValues[key] = values[key] ?? [] } - if (matchOptionalCatchAll) { - dynamicValues[matchOptionalCatchAll[1]!] = - values[matchOptionalCatchAll[1]!] ?? [] + if (matchOptionalCatchAll && matchOptionalCatchAll[1]) { + const key = matchOptionalCatchAll[1] + // Note: while Next.js returns undefined if there are no values for the + // optional catch-all, passing undefined back when setting the state + // results in the value being set to an empty string. + // Passing an empty array instead results in the value remaining undefined. + dynamicValues[key] = values[key] ?? [] } return dynamicValues } From ecc182d33d0666b1d71a7053a00e701813a50f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 16 Feb 2025 16:41:34 +0100 Subject: [PATCH 05/10] chore: Fix multitenant test --- .../e2e/next/cypress/e2e/multitenant.cy.ts | 30 ++++++++++--------- .../src/app/app/multitenant/[tenant]/page.tsx | 4 +-- .../src/pages/pages/multitenant/[tenant].tsx | 4 +-- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/e2e/next/cypress/e2e/multitenant.cy.ts b/packages/e2e/next/cypress/e2e/multitenant.cy.ts index 76fa52e2..77eca316 100644 --- a/packages/e2e/next/cypress/e2e/multitenant.cy.ts +++ b/packages/e2e/next/cypress/e2e/multitenant.cy.ts @@ -1,12 +1,16 @@ import { createTest, type TestConfig } from 'e2e-shared/create-test' -import { getShallowUrl } from 'e2e-shared/specs/shallow.defs' +import { getOptionsUrl } from 'e2e-shared/lib/options' -function testMultiTenant(options: TestConfig) { +function testMultiTenant( + options: TestConfig & { + expectedPathname: string + } +) { const factory = createTest('Multitenant', ({ path }) => { for (const shallow of [true, false]) { for (const history of ['replace', 'push'] as const) { it(`Updates with ({ shallow: ${shallow}, history: ${history} })`, () => { - cy.visit(getShallowUrl(path, { shallow, history })) + cy.visit(getOptionsUrl(path, { shallow, history })) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('#client-state').should('be.empty') cy.get('#server-state').should('be.empty') @@ -14,9 +18,7 @@ function testMultiTenant(options: TestConfig) { cy.get('#server-tenant').should('have.text', 'david') cy.get('#router-pathname').should( 'have.text', - options.nextJsRouter === 'pages' - ? '/pages/multitenant/[tenant]' - : '/app/multitenant' + options.expectedPathname ) cy.get('button').click() cy.get('#client-state').should('have.text', 'pass') @@ -24,9 +26,7 @@ function testMultiTenant(options: TestConfig) { cy.get('#server-tenant').should('have.text', 'david') cy.get('#router-pathname').should( 'have.text', - options.nextJsRouter === 'pages' - ? '/pages/multitenant/[tenant]' - : '/app/multitenant' + options.expectedPathname ) if (shallow === false) { cy.get('#server-state').should('have.text', 'pass') @@ -43,9 +43,7 @@ function testMultiTenant(options: TestConfig) { cy.get('#server-state').should('be.empty') cy.get('#router-pathname').should( 'have.text', - options.nextJsRouter === 'pages' - ? '/pages/multitenant/[tenant]' - : '/app/multitenant' + options.expectedPathname ) }) } @@ -57,10 +55,14 @@ function testMultiTenant(options: TestConfig) { testMultiTenant({ path: '/app/multitenant', - nextJsRouter: 'app' + nextJsRouter: 'app', + description: 'Dynamic route', + expectedPathname: '/app/multitenant' }) testMultiTenant({ path: '/pages/multitenant', - nextJsRouter: 'pages' + nextJsRouter: 'pages', + description: 'Dynamic route', + expectedPathname: '/pages/multitenant/[tenant]' }) diff --git a/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx b/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx index 4232b5e7..88f43de8 100644 --- a/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx +++ b/packages/e2e/next/src/app/app/multitenant/[tenant]/page.tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { createSearchParamsCache, parseAsString, @@ -36,7 +36,7 @@ export default async function TenantPage({ params, searchParams }: PageProps) { - +

{tenant}

diff --git a/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx b/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx index 54b6922c..d5339070 100644 --- a/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx +++ b/packages/e2e/next/src/pages/pages/multitenant/[tenant].tsx @@ -1,5 +1,5 @@ +import { Display } from 'e2e-shared/components/display' import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' import { useParams } from 'next/navigation' import { useRouter } from 'next/router' @@ -15,7 +15,7 @@ export default function Page({ serverState, tenant }: Props) { return ( <> - +

{tenant}

{params?.tenant}

{router.pathname}

From 408d783b2c28268a68a82b38cdaf7328d4dabe08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 16 Feb 2025 20:00:11 +0100 Subject: [PATCH 06/10] test: Add pretty URL encoding e2e test --- .../e2e/next/cypress/e2e/shared/pretty-urls.cy.ts | 11 +++++++++++ .../next/src/app/app/(shared)/pretty-urls/page.tsx | 10 ++++++++++ packages/e2e/next/src/pages/pages/pretty-urls.tsx | 3 +++ .../v6/cypress/e2e/shared/pretty-urls.cy.ts | 5 +++++ packages/e2e/react-router/v6/src/react-router.tsx | 1 + .../e2e/react-router/v6/src/routes/pretty-urls.tsx | 3 +++ packages/e2e/react-router/v7/app/routes.ts | 1 + .../e2e/react-router/v7/app/routes/pretty-urls.tsx | 3 +++ .../v7/cypress/e2e/shared/pretty-urls.cy.ts | 5 +++++ .../e2e/react/cypress/e2e/shared/pretty-urls.cy.ts | 5 +++++ packages/e2e/react/src/routes.tsx | 1 + packages/e2e/react/src/routes/pretty-urls.tsx | 3 +++ packages/e2e/remix/app/routes/pretty-urls.tsx | 3 +++ .../e2e/remix/cypress/e2e/shared/pretty-urls.cy.ts | 5 +++++ packages/e2e/shared/specs/pretty-urls.cy.ts | 10 ++++++++++ packages/e2e/shared/specs/pretty-urls.tsx | 13 +++++++++++++ 16 files changed, 82 insertions(+) create mode 100644 packages/e2e/next/cypress/e2e/shared/pretty-urls.cy.ts create mode 100644 packages/e2e/next/src/app/app/(shared)/pretty-urls/page.tsx create mode 100644 packages/e2e/next/src/pages/pages/pretty-urls.tsx create mode 100644 packages/e2e/react-router/v6/cypress/e2e/shared/pretty-urls.cy.ts create mode 100644 packages/e2e/react-router/v6/src/routes/pretty-urls.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/pretty-urls.tsx create mode 100644 packages/e2e/react-router/v7/cypress/e2e/shared/pretty-urls.cy.ts create mode 100644 packages/e2e/react/cypress/e2e/shared/pretty-urls.cy.ts create mode 100644 packages/e2e/react/src/routes/pretty-urls.tsx create mode 100644 packages/e2e/remix/app/routes/pretty-urls.tsx create mode 100644 packages/e2e/remix/cypress/e2e/shared/pretty-urls.cy.ts create mode 100644 packages/e2e/shared/specs/pretty-urls.cy.ts create mode 100644 packages/e2e/shared/specs/pretty-urls.tsx diff --git a/packages/e2e/next/cypress/e2e/shared/pretty-urls.cy.ts b/packages/e2e/next/cypress/e2e/shared/pretty-urls.cy.ts new file mode 100644 index 00000000..f02f826c --- /dev/null +++ b/packages/e2e/next/cypress/e2e/shared/pretty-urls.cy.ts @@ -0,0 +1,11 @@ +import { testPrettyUrls } from 'e2e-shared/specs/pretty-urls.cy' + +testPrettyUrls({ + path: '/app/pretty-urls', + nextJsRouter: 'app' +}) + +testPrettyUrls({ + path: '/pages/pretty-urls', + nextJsRouter: 'pages' +}) diff --git a/packages/e2e/next/src/app/app/(shared)/pretty-urls/page.tsx b/packages/e2e/next/src/app/app/(shared)/pretty-urls/page.tsx new file mode 100644 index 00000000..ee7367f5 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/pretty-urls/page.tsx @@ -0,0 +1,10 @@ +import { PrettyUrls } from 'e2e-shared/specs/pretty-urls' +import { Suspense } from 'react' + +export default function Page() { + return ( + + + + ) +} diff --git a/packages/e2e/next/src/pages/pages/pretty-urls.tsx b/packages/e2e/next/src/pages/pages/pretty-urls.tsx new file mode 100644 index 00000000..05480650 --- /dev/null +++ b/packages/e2e/next/src/pages/pages/pretty-urls.tsx @@ -0,0 +1,3 @@ +import { PrettyUrls } from 'e2e-shared/specs/pretty-urls' + +export default PrettyUrls diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/pretty-urls.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/pretty-urls.cy.ts new file mode 100644 index 00000000..9c2771a7 --- /dev/null +++ b/packages/e2e/react-router/v6/cypress/e2e/shared/pretty-urls.cy.ts @@ -0,0 +1,5 @@ +import { testPrettyUrls } from 'e2e-shared/specs/pretty-urls.cy' + +testPrettyUrls({ + path: '/pretty-urls' +}) diff --git a/packages/e2e/react-router/v6/src/react-router.tsx b/packages/e2e/react-router/v6/src/react-router.tsx index 6d007b93..4fdd1671 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -46,6 +46,7 @@ const router = createBrowserRouter( + diff --git a/packages/e2e/react-router/v6/src/routes/pretty-urls.tsx b/packages/e2e/react-router/v6/src/routes/pretty-urls.tsx new file mode 100644 index 00000000..05480650 --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/pretty-urls.tsx @@ -0,0 +1,3 @@ +import { PrettyUrls } from 'e2e-shared/specs/pretty-urls' + +export default PrettyUrls diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts index 0fc04bee..b884b2ca 100644 --- a/packages/e2e/react-router/v7/app/routes.ts +++ b/packages/e2e/react-router/v7/app/routes.ts @@ -29,6 +29,7 @@ export default [ route('/conditional-rendering/useQueryState', './routes/conditional-rendering.useQueryState.tsx'), route('/conditional-rendering/useQueryStates', './routes/conditional-rendering.useQueryStates.tsx'), route('/scroll', './routes/scroll.tsx'), + route('/pretty-urls', './routes/pretty-urls.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'), diff --git a/packages/e2e/react-router/v7/app/routes/pretty-urls.tsx b/packages/e2e/react-router/v7/app/routes/pretty-urls.tsx new file mode 100644 index 00000000..05480650 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/pretty-urls.tsx @@ -0,0 +1,3 @@ +import { PrettyUrls } from 'e2e-shared/specs/pretty-urls' + +export default PrettyUrls diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/pretty-urls.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/pretty-urls.cy.ts new file mode 100644 index 00000000..9c2771a7 --- /dev/null +++ b/packages/e2e/react-router/v7/cypress/e2e/shared/pretty-urls.cy.ts @@ -0,0 +1,5 @@ +import { testPrettyUrls } from 'e2e-shared/specs/pretty-urls.cy' + +testPrettyUrls({ + path: '/pretty-urls' +}) diff --git a/packages/e2e/react/cypress/e2e/shared/pretty-urls.cy.ts b/packages/e2e/react/cypress/e2e/shared/pretty-urls.cy.ts new file mode 100644 index 00000000..9c2771a7 --- /dev/null +++ b/packages/e2e/react/cypress/e2e/shared/pretty-urls.cy.ts @@ -0,0 +1,5 @@ +import { testPrettyUrls } from 'e2e-shared/specs/pretty-urls.cy' + +testPrettyUrls({ + path: '/pretty-urls' +}) diff --git a/packages/e2e/react/src/routes.tsx b/packages/e2e/react/src/routes.tsx index 13e91c52..da82b854 100644 --- a/packages/e2e/react/src/routes.tsx +++ b/packages/e2e/react/src/routes.tsx @@ -24,6 +24,7 @@ const routes: Record JSX.Element>> = { '/conditional-rendering/useQueryState': lazy(() => import('./routes/conditional-rendering.useQueryState')), '/conditional-rendering/useQueryStates': lazy(() => import('./routes/conditional-rendering.useQueryStates')), '/scroll': lazy(() => import('./routes/scroll')), + '/pretty-urls': lazy(() => import('./routes/pretty-urls')), '/render-count/useQueryState/true/replace/false': lazy(() => import('./routes/render-count')), '/render-count/useQueryState/true/replace/true': lazy(() => import('./routes/render-count')), diff --git a/packages/e2e/react/src/routes/pretty-urls.tsx b/packages/e2e/react/src/routes/pretty-urls.tsx new file mode 100644 index 00000000..05480650 --- /dev/null +++ b/packages/e2e/react/src/routes/pretty-urls.tsx @@ -0,0 +1,3 @@ +import { PrettyUrls } from 'e2e-shared/specs/pretty-urls' + +export default PrettyUrls diff --git a/packages/e2e/remix/app/routes/pretty-urls.tsx b/packages/e2e/remix/app/routes/pretty-urls.tsx new file mode 100644 index 00000000..05480650 --- /dev/null +++ b/packages/e2e/remix/app/routes/pretty-urls.tsx @@ -0,0 +1,3 @@ +import { PrettyUrls } from 'e2e-shared/specs/pretty-urls' + +export default PrettyUrls diff --git a/packages/e2e/remix/cypress/e2e/shared/pretty-urls.cy.ts b/packages/e2e/remix/cypress/e2e/shared/pretty-urls.cy.ts new file mode 100644 index 00000000..9c2771a7 --- /dev/null +++ b/packages/e2e/remix/cypress/e2e/shared/pretty-urls.cy.ts @@ -0,0 +1,5 @@ +import { testPrettyUrls } from 'e2e-shared/specs/pretty-urls.cy' + +testPrettyUrls({ + path: '/pretty-urls' +}) diff --git a/packages/e2e/shared/specs/pretty-urls.cy.ts b/packages/e2e/shared/specs/pretty-urls.cy.ts new file mode 100644 index 00000000..a797dbf3 --- /dev/null +++ b/packages/e2e/shared/specs/pretty-urls.cy.ts @@ -0,0 +1,10 @@ +import { createTest } from '../create-test' + +export const testPrettyUrls = createTest('Pretty URLs', ({ path }) => { + it('should render unencoded characters', () => { + cy.visit(path) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('button').click() + cy.get('#state').should('have.text', '-._~!$()*,;=:@/?[]{}\\|^') + }) +}) diff --git a/packages/e2e/shared/specs/pretty-urls.tsx b/packages/e2e/shared/specs/pretty-urls.tsx new file mode 100644 index 00000000..8081851f --- /dev/null +++ b/packages/e2e/shared/specs/pretty-urls.tsx @@ -0,0 +1,13 @@ +'use client' + +import { useQueryState } from 'nuqs' + +export function PrettyUrls() { + const [state, setState] = useQueryState('test') + return ( + <> + +
{state}
+ + ) +} From d61d570bd5c0d618d4e2a21a57fb4f88b727fce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 16 Feb 2025 22:25:28 +0100 Subject: [PATCH 07/10] test: Add dynamic segments e2e test for RR-based frameworks --- .../cypress/e2e/shared/dynamic-segments.cy.ts | 16 ++++++++ .../e2e/react-router/v6/src/react-router.tsx | 2 + .../routes/dynamic-segments.catch-all.$.tsx | 36 +++++++++++++++++ .../dynamic-segments.dynamic.$segment.tsx | 39 +++++++++++++++++++ packages/e2e/react-router/v7/app/routes.ts | 2 + .../routes/dynamic-segments.catch-all.$.tsx | 30 ++++++++++++++ .../dynamic-segments.dynamic.$segment.tsx | 33 ++++++++++++++++ .../cypress/e2e/shared/dynamic-segments.cy.ts | 16 ++++++++ .../routes/dynamic-segments.catch-all.$.tsx | 31 +++++++++++++++ .../dynamic-segments.dynamic.$segment.tsx | 34 ++++++++++++++++ .../cypress/e2e/shared/dynamic-segments.cy.ts | 16 ++++++++ 11 files changed, 255 insertions(+) create mode 100644 packages/e2e/react-router/v6/cypress/e2e/shared/dynamic-segments.cy.ts create mode 100644 packages/e2e/react-router/v6/src/routes/dynamic-segments.catch-all.$.tsx create mode 100644 packages/e2e/react-router/v6/src/routes/dynamic-segments.dynamic.$segment.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/dynamic-segments.catch-all.$.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/dynamic-segments.dynamic.$segment.tsx create mode 100644 packages/e2e/react-router/v7/cypress/e2e/shared/dynamic-segments.cy.ts create mode 100644 packages/e2e/remix/app/routes/dynamic-segments.catch-all.$.tsx create mode 100644 packages/e2e/remix/app/routes/dynamic-segments.dynamic.$segment.tsx create mode 100644 packages/e2e/remix/cypress/e2e/shared/dynamic-segments.cy.ts diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/dynamic-segments.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/dynamic-segments.cy.ts new file mode 100644 index 00000000..3f960424 --- /dev/null +++ b/packages/e2e/react-router/v6/cypress/e2e/shared/dynamic-segments.cy.ts @@ -0,0 +1,16 @@ +import { testDynamicSegments } from 'e2e-shared/specs/dynamic-segments.cy' + +testDynamicSegments({ + path: '/dynamic-segments/dynamic/foo', + expectedSegments: ['foo'] +}) + +testDynamicSegments({ + path: '/dynamic-segments/catch-all', + expectedSegments: [''] +}) + +testDynamicSegments({ + path: '/dynamic-segments/catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'] +}) diff --git a/packages/e2e/react-router/v6/src/react-router.tsx b/packages/e2e/react-router/v6/src/react-router.tsx index 4fdd1671..eef7cc4d 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -47,6 +47,8 @@ const router = createBrowserRouter( + + diff --git a/packages/e2e/react-router/v6/src/routes/dynamic-segments.catch-all.$.tsx b/packages/e2e/react-router/v6/src/routes/dynamic-segments.catch-all.$.tsx new file mode 100644 index 00000000..eb8144ba --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/dynamic-segments.catch-all.$.tsx @@ -0,0 +1,36 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString } from 'nuqs' +import { + useLoaderData, + useParams, + type LoaderFunctionArgs +} from 'react-router-dom' + +const loadSearchParams = createLoader({ + test: parseAsString +}) + +export function loader({ request, params }: LoaderFunctionArgs) { + const { test: serverState } = loadSearchParams(request) + return { + serverState, + serverSegments: params['*']?.split('/') ?? [] + } +} + +export default function Page() { + const { serverState, serverSegments } = useLoaderData() as ReturnType< + typeof loader + > + const clientSegments = useParams()['*']?.split('/') ?? [] + return ( + <> + + + + + + + ) +} diff --git a/packages/e2e/react-router/v6/src/routes/dynamic-segments.dynamic.$segment.tsx b/packages/e2e/react-router/v6/src/routes/dynamic-segments.dynamic.$segment.tsx new file mode 100644 index 00000000..e132edc1 --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/dynamic-segments.dynamic.$segment.tsx @@ -0,0 +1,39 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString } from 'nuqs' +import { + useLoaderData, + useParams, + type LoaderFunctionArgs +} from 'react-router-dom' + +const loadSearchParams = createLoader({ + test: parseAsString +}) + +export function loader({ request, params }: LoaderFunctionArgs) { + const { test: serverState } = loadSearchParams(request) + return { + serverState, + serverSegments: [params.segment as string] + } +} + +export default function Page() { + const { serverState, serverSegments } = useLoaderData() as ReturnType< + typeof loader + > + const params = useParams() + return ( + <> + + + + + + + ) +} diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts index b884b2ca..29b7d763 100644 --- a/packages/e2e/react-router/v7/app/routes.ts +++ b/packages/e2e/react-router/v7/app/routes.ts @@ -30,6 +30,8 @@ export default [ route('/conditional-rendering/useQueryStates', './routes/conditional-rendering.useQueryStates.tsx'), route('/scroll', './routes/scroll.tsx'), route('/pretty-urls', './routes/pretty-urls.tsx'), + route('/dynamic-segments/dynamic/:segment', './routes/dynamic-segments.dynamic.$segment.tsx'), + route('/dynamic-segments/catch-all?/*', './routes/dynamic-segments.catch-all.$.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'), diff --git a/packages/e2e/react-router/v7/app/routes/dynamic-segments.catch-all.$.tsx b/packages/e2e/react-router/v7/app/routes/dynamic-segments.catch-all.$.tsx new file mode 100644 index 00000000..4da98005 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/dynamic-segments.catch-all.$.tsx @@ -0,0 +1,30 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString } from 'nuqs' +import { useLoaderData, useParams, type LoaderFunctionArgs } from 'react-router' + +const loadSearchParams = createLoader({ + test: parseAsString +}) + +export function loader({ request, params }: LoaderFunctionArgs) { + const { test: serverState } = loadSearchParams(request) + return { + serverState, + serverSegments: params['*']?.split('/') ?? [] + } +} + +export default function Page() { + const { serverState, serverSegments } = useLoaderData() + const clientSegments = useParams()['*']?.split('/') ?? [] + return ( + <> + + + + + + + ) +} diff --git a/packages/e2e/react-router/v7/app/routes/dynamic-segments.dynamic.$segment.tsx b/packages/e2e/react-router/v7/app/routes/dynamic-segments.dynamic.$segment.tsx new file mode 100644 index 00000000..58ad0a40 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/dynamic-segments.dynamic.$segment.tsx @@ -0,0 +1,33 @@ +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString } from 'nuqs' +import { useLoaderData, useParams, type LoaderFunctionArgs } from 'react-router' + +const loadSearchParams = createLoader({ + test: parseAsString +}) + +export function loader({ request, params }: LoaderFunctionArgs) { + const { test: serverState } = loadSearchParams(request) + return { + serverState, + serverSegments: [params.segment as string] + } +} + +export default function Page() { + const { serverState, serverSegments } = useLoaderData() + const params = useParams() + return ( + <> + + + + + + + ) +} diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/dynamic-segments.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/dynamic-segments.cy.ts new file mode 100644 index 00000000..3f960424 --- /dev/null +++ b/packages/e2e/react-router/v7/cypress/e2e/shared/dynamic-segments.cy.ts @@ -0,0 +1,16 @@ +import { testDynamicSegments } from 'e2e-shared/specs/dynamic-segments.cy' + +testDynamicSegments({ + path: '/dynamic-segments/dynamic/foo', + expectedSegments: ['foo'] +}) + +testDynamicSegments({ + path: '/dynamic-segments/catch-all', + expectedSegments: [''] +}) + +testDynamicSegments({ + path: '/dynamic-segments/catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'] +}) diff --git a/packages/e2e/remix/app/routes/dynamic-segments.catch-all.$.tsx b/packages/e2e/remix/app/routes/dynamic-segments.catch-all.$.tsx new file mode 100644 index 00000000..2abb5f9c --- /dev/null +++ b/packages/e2e/remix/app/routes/dynamic-segments.catch-all.$.tsx @@ -0,0 +1,31 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData, useParams } from '@remix-run/react' +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString } from 'nuqs' + +const loadSearchParams = createLoader({ + test: parseAsString +}) + +export function loader({ request, params }: LoaderFunctionArgs) { + const { test: serverState } = loadSearchParams(request) + return { + serverState, + serverSegments: params['*']?.split('/') ?? [] + } +} + +export default function Page() { + const { serverState, serverSegments } = useLoaderData() + const clientSegments = useParams()['*']?.split('/') ?? [] + return ( + <> + + + + + + + ) +} diff --git a/packages/e2e/remix/app/routes/dynamic-segments.dynamic.$segment.tsx b/packages/e2e/remix/app/routes/dynamic-segments.dynamic.$segment.tsx new file mode 100644 index 00000000..265052b5 --- /dev/null +++ b/packages/e2e/remix/app/routes/dynamic-segments.dynamic.$segment.tsx @@ -0,0 +1,34 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData, useParams } from '@remix-run/react' +import { Display } from 'e2e-shared/components/display' +import { DisplaySegments, UrlControls } from 'e2e-shared/specs/dynamic-segments' +import { createLoader, parseAsString } from 'nuqs' + +const loadSearchParams = createLoader({ + test: parseAsString +}) + +export function loader({ request, params }: LoaderFunctionArgs) { + const { test: serverState } = loadSearchParams(request) + return { + serverState, + serverSegments: [params.segment] + } +} + +export default function Page() { + const { serverState, serverSegments } = useLoaderData() + const params = useParams() + return ( + <> + + + + + + + ) +} diff --git a/packages/e2e/remix/cypress/e2e/shared/dynamic-segments.cy.ts b/packages/e2e/remix/cypress/e2e/shared/dynamic-segments.cy.ts new file mode 100644 index 00000000..3f960424 --- /dev/null +++ b/packages/e2e/remix/cypress/e2e/shared/dynamic-segments.cy.ts @@ -0,0 +1,16 @@ +import { testDynamicSegments } from 'e2e-shared/specs/dynamic-segments.cy' + +testDynamicSegments({ + path: '/dynamic-segments/dynamic/foo', + expectedSegments: ['foo'] +}) + +testDynamicSegments({ + path: '/dynamic-segments/catch-all', + expectedSegments: [''] +}) + +testDynamicSegments({ + path: '/dynamic-segments/catch-all/a/b/c', + expectedSegments: ['a', 'b', 'c'] +}) From 963be7310877df315faa95a7ba200b94ee7e21d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 16 Feb 2025 22:41:56 +0100 Subject: [PATCH 08/10] chore: Cleanup --- packages/nuqs/src/adapters/next/impl.pages.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 4b8273c9..21b07998 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -67,7 +67,6 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { query: { // Note: we put search params first so that one that conflicts // with dynamic params will be overwritten. - // todo: Test this in practice. ...Object.fromEntries(search.entries()), ...urlParams } @@ -76,7 +75,7 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { }, // This is what makes the URL pretty (resolved dynamic segments // and nuqs-formatted search params). - asPath, // todo: Test formatting + asPath, // And these are the options that are passed to the router. { scroll: options.scroll, @@ -127,11 +126,11 @@ export function extractDynamicUrlParams( Object.entries(values).filter(([key]) => paramNames.has(key)) ) const matchCatchAll = catchAllRegex.exec(pathname) - const matchOptionalCatchAll = optionalCatchAllRegex.exec(pathname) if (matchCatchAll && matchCatchAll[1]) { const key = matchCatchAll[1] dynamicValues[key] = values[key] ?? [] } + const matchOptionalCatchAll = optionalCatchAllRegex.exec(pathname) if (matchOptionalCatchAll && matchOptionalCatchAll[1]) { const key = matchOptionalCatchAll[1] // Note: while Next.js returns undefined if there are no values for the From 54379ccc6d5775ae9a2e07c197a98b6cdd05246e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 17 Feb 2025 10:21:58 +0100 Subject: [PATCH 09/10] fix: Import path --- packages/nuqs/src/adapters/next/impl.pages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 21b07998..d6c06dcf 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -1,4 +1,4 @@ -import { useRouter } from 'next/compat/router' +import { useRouter } from 'next/compat/router.js' import type { NextRouter } from 'next/router' import { useCallback, useMemo } from 'react' import { debug } from '../../debug' From 1ccbe35611e57f4d9e0e9758953ba8fca5f48e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 17 Feb 2025 13:23:02 +0100 Subject: [PATCH 10/10] ref: renderURL is no longer shared --- packages/nuqs/src/adapters/next/impl.app.ts | 9 ++++++++- packages/nuqs/src/adapters/next/shared.ts | 8 -------- 2 files changed, 8 insertions(+), 9 deletions(-) delete mode 100644 packages/nuqs/src/adapters/next/shared.ts diff --git a/packages/nuqs/src/adapters/next/impl.app.ts b/packages/nuqs/src/adapters/next/impl.app.ts index 8aea3b0b..8c05cb2e 100644 --- a/packages/nuqs/src/adapters/next/impl.app.ts +++ b/packages/nuqs/src/adapters/next/impl.app.ts @@ -1,8 +1,8 @@ import { useRouter, useSearchParams } from 'next/navigation' import { startTransition, useCallback, useOptimistic } from 'react' import { debug } from '../../debug' +import { renderQueryString } from '../../url-encoding' import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' -import { renderURL } from './shared' export function useNuqsNextAppRouterAdapter(): AdapterInterface { const router = useRouter() @@ -48,3 +48,10 @@ export function useNuqsNextAppRouterAdapter(): AdapterInterface { rateLimitFactor: 2 } } + +function renderURL(base: string, search: URLSearchParams) { + const hashlessBase = base.split('#')[0] ?? '' + const query = renderQueryString(search) + const hash = location.hash + return hashlessBase + query + hash +} diff --git a/packages/nuqs/src/adapters/next/shared.ts b/packages/nuqs/src/adapters/next/shared.ts deleted file mode 100644 index 495aa7fb..00000000 --- a/packages/nuqs/src/adapters/next/shared.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { renderQueryString } from '../../url-encoding' - -export function renderURL(base: string, search: URLSearchParams) { - const hashlessBase = base.split('#')[0] ?? '' - const query = renderQueryString(search) - const hash = location.hash - return hashlessBase + query + hash -}