From 63d01eb271c5cddd70b87fe816edf6b6368c4215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Thu, 26 Sep 2024 13:04:40 +0200 Subject: [PATCH] fix: Handle Promises for the searchParams page prop (#652) Next.js 15.0.0-canary-171 introduced a breaking change in https://github.com/vercel/next.js/pull/68812 causing the searchParam page prop to be a Promise. Using overloads, the cache.parse function can now handle either, but typing the searchParams page prop in userland is becoming painful if support for both Next.js 14 and 15 is desired. Best approach is to always declare it as a Promise (with `SearchParams` imported from 'nuqs/server'), as it highlights the need to await the result of the parser, and doesn't cause runtime issues if the underlyign type is a plain object (the await becomes a no-op). --- packages/e2e/src/app/app/cache/page.tsx | 11 ++--- packages/e2e/src/app/app/push/page.tsx | 9 ++-- packages/e2e/src/app/app/push/searchParams.ts | 5 ++- .../src/app/app/rewrites/destination/page.tsx | 6 +-- packages/e2e/src/app/app/transitions/page.tsx | 5 ++- packages/nuqs/src/cache.ts | 45 ++++++++++++++----- packages/nuqs/src/tests/cache.test-d.ts | 4 ++ 7 files changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/e2e/src/app/app/cache/page.tsx b/packages/e2e/src/app/app/cache/page.tsx index c714d1e2..f4d50920 100644 --- a/packages/e2e/src/app/app/cache/page.tsx +++ b/packages/e2e/src/app/app/cache/page.tsx @@ -1,3 +1,4 @@ +import type { SearchParams } from 'nuqs/server' import { Suspense } from 'react' import { All } from './all' import { Get } from './get' @@ -5,11 +6,11 @@ import { cache } from './searchParams' import { Set } from './set' type Props = { - searchParams: Record + searchParams: Promise } -export default function Page({ searchParams }: Props) { - const { str, bool, num, def, nope } = cache.parse(searchParams) +export default async function Page({ searchParams }: Props) { + const { str, bool, num, def, nope } = await cache.parse(searchParams) return ( <>

Root page

@@ -30,9 +31,9 @@ export default function Page({ searchParams }: Props) { ) } -export function generateMetadata({ searchParams }: Props) { +export async function generateMetadata({ searchParams }: Props) { // parse here too to ensure we can idempotently parse the same search params as the page in the same request - const { str } = cache.parse(searchParams) + const { str } = await cache.parse(searchParams) return { title: `metadata-title-str:${str}` } diff --git a/packages/e2e/src/app/app/push/page.tsx b/packages/e2e/src/app/app/push/page.tsx index 2a0f0120..54e1e567 100644 --- a/packages/e2e/src/app/app/push/page.tsx +++ b/packages/e2e/src/app/app/push/page.tsx @@ -1,12 +1,13 @@ +import type { SearchParams } from 'nuqs/server' import { Client } from './client' -import { parser } from './searchParams' +import { searchParamsCache } from './searchParams' -export default function Page({ +export default async function Page({ searchParams }: { - searchParams: Record + searchParams: Promise }) { - const server = parser.parseServerSide(searchParams.server) + const { server } = await searchParamsCache.parse(searchParams) return ( <>

diff --git a/packages/e2e/src/app/app/push/searchParams.ts b/packages/e2e/src/app/app/push/searchParams.ts index 8aaf9946..26e0548b 100644 --- a/packages/e2e/src/app/app/push/searchParams.ts +++ b/packages/e2e/src/app/app/push/searchParams.ts @@ -1,5 +1,8 @@ -import { parseAsInteger } from 'nuqs' +import { createSearchParamsCache, parseAsInteger } from 'nuqs/server' export const parser = parseAsInteger.withDefault(0).withOptions({ history: 'push' }) +export const searchParamsCache = createSearchParamsCache({ + server: parser +}) diff --git a/packages/e2e/src/app/app/rewrites/destination/page.tsx b/packages/e2e/src/app/app/rewrites/destination/page.tsx index 5237eaf6..f1d625d1 100644 --- a/packages/e2e/src/app/app/rewrites/destination/page.tsx +++ b/packages/e2e/src/app/app/rewrites/destination/page.tsx @@ -3,12 +3,12 @@ import { Suspense } from 'react' import { RewriteDestinationClient } from './client' import { cache } from './searchParams' -export default function RewriteDestinationPage({ +export default async function RewriteDestinationPage({ searchParams }: { - searchParams: SearchParams + searchParams: Promise }) { - const { injected, through } = cache.parse(searchParams) + const { injected, through } = await cache.parse(searchParams) return ( <>

diff --git a/packages/e2e/src/app/app/transitions/page.tsx b/packages/e2e/src/app/app/transitions/page.tsx index 3a4112f6..bd291f26 100644 --- a/packages/e2e/src/app/app/transitions/page.tsx +++ b/packages/e2e/src/app/app/transitions/page.tsx @@ -1,9 +1,10 @@ import { setTimeout } from 'node:timers/promises' +import type { SearchParams } from 'nuqs/server' import { Suspense } from 'react' import { Client } from './client' type PageProps = { - searchParams: Record + searchParams: Promise } export default async function Page({ searchParams }: PageProps) { @@ -11,7 +12,7 @@ export default async function Page({ searchParams }: PageProps) { return ( <>

Transitions

-
{JSON.stringify(searchParams)}
+
{JSON.stringify(await searchParams)}
diff --git a/packages/nuqs/src/cache.ts b/packages/nuqs/src/cache.ts index d5e1beb3..a54eab64 100644 --- a/packages/nuqs/src/cache.ts +++ b/packages/nuqs/src/cache.ts @@ -1,22 +1,17 @@ import { cache } from 'react' import { error } from './errors' -import type { ParserBuilder } from './parsers' +import type { ParserBuilder, inferParserType } from './parsers' export type SearchParams = Record const $input: unique symbol = Symbol('Input') -type ExtractParserType = - Parser extends ParserBuilder - ? ReturnType - : never - export function createSearchParamsCache< Parsers extends Record> >(parsers: Parsers) { type Keys = keyof Parsers type ParsedSearchParams = { - [K in Keys]: ExtractParserType + readonly [K in Keys]: inferParserType } type Cache = { @@ -32,7 +27,8 @@ export function createSearchParamsCache< const getCache = cache<() => Cache>(() => ({ searchParams: {} })) - function parse(searchParams: SearchParams) { + + function parseSync(searchParams: SearchParams): ParsedSearchParams { const c = getCache() if (Object.isFrozen(c.searchParams)) { // Parse has already been called... @@ -51,14 +47,43 @@ export function createSearchParamsCache< c.searchParams[key] = parser.parseServerSide(searchParams[key]) } c[$input] = searchParams - return Object.freeze(c.searchParams) as Readonly + return Object.freeze(c.searchParams) as ParsedSearchParams + } + + /** + * Parse the incoming `searchParams` page prop using the parsers provided, + * and make it available to the RSC tree. + * + * @returns The parsed search params for direct use in the page component. + * + * Note: Next.js 15 introduced a breaking change in making their + * `searchParam` prop a Promise. You will need to await this function + * to use the Promise version in Next.js 15. + */ + function parse(searchParams: SearchParams): ParsedSearchParams + + /** + * Parse the incoming `searchParams` page prop using the parsers provided, + * and make it available to the RSC tree. + * + * @returns The parsed search params for direct use in the page component. + * + * Note: this async version requires Next.js 15 or later. + */ + function parse(searchParams: Promise): Promise + + function parse(searchParams: SearchParams | Promise) { + if (searchParams instanceof Promise) { + return searchParams.then(parseSync) + } + return parseSync(searchParams) } function all() { const { searchParams } = getCache() if (Object.keys(searchParams).length === 0) { throw new Error(error(500)) } - return searchParams as Readonly + return searchParams as ParsedSearchParams } function get(key: Key): ParsedSearchParams[Key] { const { searchParams } = getCache() diff --git a/packages/nuqs/src/tests/cache.test-d.ts b/packages/nuqs/src/tests/cache.test-d.ts index 2812f8bf..ca99adb8 100644 --- a/packages/nuqs/src/tests/cache.test-d.ts +++ b/packages/nuqs/src/tests/cache.test-d.ts @@ -29,4 +29,8 @@ import { type All = Readonly<{ foo: string | null; bar: number | null; egg: boolean }> expectType(cache.parse({})) expectType(cache.all()) + + // It supports async search params (Next.js 15+) + expectType>(cache.parse(Promise.resolve({}))) + expectType(cache.all()) }