Skip to content

Commit 0296ae5

Browse files
committed
fix: Handle Promises for the searchParams page prop
Next.js 15.0.0-canary-171 introduced a breaking change in vercel/next.js#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<SearchParams> (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).
1 parent 12a5189 commit 0296ae5

File tree

7 files changed

+60
-25
lines changed

7 files changed

+60
-25
lines changed

Diff for: packages/e2e/src/app/app/cache/page.tsx

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
import type { SearchParams } from 'nuqs/server'
12
import { Suspense } from 'react'
23
import { All } from './all'
34
import { Get } from './get'
45
import { cache } from './searchParams'
56
import { Set } from './set'
67

78
type Props = {
8-
searchParams: Record<string, string | string[] | undefined>
9+
searchParams: Promise<SearchParams>
910
}
1011

11-
export default function Page({ searchParams }: Props) {
12-
const { str, bool, num, def, nope } = cache.parse(searchParams)
12+
export default async function Page({ searchParams }: Props) {
13+
const { str, bool, num, def, nope } = await cache.parse(searchParams)
1314
return (
1415
<>
1516
<h1>Root page</h1>
@@ -30,9 +31,9 @@ export default function Page({ searchParams }: Props) {
3031
)
3132
}
3233

33-
export function generateMetadata({ searchParams }: Props) {
34+
export async function generateMetadata({ searchParams }: Props) {
3435
// parse here too to ensure we can idempotently parse the same search params as the page in the same request
35-
const { str } = cache.parse(searchParams)
36+
const { str } = await cache.parse(searchParams)
3637
return {
3738
title: `metadata-title-str:${str}`
3839
}

Diff for: packages/e2e/src/app/app/push/page.tsx

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
import type { SearchParams } from 'nuqs/server'
12
import { Client } from './client'
2-
import { parser } from './searchParams'
3+
import { searchParamsCache } from './searchParams'
34

4-
export default function Page({
5+
export default async function Page({
56
searchParams
67
}: {
7-
searchParams: Record<string, string | string[] | undefined>
8+
searchParams: Promise<SearchParams>
89
}) {
9-
const server = parser.parseServerSide(searchParams.server)
10+
const { server } = await searchParamsCache.parse(searchParams)
1011
return (
1112
<>
1213
<p>

Diff for: packages/e2e/src/app/app/push/searchParams.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { parseAsInteger } from 'nuqs'
1+
import { createSearchParamsCache, parseAsInteger } from 'nuqs/server'
22

33
export const parser = parseAsInteger.withDefault(0).withOptions({
44
history: 'push'
55
})
6+
export const searchParamsCache = createSearchParamsCache({
7+
server: parser
8+
})

Diff for: packages/e2e/src/app/app/rewrites/destination/page.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { Suspense } from 'react'
33
import { RewriteDestinationClient } from './client'
44
import { cache } from './searchParams'
55

6-
export default function RewriteDestinationPage({
6+
export default async function RewriteDestinationPage({
77
searchParams
88
}: {
9-
searchParams: SearchParams
9+
searchParams: Promise<SearchParams>
1010
}) {
11-
const { injected, through } = cache.parse(searchParams)
11+
const { injected, through } = await cache.parse(searchParams)
1212
return (
1313
<>
1414
<p>

Diff for: packages/e2e/src/app/app/transitions/page.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { setTimeout } from 'node:timers/promises'
2+
import type { SearchParams } from 'nuqs/server'
23
import { Suspense } from 'react'
34
import { Client } from './client'
45

56
type PageProps = {
6-
searchParams: Record<string, string | string[] | undefined>
7+
searchParams: Promise<SearchParams>
78
}
89

910
export default async function Page({ searchParams }: PageProps) {
1011
await setTimeout(1000)
1112
return (
1213
<>
1314
<h1>Transitions</h1>
14-
<pre id="server-rendered">{JSON.stringify(searchParams)}</pre>
15+
<pre id="server-rendered">{JSON.stringify(await searchParams)}</pre>
1516
<Suspense>
1617
<Client />
1718
</Suspense>

Diff for: packages/nuqs/src/cache.ts

+35-10
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
import { cache } from 'react'
22
import { error } from './errors'
3-
import type { ParserBuilder } from './parsers'
3+
import type { ParserBuilder, inferParserType } from './parsers'
44

55
export type SearchParams = Record<string, string | string[] | undefined>
66

77
const $input: unique symbol = Symbol('Input')
88

9-
type ExtractParserType<Parser> =
10-
Parser extends ParserBuilder<any>
11-
? ReturnType<Parser['parseServerSide']>
12-
: never
13-
149
export function createSearchParamsCache<
1510
Parsers extends Record<string, ParserBuilder<any>>
1611
>(parsers: Parsers) {
1712
type Keys = keyof Parsers
1813
type ParsedSearchParams = {
19-
[K in Keys]: ExtractParserType<Parsers[K]>
14+
readonly [K in Keys]: inferParserType<Parsers[K]>
2015
}
2116

2217
type Cache = {
@@ -32,7 +27,8 @@ export function createSearchParamsCache<
3227
const getCache = cache<() => Cache>(() => ({
3328
searchParams: {}
3429
}))
35-
function parse(searchParams: SearchParams) {
30+
31+
function parseSync(searchParams: SearchParams): ParsedSearchParams {
3632
const c = getCache()
3733
if (Object.isFrozen(c.searchParams)) {
3834
// Parse has already been called...
@@ -51,14 +47,43 @@ export function createSearchParamsCache<
5147
c.searchParams[key] = parser.parseServerSide(searchParams[key])
5248
}
5349
c[$input] = searchParams
54-
return Object.freeze(c.searchParams) as Readonly<ParsedSearchParams>
50+
return Object.freeze(c.searchParams) as ParsedSearchParams
51+
}
52+
53+
/**
54+
* Parse the incoming `searchParams` page prop using the parsers provided,
55+
* and make it available to the RSC tree.
56+
*
57+
* @returns The parsed search params for direct use in the page component.
58+
*
59+
* Note: Next.js 15 introduced a breaking change in making their
60+
* `searchParam` prop a Promise. You will need to await this function
61+
* to use the Promise version in Next.js 15.
62+
*/
63+
function parse(searchParams: SearchParams): ParsedSearchParams
64+
65+
/**
66+
* Parse the incoming `searchParams` page prop using the parsers provided,
67+
* and make it available to the RSC tree.
68+
*
69+
* @returns The parsed search params for direct use in the page component.
70+
*
71+
* Note: this async version requires Next.js 15 or later.
72+
*/
73+
function parse(searchParams: Promise<any>): Promise<ParsedSearchParams>
74+
75+
function parse(searchParams: SearchParams | Promise<any>) {
76+
if (searchParams instanceof Promise) {
77+
return searchParams.then(parseSync)
78+
}
79+
return parseSync(searchParams)
5580
}
5681
function all() {
5782
const { searchParams } = getCache()
5883
if (Object.keys(searchParams).length === 0) {
5984
throw new Error(error(500))
6085
}
61-
return searchParams as Readonly<ParsedSearchParams>
86+
return searchParams as ParsedSearchParams
6287
}
6388
function get<Key extends Keys>(key: Key): ParsedSearchParams[Key] {
6489
const { searchParams } = getCache()

Diff for: packages/nuqs/src/tests/cache.test-d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,8 @@ import {
2929
type All = Readonly<{ foo: string | null; bar: number | null; egg: boolean }>
3030
expectType<All>(cache.parse({}))
3131
expectType<All>(cache.all())
32+
33+
// It supports async search params (Next.js 15+)
34+
expectType<Promise<All>>(cache.parse(Promise.resolve({})))
35+
expectType<All>(cache.all())
3236
}

0 commit comments

Comments
 (0)