diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index d317741b..ebc74aa3 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -185,7 +185,7 @@ { "name": "Server", "path": "dist/server.js", - "limit": "2 kB", + "limit": "2.5 kB", "ignore": [ "react", "next" diff --git a/packages/nuqs/src/cache.ts b/packages/nuqs/src/cache.ts index df3df919..9b28701b 100644 --- a/packages/nuqs/src/cache.ts +++ b/packages/nuqs/src/cache.ts @@ -2,15 +2,13 @@ import { cache } from 'react' import type { SearchParams } from './defs' import { error } from './errors' -import type { ParserBuilder, inferParserType } from './parsers' +import type { inferParserType, ParserMap, UrlKeys } from './parsers' const $input: unique symbol = Symbol('Input') -export function createSearchParamsCache< - Parsers extends Record> ->( +export function createSearchParamsCache( parsers: Parsers, - { urlKeys = {} }: { urlKeys?: Partial> } = {} + { urlKeys = {} }: { urlKeys?: UrlKeys } = {} ) { type Keys = keyof Parsers type ParsedSearchParams = { diff --git a/packages/nuqs/src/index.server.ts b/packages/nuqs/src/index.server.ts index 3bfc4845..f94d7bbc 100644 --- a/packages/nuqs/src/index.server.ts +++ b/packages/nuqs/src/index.server.ts @@ -1,4 +1,10 @@ export { createSearchParamsCache } from './cache' export type { HistoryOptions, Nullable, Options, SearchParams } from './defs' +export { + createLoader, + type LoaderFunction, + type LoaderInput, + type LoaderOptions +} from './loader' export * from './parsers' export { createSerializer } from './serializer' diff --git a/packages/nuqs/src/index.ts b/packages/nuqs/src/index.ts index b8bc6f51..cf144973 100644 --- a/packages/nuqs/src/index.ts +++ b/packages/nuqs/src/index.ts @@ -1,4 +1,10 @@ export type { HistoryOptions, Nullable, Options, SearchParams } from './defs' +export { + createLoader, + type LoaderFunction, + type LoaderInput, + type LoaderOptions +} from './loader' export * from './parsers' export { createSerializer } from './serializer' export * from './useQueryState' diff --git a/packages/nuqs/src/loader.test.ts b/packages/nuqs/src/loader.test.ts new file mode 100644 index 00000000..b7e24392 --- /dev/null +++ b/packages/nuqs/src/loader.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { createLoader } from './loader' +import { parseAsInteger } from './parsers' + +describe('loader', () => { + describe('sync', () => { + it('parses a URL object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load(new URL('http://example.com/?a=1&b=2')) + expect(result).toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a Request object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load(new Request('http://example.com/?a=1&b=2')) + expect(result).toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a URLSearchParams object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load(new URLSearchParams('a=1&b=2')) + expect(result).toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a Record object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load({ + a: '1', + b: '2' + }) + expect(result).toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a URL string', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load('https://example.com/?a=1&b=2') + expect(result).toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a search params string', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load('?a=1&b=2') + expect(result).toEqual({ + a: 1, + b: 2 + }) + }) + it('supports urlKeys', () => { + const load = createLoader( + { + urlKey: parseAsInteger + }, + { + urlKeys: { + urlKey: 'a' + } + } + ) + const result = load('?a=1') + expect(result).toEqual({ + urlKey: 1 + }) + }) + }) + + describe('async', () => { + it('parses a URL object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load( + Promise.resolve(new URL('http://example.com/?a=1&b=2')) + ) + return expect(result).resolves.toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a Request object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load( + Promise.resolve(new Request('http://example.com/?a=1&b=2')) + ) + return expect(result).resolves.toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a URLSearchParams object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load(Promise.resolve(new URLSearchParams('a=1&b=2'))) + return expect(result).resolves.toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a Record object', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load( + Promise.resolve({ + a: '1', + b: '2' + }) + ) + return expect(result).resolves.toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a URL string', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load(Promise.resolve('https://example.com/?a=1&b=2')) + return expect(result).resolves.toEqual({ + a: 1, + b: 2 + }) + }) + it('parses a search params string', () => { + const load = createLoader({ + a: parseAsInteger, + b: parseAsInteger + }) + const result = load(Promise.resolve('?a=1&b=2')) + return expect(result).resolves.toEqual({ + a: 1, + b: 2 + }) + }) + it('supports urlKeys', () => { + const load = createLoader( + { + urlKey: parseAsInteger + }, + { + urlKeys: { + urlKey: 'a' + } + } + ) + const result = load(Promise.resolve('?a=1')) + return expect(result).resolves.toEqual({ + urlKey: 1 + }) + }) + }) +}) diff --git a/packages/nuqs/src/loader.ts b/packages/nuqs/src/loader.ts new file mode 100644 index 00000000..d70694ce --- /dev/null +++ b/packages/nuqs/src/loader.ts @@ -0,0 +1,123 @@ +import type { inferParserType, ParserMap, UrlKeys } from './parsers' + +export type LoaderInput = + | URL + | Request + | URLSearchParams + | Record + | string + +export type LoaderOptions = { + urlKeys?: UrlKeys +} + +export type LoaderFunction = ReturnType< + typeof createLoader +> + +export function createLoader( + parsers: Parsers, + { urlKeys = {} }: LoaderOptions = {} +) { + type ParsedSearchParams = inferParserType + + /** + * Load & parse search params from (almost) any input. + * + * While loaders are typically used in the context of a React Router / Remix + * loader function, it can also be used in Next.js API routes or + * getServerSideProps functions, or even with the app router `searchParams` + * page prop (sync or async), if you don't need the cache behaviours. + */ + function loadSearchParams( + input: LoaderInput, + options?: LoaderOptions + ): ParsedSearchParams + + /** + * Load & parse search params from (almost) any input. + * + * While loaders are typically used in the context of a React Router / Remix + * loader function, it can also be used in Next.js API routes or + * getServerSideProps functions, or even with the app router `searchParams` + * page prop (sync or async), if you don't need the cache behaviours. + * + * Note: this async overload makes it easier to use against the `searchParams` + * page prop in Next.js 15 app router: + * + * ```tsx + * export default async function Page({ searchParams }) { + * const parsedSearchParamsPromise = loadSearchParams(searchParams) + * return ( + * // Pre-render & stream the shell immediately + * + * + * // Stream the Promise down + * + * + * + * ) + * } + * ``` + */ + function loadSearchParams( + input: Promise, + options?: LoaderOptions + ): Promise + + function loadSearchParams(input: LoaderInput | Promise) { + if (input instanceof Promise) { + return input.then(i => loadSearchParams(i)) + } + const searchParams = extractSearchParams(input) + const result = {} as any + for (const [key, parser] of Object.entries(parsers)) { + const urlKey = urlKeys[key] ?? key + const value = searchParams.get(urlKey) + result[key] = parser.parseServerSide(value ?? undefined) + } + return result + } + return loadSearchParams +} + +function extractSearchParams(input: LoaderInput): URLSearchParams { + try { + if (input instanceof Request) { + if (input.url) { + return new URL(input.url).searchParams + } else { + return new URLSearchParams() + } + } + if (input instanceof URL) { + return input.searchParams + } + if (input instanceof URLSearchParams) { + return input + } + if (typeof input === 'object') { + const entries = Object.entries(input) + const searchParams = new URLSearchParams() + for (const [key, value] of entries) { + if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(key, v) + } + } else if (value !== undefined) { + searchParams.set(key, value) + } + } + return searchParams + } + if (typeof input === 'string') { + if ('canParse' in URL && URL.canParse(input)) { + return new URL(input).searchParams + } + return new URLSearchParams(input) + } + } catch (e) { + return new URLSearchParams() + } + return new URLSearchParams() +} diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 9a94681b..9dda36d4 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -464,3 +464,12 @@ export type inferParserType = : Input extends Record> ? inferParserRecordType : never + +export type ParserWithOptionalDefault = ParserBuilder & { + defaultValue?: T +} +export type ParserMap = Record> + +export type UrlKeys = Partial< + Record +> diff --git a/packages/nuqs/src/serializer.ts b/packages/nuqs/src/serializer.ts index 272c05b1..10f3a4c1 100644 --- a/packages/nuqs/src/serializer.ts +++ b/packages/nuqs/src/serializer.ts @@ -1,19 +1,17 @@ +import type { UrlKeys } from '../server' import type { Nullable, Options } from './defs' -import type { inferParserType, ParserBuilder } from './parsers' +import type { inferParserType, ParserMap } from './parsers' import { renderQueryString } from './url-encoding' type Base = string | URLSearchParams | URL -type ParserWithOptionalDefault = ParserBuilder & { defaultValue?: T } -export function createSerializer< - Parsers extends Record> ->( +export function createSerializer( parsers: Parsers, { clearOnDefault = true, urlKeys = {} }: Pick & { - urlKeys?: Partial> + urlKeys?: UrlKeys } = {} ) { type Values = Partial>>