Skip to content

Commit

Permalink
feat: Add support for transitions (#406)
Browse files Browse the repository at this point in the history
* feat: Add support for transitions

This lets non-shallow updates be notified of server rendering
loading status, using an external `React.useTransition` hook.

* test: Add e2e test for transitions

* fix: improve the Option type for transitions (#410)

* feat: Add support for transitions

This lets non-shallow updates be notified of server rendering
loading status, using an external `React.useTransition` hook.

* fix: improve typing

* fix: better typing

* fix: remove unnecessary code

* chore: remove unnecessary changes

* fix: some edge cases when shallow is not a boolean type

* fix: remove as any

* fix: e2e build

* chore: better name for generic

* fix: parser type

* fix: better naming

* fix: better naming

* chore: better naming

* test: Add type tests for shallow/startTransition interaction

* fix: simplify type

* test: add a extra test case

* chore: extract type exclusion logic

* chore: simplify types

* chore: remove unnecessary generics

* chore: add test case for startTransition

* test: Add type tests

* chore: Simplify type & prettify

---------

Co-authored-by: Francois Best <[email protected]>

* doc: Add transitions docs

---------

Co-authored-by: Jon Sun <[email protected]>
  • Loading branch information
franky47 and Talent30 authored Nov 23, 2023
1 parent d4bf14e commit 1b53ea9
Show file tree
Hide file tree
Showing 13 changed files with 297 additions and 29 deletions.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ useQueryState hook for Next.js - Like React.useState, but stored in the URL quer
- ♊️ 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
- ⌛️ **new:** Support for [`useTransition`](#transitions) to get loading states on server updates

## Installation

Expand Down Expand Up @@ -318,6 +319,40 @@ If multiple hooks set different throttle values on the same event loop tick,
the highest value will be used. Also, values lower than 50ms will be ignored,
to avoid rate-limiting issues. [Read more](https://francoisbest.com/posts/2023/storing-react-state-in-the-url-with-nextjs#batching--throttling).

### Transitions

When combined with `shallow: false`, you can use the `useTransition` hook to get
loading states while the server is re-rendering server components with the
updated URL.

Pass in the `startTransition` function from `useTransition` to the options
to enable this behaviour _(this will set `shallow: false` automatically for you)_:

```tsx
'use client'

import React from 'react'
import { useQueryState, parseAsString } from 'next-usequerystate'

function ClientComponent({ data }) {
// 1. Provide your own useTransition hook:
const [isLoading, startTransition] = React.useTransition()
const [query, setQuery] = useQueryState(
'query',
// 2. Pass the `startTransition` as an option:
parseAsString().withOptions({ startTransition })
)
// 3. `isLoading` will be true while the server is re-rendering
// and streaming RSC payloads, when the query is updated via `setQuery`.

// Indicate loading state
if (isLoading) return <div>Loading...</div>

// Normal rendering with data
return <div>{/*...*/}</div>
}
```

## Configuring parsers, default value & options

You can use a builder pattern to facilitate specifying all of those things:
Expand Down
20 changes: 20 additions & 0 deletions packages/e2e/cypress/e2e/transitions.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// <reference types="cypress" />

it('transitions', () => {
cy.visit('/app/transitions')
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('#server-rendered').should('have.text', '{}')
cy.get('#server-status').should('have.text', 'idle')
const button = cy.get('button')
button.should('have.text', '0')
button.click()
button.should('have.text', '1') // Instant setState
cy.get('#server-rendered').should('have.text', '{}')
cy.get('#server-status').should('have.text', 'loading')
cy.wait(500)
cy.get('#server-rendered').should('have.text', '{}')
cy.get('#server-status').should('have.text', 'loading')
cy.wait(500)
cy.get('#server-rendered').should('have.text', '{"counter":"1"}')
cy.get('#server-status').should('have.text', 'idle')
})
20 changes: 20 additions & 0 deletions packages/e2e/src/app/app/transitions/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client'

import { parseAsInteger, useQueryState } from 'next-usequerystate'
import React from 'react'
import { HydrationMarker } from '../../../components/hydration-marker'

export function Client() {
const [isLoading, startTransition] = React.useTransition()
const [counter, setCounter] = useQueryState(
'counter',
parseAsInteger.withDefault(0).withOptions({ startTransition })
)
return (
<>
<HydrationMarker />
<p id="server-status">{isLoading ? 'loading' : 'idle'}</p>
<button onClick={() => setCounter(counter + 1)}>{counter}</button>
</>
)
}
20 changes: 20 additions & 0 deletions packages/e2e/src/app/app/transitions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { setTimeout } from 'node:timers/promises'
import { Suspense } from 'react'
import { Client } from './client'

type PageProps = {
searchParams: Record<string, string | string[] | undefined>
}

export default async function Page({ searchParams }: PageProps) {
await setTimeout(1000)
return (
<>
<h1>Transitions</h1>
<pre id="server-rendered">{JSON.stringify(searchParams)}</pre>
<Suspense>
<Client />
</Suspense>
</>
)
}
23 changes: 21 additions & 2 deletions packages/next-usequerystate/src/defs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useRouter } from 'next/navigation.js' // https://github.com/47ng/next-usequerystate/discussions/352
import type { TransitionStartFunction } from 'react'

export type Router = ReturnType<typeof useRouter>

export type HistoryOptions = 'replace' | 'push'

export type Options = {
// prettier-ignore
type StartTransition<T> = T extends false
? TransitionStartFunction
: T extends true
? never
: TransitionStartFunction

export type Options<Shallow = unknown> = {
/**
* How the query update affects page history
*
Expand All @@ -29,7 +37,7 @@ export type Options = {
* Setting it to `false` will trigger a network request to the server with
* the updated querystring.
*/
shallow?: boolean
shallow?: Extract<Shallow | boolean, boolean>

/**
* Maximum amount of time (ms) to wait between updates of the URL query string.
Expand All @@ -41,6 +49,17 @@ export type Options = {
* will not have any effect.
*/
throttleMs?: number

/**
* Opt-in to observing Server Component loading states when doing
* non-shallow updates by passing a `startTransition` from the
* `React.useTransition()` hook.
*
* Using this will set the `shallow` setting to `false` automatically.
* As a result, you can't set both `shallow: true` and `startTransition`
* in the same Options object.
*/
startTransition?: StartTransition<Shallow>
}

export type Nullable<T> = {
Expand Down
2 changes: 1 addition & 1 deletion packages/next-usequerystate/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type ParserBuilder<T> = Required<Parser<T>> &
* Note that you can override those options in individual calls to the
* state updater function.
*/
withOptions<This>(this: This, options: Options): This
withOptions<This, Shallow>(this: This, options: Options<Shallow>): This

/**
* Specifying a default value makes the hook state non-nullable when the
Expand Down
49 changes: 48 additions & 1 deletion packages/next-usequerystate/src/tests/parsers.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expectType } from 'tsd'
import { expectError, expectType } from 'tsd'
import { parseAsString } from '../../dist'

{
Expand Down Expand Up @@ -27,3 +27,50 @@ import { parseAsString } from '../../dist'
expectType<string | null>(p.parse('foo'))
expectType<string>(p.parseServerSide(undefined))
}

// Shallow / startTransition interaction
{
type RSTF = React.TransitionStartFunction
type MaybeBool = boolean | undefined
type MaybeRSTF = RSTF | undefined

expectType<MaybeBool>(parseAsString.withOptions({}).shallow)
expectType<MaybeRSTF>(parseAsString.withOptions({}).startTransition)
expectType<MaybeBool>(parseAsString.withOptions({ shallow: true }).shallow)
expectType<MaybeRSTF>(
parseAsString.withOptions({ shallow: true }).startTransition
)
expectType<MaybeBool>(parseAsString.withOptions({ shallow: false }).shallow)
expectType<MaybeRSTF>(
parseAsString.withOptions({ shallow: false }).startTransition
)
expectType<MaybeBool>(
parseAsString.withOptions({ startTransition: () => {} }).shallow
)
expectType<MaybeRSTF>(
parseAsString.withOptions({ startTransition: () => {} }).startTransition
)
// Allowed
parseAsString.withOptions({
shallow: false,
startTransition: () => {}
})
// Not allowed
expectError(() => {
parseAsString.withOptions({
shallow: true,
startTransition: () => {}
})
})
expectError(() => {
parseAsString.withOptions({
shallow: {}
})
})

expectError(() => {
parseAsString.withOptions({
startTransition: {}
})
})
}
14 changes: 14 additions & 0 deletions packages/next-usequerystate/src/tests/useQueryState.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,17 @@ import {
expectError(() => setFoo(() => undefined))
expectError(() => setBar(() => undefined))
}

// Shallow & startTransition interaction
{
const [, set] = useQueryState('foo')
set('ok', { shallow: true })
set('ok', { shallow: false })
set('ok', { startTransition: () => {} })
expectError(() => {
set('nope', {
shallow: true,
startTransition: () => {}
})
})
}
43 changes: 43 additions & 0 deletions packages/next-usequerystate/src/update-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, test, vi } from 'vitest'
import { compose } from './update-queue'

describe('update-queue/compose', () => {
test('empty array', () => {
const final = vi.fn()
compose([], final)
expect(final).toHaveBeenCalledOnce()
})
test('one item', () => {
const a = vi
.fn()
.mockImplementation(x => x())
.mockName('a')
const final = vi.fn()
compose([a], final)
expect(a).toHaveBeenCalledOnce()
expect(final).toHaveBeenCalledOnce()
expect(a.mock.invocationCallOrder[0]).toBeLessThan(
final.mock.invocationCallOrder[0]!
)
})
test('several items', () => {
const a = vi.fn().mockImplementation(x => x())
const b = vi.fn().mockImplementation(x => x())
const c = vi.fn().mockImplementation(x => x())
const final = vi.fn()
compose([a, b, c], final)
expect(a).toHaveBeenCalledOnce()
expect(b).toHaveBeenCalledOnce()
expect(c).toHaveBeenCalledOnce()
expect(final).toHaveBeenCalledOnce()
expect(a.mock.invocationCallOrder[0]).toBeLessThan(
b.mock.invocationCallOrder[0]!
)
expect(b.mock.invocationCallOrder[0]).toBeLessThan(
c.mock.invocationCallOrder[0]!
)
expect(c.mock.invocationCallOrder[0]).toBeLessThan(
final.mock.invocationCallOrder[0]!
)
})
})
42 changes: 35 additions & 7 deletions packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle()

type UpdateMap = Map<string, string | null>
const updateQueue: UpdateMap = new Map()
const queueOptions: Required<Options> = {
const queueOptions: Required<Omit<Options, 'startTransition'>> = {
history: 'replace',
scroll: false,
shallow: true,
throttleMs: FLUSH_RATE_LIMIT_MS
}
const transitionsQueue: Set<React.TransitionStartFunction> = new Set()

let lastFlushTimestamp = 0
let flushPromiseCache: Promise<URLSearchParams> | null = null
Expand All @@ -38,6 +39,12 @@ export function enqueueQueryStringUpdate<Value>(
if (options.shallow === false) {
queueOptions.shallow = false
}
if (options.startTransition) {
// Providing a startTransition function will
// cause the update to be non-shallow.
transitionsQueue.add(options.startTransition)
queueOptions.shallow = false
}
queueOptions.throttleMs = Math.max(
options.throttleMs ?? FLUSH_RATE_LIMIT_MS,
Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0
Expand Down Expand Up @@ -117,8 +124,10 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] {
// Work on a copy and clear the queue immediately
const items = Array.from(updateQueue.entries())
const options = { ...queueOptions }
const transitions = Array.from(transitionsQueue)
// Restore defaults
updateQueue.clear()
transitionsQueue.clear()
queueOptions.history = 'replace'
queueOptions.scroll = false
queueOptions.shallow = true
Expand Down Expand Up @@ -153,12 +162,14 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] {
window.scrollTo(0, 0)
}
if (!options.shallow) {
// Call the Next.js router to perform a network request
// and re-render server components.
router.replace(url, {
scroll: false,
// @ts-expect-error - pages router fix, but not exposed in navigation types
shallow: false
compose(transitions, () => {
// Call the Next.js router to perform a network request
// and re-render server components.
router.replace(url, {
scroll: false,
// @ts-expect-error - pages router fix, but not exposed in navigation types
shallow: false
})
})
}
return [search, null]
Expand All @@ -176,3 +187,20 @@ function renderURL(search: URLSearchParams) {
const hash = location.hash
return href + query + hash
}

export function compose(
fns: React.TransitionStartFunction[],
final: () => void
) {
const recursiveCompose = (index: number) => {
if (index === fns.length) {
return final()
}
const fn = fns[index]
if (!fn) {
throw new Error('Invalid transition function')
}
fn(() => recursiveCompose(index + 1))
}
recursiveCompose(0)
}
Loading

0 comments on commit 1b53ea9

Please sign in to comment.