Skip to content

Commit

Permalink
feat: Add search params cache (#397)
Browse files Browse the repository at this point in the history
* feat: Add search params cache

This simplifies the use of parsers on the server, and allows
accessing search params in a type-safe manner in deeply
nested server components.

Big thanks to @eric-burel for the tip on how to use React's `cache` this way.

* doc: Add error

Running out of relevant HTTP status codes.. Maybe this was a bad idea.

* feat: Freeze cache after parsing and return it

This allows accessing the parsed values straight from the
page component. Freezing also ensures the object reference
stays immutable for the whole duration of the page render.

* chore: New API for seach param cache

* test: Add cache e2e test & fix NUQS-500

* doc: Add cache feature

* doc: App router support isn't new anymore

* doc: Refactor cache code examples

Cache can't be exported from the page file,
so instead showcase the "soft convention"
of using a searchParams.ts file next to the page.

* chore: Ignore react on the server too

* chore: Increase server size limit

* test: Add more navigation cases to the cache test

* chore: Add comment about the use of `cache`

* chore: Change error to emphasize the Layouts case

* chore: Remove children pages to get CI to pass

What the actual ***?
The mere presence of those pages (in the app router),
and even if they only were to contain a static Hello world
output, would cause the **pages** router to fail to apply
updates, when there is a basePath, and on Next.js 13.5+.

Losing sanity here trying to figure out what's wrong,
and it's the middle of the night so I won't go deeper
into this right now.

See #397 (comment)

* chore: Remove links

* test: Restore faulty pages, but disabled

* test: Fix gSSP in base test rig
  • Loading branch information
franky47 authored Nov 23, 2023
1 parent 0947d32 commit d4bf14e
Show file tree
Hide file tree
Showing 22 changed files with 531 additions and 25 deletions.
113 changes: 112 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ useQueryState hook for Next.js - Like React.useState, but stored in the URL quer

## Features

- 🔀 _**new:**_ Supports both the `app` and `pages` routers
- 🔀 Supports both the `app` and `pages` routers
- 🧘‍♀️ Simple: the URL is the source of truth
- 🕰 Replace history or [append](#history) to use the Back button to navigate state updates
- ⚡️ Built-in [parsers](#parsing) for common state types (integer, float, boolean, Date, and more)
- ♊️ Related querystrings with [`useQueryStates`](#usequerystates)
- 📡 [Shallow mode](#shallow) by default for URL query updates, opt-in to notify server components
- 🗃 _**new:**_ [Server cache](#accessing-searchparams-in-server-components) for type-safe searchParams access in nested server components

## Installation

Expand Down Expand Up @@ -454,6 +455,116 @@ 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
// searchParams.ts
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)
})

// page.tsx
import { searchParamsCache } from './searchParams'

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
56 changes: 56 additions & 0 deletions errors/NUQS-500.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Empty Search Params Cache

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

## A note on layouts

The error can also occur if your server component consuming the search params
cache is mounted in a **layout** component. Those don't receive search params as
they are not re-rendered when the page renders.

In this case, your only option is to turn the server component into a client
component, and read the search params with `useQueryStates`. You can
[feed it the same parser object](https://github.com/47ng/next-usequerystate#accessing-searchparams-in-server-components)
you used to create the cache, and it you'll get the same
type safety.

## Possible Solution

Run the `parseSearchParam` function on the page's `searchParams`:

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

const cache = createSearchParamsCache({
q: parseAsString,
maxResults: parseAsInteger.withDefault(10)
})

export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
// ⚠️ Don't forget to call `parse` here:
const { q: query } = cache.parse(searchParams)
return (
<div>
<h1>Search Results for {query}</h1>
<Results />
</div>
)
}

function Results() {
// In order to get search params from child server components:
const maxResults = cache.get('maxResults')
return <span>Showing up to {maxResults} results</span>
}
```
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.
22 changes: 22 additions & 0 deletions packages/e2e/cypress/e2e/cache.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// <reference types="cypress" />

describe('cache', () => {
it('works in app router', () => {
cy.visit('/app/cache?str=foo&num=42&bool=true')
cy.get('#parse-str').should('have.text', 'foo')
cy.get('#parse-num').should('have.text', '42')
cy.get('#parse-bool').should('have.text', 'true')
cy.get('#parse-def').should('have.text', 'default')
cy.get('#parse-nope').should('have.text', 'null')
cy.get('#all-str').should('have.text', 'foo')
cy.get('#all-num').should('have.text', '42')
cy.get('#all-bool').should('have.text', 'true')
cy.get('#all-def').should('have.text', 'default')
cy.get('#all-nope').should('have.text', 'null')
cy.get('#get-str').should('have.text', 'foo')
cy.get('#get-num').should('have.text', '42')
cy.get('#get-bool').should('have.text', 'true')
cy.get('#get-def').should('have.text', 'default')
cy.get('#get-nope').should('have.text', 'null')
})
})
40 changes: 40 additions & 0 deletions packages/e2e/src/app/app/cache/a/page.disabled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Link from 'next/link'
import { Suspense } from 'react'
import { All } from '../all'
import { Get } from '../get'
import { cache } from '../searchParams'
import { Set } from '../set'

export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
const { str, bool, num, def, nope } = cache.parse(searchParams)
return (
<>
<h1>Page A</h1>
<h2>From parse:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="parse-str">{str}</span>
<span id="parse-num">{num}</span>
<span id="parse-bool">{String(bool)}</span>
<span id="parse-def">{def}</span>
<span id="parse-nope">{String(nope)}</span>
</p>
<All />
<Get />
<Suspense>
<Set />
</Suspense>
<ul>
<li>
<Link href="/app/cache?str=from-a">To root page</Link>
</li>
<li>
<Link href="/app/cache/b?str=from-a">To page B</Link>
</li>
</ul>
</>
)
}
17 changes: 17 additions & 0 deletions packages/e2e/src/app/app/cache/all.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cache } from './searchParams'

export function All() {
const { bool, num, str, def, nope } = cache.all()
return (
<>
<h2>From all:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="all-str">{str}</span>
<span id="all-num">{num}</span>
<span id="all-bool">{String(bool)}</span>
<span id="all-def">{def}</span>
<span id="all-nope">{String(nope)}</span>
</p>
</>
)
}
40 changes: 40 additions & 0 deletions packages/e2e/src/app/app/cache/b/page.disabled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Link from 'next/link'
import { Suspense } from 'react'
import { All } from '../all'
import { Get } from '../get'
import { cache } from '../searchParams'
import { Set } from '../set'

export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
const { str, bool, num, def, nope } = cache.parse(searchParams)
return (
<>
<h1>Page B</h1>
<h2>From parse:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="parse-str">{str}</span>
<span id="parse-num">{num}</span>
<span id="parse-bool">{String(bool)}</span>
<span id="parse-def">{def}</span>
<span id="parse-nope">{String(nope)}</span>
</p>
<All />
<Get />
<Suspense>
<Set />
</Suspense>
<ul>
<li>
<Link href="/app/cache?str=from-b">To root page</Link>
</li>
<li>
<Link href="/app/cache/a?str=from-b">To page A</Link>
</li>
</ul>
</>
)
}
21 changes: 21 additions & 0 deletions packages/e2e/src/app/app/cache/get.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { cache } from './searchParams'

export function Get() {
const bool = cache.get('bool')
const num = cache.get('num')
const str = cache.get('str')
const def = cache.get('def')
const nope = cache.get('nope')
return (
<>
<h2>From get:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="get-str">{str}</span>
<span id="get-num">{num}</span>
<span id="get-bool">{String(bool)}</span>
<span id="get-def">{def}</span>
<span id="get-nope">{String(nope)}</span>
</p>
</>
)
}
18 changes: 18 additions & 0 deletions packages/e2e/src/app/app/cache/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react'
import { cache } from './searchParams'

export default function Layout({ children }: { children: React.ReactNode }) {
let result = ''
try {
result = JSON.stringify(cache.all())
} catch (error) {
result = String(error)
}
return (
<>
{children}
<h2>Layout</h2>
<p id="layout-result">{result}</p>
</>
)
}
31 changes: 31 additions & 0 deletions packages/e2e/src/app/app/cache/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Suspense } from 'react'
import { All } from './all'
import { Get } from './get'
import { cache } from './searchParams'
import { Set } from './set'

export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
const { str, bool, num, def, nope } = cache.parse(searchParams)
return (
<>
<h1>Root page</h1>
<h2>From parse:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="parse-str">{str}</span>
<span id="parse-num">{num}</span>
<span id="parse-bool">{String(bool)}</span>
<span id="parse-def">{def}</span>
<span id="parse-nope">{String(nope)}</span>
</p>
<All />
<Get />
<Suspense>
<Set />
</Suspense>
</>
)
}
Loading

0 comments on commit d4bf14e

Please sign in to comment.