Skip to content

Commit

Permalink
fix: Handle Promises for the searchParams page prop (#652)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
franky47 authored Sep 26, 2024
1 parent 12a5189 commit 63d01eb
Show file tree
Hide file tree
Showing 7 changed files with 60 additions and 25 deletions.
11 changes: 6 additions & 5 deletions packages/e2e/src/app/app/cache/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { SearchParams } from 'nuqs/server'
import { Suspense } from 'react'
import { All } from './all'
import { Get } from './get'
import { cache } from './searchParams'
import { Set } from './set'

type Props = {
searchParams: Record<string, string | string[] | undefined>
searchParams: Promise<SearchParams>
}

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 (
<>
<h1>Root page</h1>
Expand All @@ -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}`
}
Expand Down
9 changes: 5 additions & 4 deletions packages/e2e/src/app/app/push/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | string[] | undefined>
searchParams: Promise<SearchParams>
}) {
const server = parser.parseServerSide(searchParams.server)
const { server } = await searchParamsCache.parse(searchParams)
return (
<>
<p>
Expand Down
5 changes: 4 additions & 1 deletion packages/e2e/src/app/app/push/searchParams.ts
Original file line number Diff line number Diff line change
@@ -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
})
6 changes: 3 additions & 3 deletions packages/e2e/src/app/app/rewrites/destination/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchParams>
}) {
const { injected, through } = cache.parse(searchParams)
const { injected, through } = await cache.parse(searchParams)
return (
<>
<p>
Expand Down
5 changes: 3 additions & 2 deletions packages/e2e/src/app/app/transitions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { setTimeout } from 'node:timers/promises'
import type { SearchParams } from 'nuqs/server'
import { Suspense } from 'react'
import { Client } from './client'

type PageProps = {
searchParams: Record<string, string | string[] | undefined>
searchParams: Promise<SearchParams>
}

export default async function Page({ searchParams }: PageProps) {
await setTimeout(1000)
return (
<>
<h1>Transitions</h1>
<pre id="server-rendered">{JSON.stringify(searchParams)}</pre>
<pre id="server-rendered">{JSON.stringify(await searchParams)}</pre>
<Suspense>
<Client />
</Suspense>
Expand Down
45 changes: 35 additions & 10 deletions packages/nuqs/src/cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | string[] | undefined>

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

type ExtractParserType<Parser> =
Parser extends ParserBuilder<any>
? ReturnType<Parser['parseServerSide']>
: never

export function createSearchParamsCache<
Parsers extends Record<string, ParserBuilder<any>>
>(parsers: Parsers) {
type Keys = keyof Parsers
type ParsedSearchParams = {
[K in Keys]: ExtractParserType<Parsers[K]>
readonly [K in Keys]: inferParserType<Parsers[K]>
}

type Cache = {
Expand All @@ -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...
Expand All @@ -51,14 +47,43 @@ export function createSearchParamsCache<
c.searchParams[key] = parser.parseServerSide(searchParams[key])
}
c[$input] = searchParams
return Object.freeze(c.searchParams) as Readonly<ParsedSearchParams>
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<any>): Promise<ParsedSearchParams>

function parse(searchParams: SearchParams | Promise<any>) {
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<ParsedSearchParams>
return searchParams as ParsedSearchParams
}
function get<Key extends Keys>(key: Key): ParsedSearchParams[Key] {
const { searchParams } = getCache()
Expand Down
4 changes: 4 additions & 0 deletions packages/nuqs/src/tests/cache.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ import {
type All = Readonly<{ foo: string | null; bar: number | null; egg: boolean }>
expectType<All>(cache.parse({}))
expectType<All>(cache.all())

// It supports async search params (Next.js 15+)
expectType<Promise<All>>(cache.parse(Promise.resolve({})))
expectType<All>(cache.all())
}

0 comments on commit 63d01eb

Please sign in to comment.