diff --git a/README.md b/README.md index 7a9f094c..da1cee22 100644 --- a/README.md +++ b/README.md @@ -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 +}) { + // ⚠️ Don't forget to call `parse` here. + // You can access type-safe values from the returned object: + const { q: query } = searchParamsCache.parse(searchParams) + return ( +
+

Search Results for {query}

+ +
+ ) +} + +function Results() { + // Access type-safe search params in children server components: + const maxResults = searchParamsCache.get('maxResults') + return Showing up to {maxResults} results +} +``` + +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.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 ( + + Latitude: {lat} - Longitude: {lng} + + ) +} + +// 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 diff --git a/errors/NUQS-500.md b/errors/NUQS-500.md index e26fec84..2ec1e3dd 100644 --- a/errors/NUQS-500.md +++ b/errors/NUQS-500.md @@ -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 @@ -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) }) diff --git a/errors/NUQS-501.md b/errors/NUQS-501.md new file mode 100644 index 00000000..8ebfc48f --- /dev/null +++ b/errors/NUQS-501.md @@ -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. diff --git a/packages/next-usequerystate/src/cache.ts b/packages/next-usequerystate/src/cache.ts index 5305ceb7..44953449 100644 --- a/packages/next-usequerystate/src/cache.ts +++ b/packages/next-usequerystate/src/cache.ts @@ -8,35 +8,43 @@ type ExtractParserType = Parser extends ParserBuilder ? ReturnType : never -export function createSearchParamCache< +export function createSearchParamsCache< Parsers extends Record> >(parsers: Parsers) { type Keys = keyof Parsers type ParsedSearchParams = { - [K in Keys]?: ExtractParserType + [K in Keys]: ExtractParserType } - const getCache = cache<() => ParsedSearchParams>(() => ({})) - function parseSearchParams(searchParams: SearchParams) { + const getCache = cache<() => Partial>(() => ({})) + 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: Key - ): Required[Key] { + function all() { + const c = getCache() + if (Object.keys(c) !== Object.keys(parsers)) { + throw new Error(error(500)) + } + return c as Readonly + } + function get(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 } } diff --git a/packages/next-usequerystate/src/errors.ts b/packages/next-usequerystate/src/errors.ts index 8942888e..023e1673 100644 --- a/packages/next-usequerystate/src/errors.ts +++ b/packages/next-usequerystate/src/errors.ts @@ -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) { diff --git a/packages/next-usequerystate/src/tests/cache.test-d.ts b/packages/next-usequerystate/src/tests/cache.test-d.ts index 0911ad4e..7763d4d7 100644 --- a/packages/next-usequerystate/src/tests/cache.test-d.ts +++ b/packages/next-usequerystate/src/tests/cache.test-d.ts @@ -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(getSearchParam('foo')) - expectType(getSearchParam('bar')) + expectType(cache.get('foo')) + expectType(cache.get('bar')) // Default values are taken into account - expectType(getSearchParam('egg')) - expectNotAssignable(getSearchParam('egg')) + expectType(cache.get('egg')) + expectNotAssignable(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()) }