Skip to content

Commit

Permalink
chore: New API for seach param cache
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 committed Nov 16, 2023
1 parent 5738335 commit 33f32e8
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 20 deletions.
107 changes: 107 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,113 @@ const search = await setCoordinates({
})
```

## Accessing searchParams in Server Components

If you wish to access the searchParams in a deeply nested Server Component
(ie: not in the Page component), you can use `createSearchParamsCache`
to do so in a type-safe manner:

```tsx
// page.tsx
import {
createSearchParamsCache,
parseAsInteger,
parseAsString
} from 'next-usequerystate/parsers'

export const searchParamsCache = createSearchParamsCache({
// List your search param keys and associated parsers here:
q: parseAsString.withDefault(''),
maxResults: parseAsInteger.withDefault(10)
})

export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
// ⚠️ Don't forget to call `parse` here.
// You can access type-safe values from the returned object:
const { q: query } = searchParamsCache.parse(searchParams)
return (
<div>
<h1>Search Results for {query}</h1>
<Results />
</div>
)
}

function Results() {
// Access type-safe search params in children server components:
const maxResults = searchParamsCache.get('maxResults')
return <span>Showing up to {maxResults} results</span>
}
```

The cache will only be valid for the current page render
(see React's [`cache`](https://react.dev/reference/react/cache) function).

Note: the cache only works for **server components**, but you may share your
parser declaration with `useQueryStates` for type-safety in client components:

```tsx
// searchParams.ts
import {
parseAsFloat,
createSearchParamsCache
} from 'next-usequerystate/parsers'

export const coordinatesParsers = {
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72)
}
export const coordinatesCache = createSearchParamsCache(coordinatesParsers)

// page.tsx
import { coordinatesCache } from './searchParams'
import { Server } from './server'
import { Client } from './client'

export default function Page({ searchParams }) {
coordinatesCache.parse(searchParams)
return (
<>
<Server />
<Suspense>
<Client />
</Suspense>
</>
)
}

// server.tsx
import { coordinatesCache } from './searchParams'

export function Server() {
const { lat, lng } = coordinatesCache.all()
// or access keys individually:
const lat = coordinatesCache.get('lat')
const lng = coordinatesCache.get('lng')
return (
<span>
Latitude: {lat} - Longitude: {lng}
</span>
)
}

// client.tsx
// prettier-ignore
'use client'

import { useQueryStates } from 'next-usequerystate'
import { coordinatesParsers } from './searchParams'

export function Client() {
const [{ lat, lng }, setCoordinates] = useQueryStates(coordinatesParsers)
// ...
}
```

## Testing

Currently, the best way to test the behaviour of your components using
Expand Down
6 changes: 3 additions & 3 deletions errors/NUQS-500.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Empty Search Params Cache

This error shows up on the server when trying to access a searchParam from
a cache created with `createSearchParamCache`, but when the cache was not
a cache created with `createSearchParamsCache`, but when the cache was not
properly populated at the top of the page.

## Solution
Expand All @@ -11,12 +11,12 @@ Run the `parseSearchParam` function on the page's `searchParams`:
```tsx
// page.tsx
import {
createSearchParamCache,
createSearchParamsCache,
parseAsInteger,
parseAsString
} from 'next-usequerystate/parsers'

const { parseSearchParams, getSearchParam } = createSearchParamCache({
const { parseSearchParams, getSearchParam } = createSearchParamsCache({
q: parseAsString,
maxResults: parseAsInteger.withDefault(10)
})
Expand Down
13 changes: 13 additions & 0 deletions errors/NUQS-501.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Search params cache already populated

This error occurs when a [search params cache](https://github.com/47ng/next-usequerystate#accessing-searchparams-in-server-components)
is being fed searchParams more than once.

Internally, the cache object will be frozen for the duration of the page render
after having been populated. This is to prevent search params from being modified
while the page is being rendered.

## Solutions

Look into the stack trace where the error occurred and remove the second call to
`parse` that threw the error.
26 changes: 17 additions & 9 deletions packages/next-usequerystate/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,43 @@ type ExtractParserType<Parser> = Parser extends ParserBuilder<any>
? ReturnType<Parser['parseServerSide']>
: never

export function createSearchParamCache<
export function createSearchParamsCache<
Parsers extends Record<string, ParserBuilder<any>>
>(parsers: Parsers) {
type Keys = keyof Parsers
type ParsedSearchParams = {
[K in Keys]?: ExtractParserType<Parsers[K]>
[K in Keys]: ExtractParserType<Parsers[K]>
}
const getCache = cache<() => ParsedSearchParams>(() => ({}))
function parseSearchParams(searchParams: SearchParams) {
const getCache = cache<() => Partial<ParsedSearchParams>>(() => ({}))
function parse(searchParams: SearchParams) {
const c = getCache()
if (Object.isFrozen(c)) {
throw new Error(error(501))
}
for (const key in parsers) {
const parser = parsers[key]!
c[key] = parser.parseServerSide(searchParams[key])
}
return Object.freeze(c)
}
function getSearchParam<Key extends keyof Parsers>(
key: Key
): Required<ParsedSearchParams>[Key] {
function all() {
const c = getCache()
if (Object.keys(c) !== Object.keys(parsers)) {
throw new Error(error(500))
}
return c as Readonly<ParsedSearchParams>
}
function get<Key extends keyof Parsers>(key: Key): ParsedSearchParams[Key] {
const c = getCache()
const entry = c[key]
if (typeof entry === 'undefined') {
throw new Error(
error(500) +
`
in getSearchParam(${String(key)})`
in get(${String(key)})`
)
}
return entry
}
return { parseSearchParams, getSearchParam }
return { parse, get, all }
}
3 changes: 2 additions & 1 deletion packages/next-usequerystate/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export const errors = {
409: 'Multiple versions of the library are loaded. This may lead to unexpected behavior. Currently using %s, but %s was about to load on top.',
429: 'URL update rate-limited by the browser. Consider increasing `throttleMs` for keys %s. %O',
500: 'Empty search params cache. Call `parseSearchParams` in the page component to set it up.'
500: 'Empty search params cache. Call `parseSearchParams` in the page component to set it up.',
501: 'Search params cache already populated. Have you called `parse` twice?'
} as const

export function error(code: keyof typeof errors) {
Expand Down
17 changes: 10 additions & 7 deletions packages/next-usequerystate/src/tests/cache.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import { expectError, expectNotAssignable, expectType } from 'tsd'
import {
createSearchParamCache,
createSearchParamsCache,
parseAsBoolean,
parseAsInteger,
parseAsString
} from '../../dist/parsers'

{
const { getSearchParam } = createSearchParamCache({
const cache = createSearchParamsCache({
foo: parseAsString,
bar: parseAsInteger,
egg: parseAsBoolean.withDefault(false)
})
// Values are type-safe
expectType<string | null>(getSearchParam('foo'))
expectType<number | null>(getSearchParam('bar'))
expectType<string | null>(cache.get('foo'))
expectType<number | null>(cache.get('bar'))
// Default values are taken into account
expectType<boolean>(getSearchParam('egg'))
expectNotAssignable<null>(getSearchParam('egg'))
expectType<boolean>(cache.get('egg'))
expectNotAssignable<null>(cache.get('egg'))
// Keys are type safe
expectError(() => {
getSearchParam('spam')
cache.get('spam')
})
expectType<
Readonly<{ foo: string | null; bar: number | null; egg: boolean }>
>(cache.all())
}

0 comments on commit 33f32e8

Please sign in to comment.