Skip to content

Commit

Permalink
feat: Add loader feature for React Routers and Remix
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 committed Dec 19, 2024
1 parent 03aec1c commit b71e858
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 12 deletions.
2 changes: 1 addition & 1 deletion packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
{
"name": "Server",
"path": "dist/server.js",
"limit": "2 kB",
"limit": "2.5 kB",
"ignore": [
"react",
"next"
Expand Down
8 changes: 3 additions & 5 deletions packages/nuqs/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ParserBuilder<any>>
>(
export function createSearchParamsCache<Parsers extends ParserMap>(
parsers: Parsers,
{ urlKeys = {} }: { urlKeys?: Partial<Record<keyof Parsers, string>> } = {}
{ urlKeys = {} }: { urlKeys?: UrlKeys<Parsers> } = {}
) {
type Keys = keyof Parsers
type ParsedSearchParams = {
Expand Down
6 changes: 6 additions & 0 deletions packages/nuqs/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -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'
6 changes: 6 additions & 0 deletions packages/nuqs/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
187 changes: 187 additions & 0 deletions packages/nuqs/src/loader.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | string[] | undefined> 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<string, string | string[] | undefined> 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
})
})
})
})
123 changes: 123 additions & 0 deletions packages/nuqs/src/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { inferParserType, ParserMap, UrlKeys } from './parsers'

export type LoaderInput =
| URL
| Request
| URLSearchParams
| Record<string, string | string[] | undefined>
| string

export type LoaderOptions<Parsers extends ParserMap> = {
urlKeys?: UrlKeys<Parsers>
}

export type LoaderFunction<Parsers extends ParserMap> = ReturnType<
typeof createLoader<Parsers>
>

export function createLoader<Parsers extends ParserMap>(
parsers: Parsers,
{ urlKeys = {} }: LoaderOptions<Parsers> = {}
) {
type ParsedSearchParams = inferParserType<Parsers>

/**
* 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<Parsers>
): 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
* <StaticShell>
* <Suspense>
* // Stream the Promise down
* <DynamicComponent searchParams={parsedSearchParamsPromise} />
* </Suspense>
* </StaticShell>
* )
* }
* ```
*/
function loadSearchParams(
input: Promise<LoaderInput>,
options?: LoaderOptions<Parsers>
): Promise<ParsedSearchParams>

function loadSearchParams(input: LoaderInput | Promise<LoaderInput>) {
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()
}
9 changes: 9 additions & 0 deletions packages/nuqs/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,12 @@ export type inferParserType<Input> =
: Input extends Record<string, ParserBuilder<any>>
? inferParserRecordType<Input>
: never

export type ParserWithOptionalDefault<T> = ParserBuilder<T> & {
defaultValue?: T
}
export type ParserMap = Record<string, ParserWithOptionalDefault<any>>

export type UrlKeys<Parsers extends ParserMap> = Partial<
Record<keyof Parsers, string>
>
10 changes: 4 additions & 6 deletions packages/nuqs/src/serializer.ts
Original file line number Diff line number Diff line change
@@ -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<T> = ParserBuilder<T> & { defaultValue?: T }

export function createSerializer<
Parsers extends Record<string, ParserWithOptionalDefault<any>>
>(
export function createSerializer<Parsers extends ParserMap>(
parsers: Parsers,
{
clearOnDefault = true,
urlKeys = {}
}: Pick<Options, 'clearOnDefault'> & {
urlKeys?: Partial<Record<keyof Parsers, string>>
urlKeys?: UrlKeys<Parsers>
} = {}
) {
type Values = Partial<Nullable<inferParserType<Parsers>>>
Expand Down

0 comments on commit b71e858

Please sign in to comment.