Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: react-query-next-experimental package #5598

Merged
merged 32 commits into from
Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
42c4613
chore: fix a copy-paste error
TkDodo Jun 17, 2023
5777555
chore: bootstrap package
TkDodo Jun 17, 2023
f6344ca
chore: setup package
TkDodo Jun 17, 2023
526c2e3
chore: allow passing with no tests
TkDodo Jun 17, 2023
dd7233c
fix: remove 'use client' from index bundle
TkDodo Jun 17, 2023
6ddb1ca
chore: cleanup copy/paste error
TkDodo Jun 17, 2023
2d7265b
chore: fix prettier
TkDodo Jun 17, 2023
c428e49
refactor: replace ref with direct props access
TkDodo Jun 18, 2023
40efb37
fix: do not write to refs during render
TkDodo Jun 18, 2023
7336754
refactor: inline function into useEffect to avoid useCallback
TkDodo Jun 18, 2023
c5169c5
fix: eslint no-shadow
TkDodo Jun 18, 2023
fede312
refactor: namespace id
TkDodo Jun 18, 2023
9ce9513
refactor: removed pointless check
TkDodo Jun 18, 2023
a83ca67
fix: set to empty array on cleanup
TkDodo Jun 18, 2023
cb6d33f
Merge branch 'alpha' into feature/suspense
TkDodo Jun 18, 2023
17edfd5
Merge branch 'alpha' into feature/suspense
TkDodo Jun 19, 2023
09ac023
fix: adapt for v5 changes
TkDodo Jun 19, 2023
ed8baa5
chore: fix build
TkDodo Jun 19, 2023
6ebe4f3
docs: add streaming example
TkDodo Jun 19, 2023
595c732
chore: fix outdated lockfile
TkDodo Jun 19, 2023
bd75087
chore: fix prettier
TkDodo Jun 19, 2023
c6727fe
Merge branch 'alpha' into feature/suspense
TkDodo Jun 23, 2023
83ec6f6
Merge branch 'alpha' into feature/suspense
TkDodo Jun 25, 2023
4ef87c1
refactor: remove isSubscribed ref
TkDodo Jun 25, 2023
ba57ad8
refactor: re-arrange comment
TkDodo Jun 25, 2023
acf0da6
chore: remove comments
TkDodo Jun 25, 2023
137433d
Merge branch 'alpha' into feature/suspense
TkDodo Jun 27, 2023
63c2891
chore: fix broken lock file
TkDodo Jun 27, 2023
2d45542
Merge branch 'alpha' into feature/suspense
TkDodo Jun 27, 2023
4cad0d4
Merge branch 'alpha' into feature/suspense
TkDodo Jun 27, 2023
9d67d8b
feat: allow customization of hydrate / dehydrate options
TkDodo Jun 27, 2023
5bf15c2
Merge branch 'alpha' into feature/suspense
TkDodo Jul 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-query-devtools/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
name: 'react-query-persist-client',
name: 'react-query-devtools',
dir: './src',
watch: false,
setupFiles: ['test-setup.ts'],
Expand Down
16 changes: 16 additions & 0 deletions packages/react-query-next-experimental/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {
extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.eslint.json',
},
rules: {
'react/jsx-key': ['error', { checkFragmentShorthand: true }],
'react-hooks/exhaustive-deps': 'error',
},
}

module.exports = config
57 changes: 57 additions & 0 deletions packages/react-query-next-experimental/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@tanstack/react-query-next-experimental",
"version": "5.0.0-alpha.67",
"description": "Hydration utils for React Query in the NextJs app directory",
"author": "tannerlinsley",
"license": "MIT",
"repository": "tanstack/query",
"homepage": "https://tanstack.com/query",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"type": "module",
"types": "build/lib/index.d.ts",
"main": "build/lib/index.legacy.cjs",
"module": "build/lib/index.legacy.js",
"exports": {
".": {
"types": "./build/lib/index.d.ts",
"import": "./build/lib/index.js",
"require": "./build/lib/index.cjs",
"default": "./build/lib/index.cjs"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"build/lib/*",
"src"
],
"scripts": {
"clean": "rimraf ./build && rimraf ./coverage",
"test:eslint": "eslint --ext .ts,.tsx ./src",
"test:types": "tsc --noEmit",
"test:lib": "vitest run --coverage --passWithNoTests",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict",
"build": "pnpm build:rollup && pnpm build:types",
"build:rollup": "rollup --config rollup.config.js",
"build:types": "tsc --emitDeclarationOnly"
},
"devDependencies": {
"@tanstack/react-query": "workspace:*",
"@types/react": "^18.2.4",
"@types/react-dom": "^18.2.4",
"next": "^13.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4"
},
"peerDependencies": {
"@tanstack/react-query": "workspace:*",
"next": "^13.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
12 changes: 12 additions & 0 deletions packages/react-query-next-experimental/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// @ts-check

import { defineConfig } from 'rollup'
import { buildConfigs } from '../../scripts/getRollupConfig.js'

export default defineConfig(
buildConfigs({
name: 'react-query-next-experimental',
outputFile: 'index',
entryFile: './src/index.ts',
}),
)
194 changes: 194 additions & 0 deletions packages/react-query-next-experimental/src/HydrationStreamProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
'use client'

import { useServerInsertedHTML } from 'next/navigation'
import * as React from 'react'

const serializedSymbol = Symbol('serialized')

interface DataTransformer {
serialize(object: any): any
deserialize(object: any): any
}

type Serialized<TData> = unknown & {
[serializedSymbol]: TData
}

interface TypedDataTransformer<TData> {
serialize: (obj: TData) => Serialized<TData>
deserialize: (obj: Serialized<TData>) => TData
}

interface HydrationStreamContext<TShape> {
id: string
stream: {
/**
* **Server method**
* Push a new entry to the stream
* Will be ignored on the client
*/
push: (...shape: TShape[]) => void
}
}

export interface HydrationStreamProviderProps<TShape> {
children: React.ReactNode
/**
* Optional transformer to serialize/deserialize the data
* Example devalue, superjson et al
*/
transformer?: DataTransformer
/**
* **Client method**
* Called in the browser when new entries are received
*/
onEntries: (entries: TShape[]) => void
/**
* **Server method**
* onFlush is called on the server when the cache is flushed
*/
onFlush?: () => TShape[]
}

export function createHydrationStreamProvider<TShape>() {
Ephem marked this conversation as resolved.
Show resolved Hide resolved
const context = React.createContext<HydrationStreamContext<TShape>>(
null as any,
)
/**

* 1. (Happens on server): `useServerInsertedHTML()` is called **on the server** whenever a `Suspense`-boundary completes
* - This means that we might have some new entries in the cache that needs to be flushed
* - We pass these to the client by inserting a `<script>`-tag where we do `window[id].push(serializedVersionOfCache)`
* 2. (Happens in browser) In `useEffect()`:
* - We check if `window[id]` is set to an array and call `push()` on all the entries which will call `onEntries()` with the new entries
* - We replace `window[id]` with a `push()`-method that will be called whenever new entries are received
**/
function UseClientHydrationStreamProvider(props: {
children: React.ReactNode
/**
* Optional transformer to serialize/deserialize the data
* Example devalue, superjson et al
*/
transformer?: DataTransformer
/**
* **Client method**
* Called in the browser when new entries are received
*/
onEntries: (entries: TShape[]) => void
/**
* **Server method**
* onFlush is called on the server when the cache is flushed
*/
onFlush?: () => TShape[]
}) {
// unique id for the cache provider
const id = `_${React.useId()}`
const idJSON = JSON.stringify(id)
TkDodo marked this conversation as resolved.
Show resolved Hide resolved

const [transformer] = React.useState(
() =>
(props.transformer ?? {
// noop
serialize: (obj: any) => obj,
deserialize: (obj: any) => obj,
}) as unknown as TypedDataTransformer<TShape>,
)

// <server stuff>
const [stream] = React.useState<TShape[]>(() => {
if (typeof window !== 'undefined') {
return {
push() {
// no-op on the client
},
} as unknown as TShape[]
}
return []
})
const count = React.useRef(0)
const onDehydrateRef = React.useRef(props.onFlush)
onDehydrateRef.current = props.onFlush
useServerInsertedHTML(() => {
// This only happens on the server
stream.push(...(onDehydrateRef.current?.() ?? []))
TkDodo marked this conversation as resolved.
Show resolved Hide resolved

if (!stream.length) {
return null
}
// console.log(`pushing ${stream.length} entries`)
const serializedCacheArgs = stream
.map((entry) => transformer.serialize(entry))
.map((entry) => JSON.stringify(entry))
.join(',')

// Flush stream
stream.length = 0

const html: string[] = [
`window[${idJSON}] = window[${idJSON}] || [];`,
`window[${idJSON}].push(${serializedCacheArgs});`,
]
return (
<script
key={count.current++}
dangerouslySetInnerHTML={{
__html: html.join(''),
}}
/>
)
})
// </server stuff>

// <client stuff>
const onEntriesRef = React.useRef(props.onEntries)
onEntriesRef.current = props.onEntries
TkDodo marked this conversation as resolved.
Show resolved Hide resolved

// Client: consume cache:
const onEntries = React.useCallback(
TkDodo marked this conversation as resolved.
Show resolved Hide resolved
(...serializedEntries: Serialized<TShape>[]) => {
const entries = serializedEntries.map((serialized) =>
transformer.deserialize(serialized),
)
onEntriesRef.current(entries)
},
[transformer],
)

React.useEffect(() => {
const win = window as any
// Register cache consumer
const stream: Array<Serialized<TShape>> = win[id] ?? []

if (!Array.isArray(stream)) {
throw new Error(`${id} seem to have been registered twice`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check should be removed, it's pointless

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed: 9ce9513

}
onEntries(...stream)

// Register our own consumer
win[id] = {
push: onEntries,
}

return () => {
// Cleanup after unmount
win[id] = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be changed to an empty array instead

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay: a83ca67

push() {
// no-op
},
}
}
}, [id, onEntries])
// </client stuff>

return (
<context.Provider value={{ stream, id }}>
{props.children}
</context.Provider>
)
}

return {
Provider: UseClientHydrationStreamProvider,
context,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client'

import type { ContextOptions, DehydratedState } from '@tanstack/react-query'
import { dehydrate, hydrate, useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import type { HydrationStreamProviderProps } from './HydrationStreamProvider'
import { createHydrationStreamProvider } from './HydrationStreamProvider'

const stream = createHydrationStreamProvider<DehydratedState>()

/**
* This component is responsible for:
* - hydrating the query client on the server
* - dehydrating the query client on the server
*/
export function ReactQueryStreamedHydration(props: {
children: React.ReactNode
context?: ContextOptions['context']
transformer?: HydrationStreamProviderProps<DehydratedState>['transformer']
}) {
const queryClient = useQueryClient({
context: props.context,
})

// <server only>
Ephem marked this conversation as resolved.
Show resolved Hide resolved
const isSubscribed = React.useRef(false)
/**
* We need to track which queries were added/updated during the render
*/
const [trackedKeys] = React.useState(() => new Set<string>())
Ephem marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there are already queries in the cache, for example is some queries have been seeded/prefetched/are currently being prefetched (pending)? Are you then supposed to seed the browser RQ cache yourself too by having a <Hydrate> above these new providers in the tree (not possible for pending queries), or should this Set be initialised with any existing queries?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think they would be hydrated automatically, too. Given that the ReactQueryStreamedHydration is very high up your component tree, it would subscribe very early to the QueryCache, so it would also track those queries.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What @TkDodo said, if you do something that changes the react query client cache, we'll subscribe to it and flush it next chance we get

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But what if you prime the cache before the rendering pass? Like if you await some data in a Server Component (maybe you need to generate metadata anyway), or you have some existing mechanism of prefetching that prefetches before rendering?

/**
* Track which queries were already passed to the client so we don't pass them again
*/
// const [passedKeys] = useState(() => new Set<string>());

const cache = queryClient.getQueryCache()

if (typeof window === 'undefined' && !isSubscribed.current) {
// Do we need to care about unsubscribing? I don't think so to be honest
cache.subscribe((event) => {
Ephem marked this conversation as resolved.
Show resolved Hide resolved
switch (event.type) {
case 'added':
case 'updated':
// console.log('tracking', event.query.queryHash, 'b/c of a', event.type)
trackedKeys.add(event.query.queryHash)
}
})
}
// </server only>

return (
<stream.Provider
// Happens on server:
onFlush={() => {
/**
* Dehydrated state of the client where we only include the queries that were added/updated since the last flush
*/
const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery(query) {
const shouldDehydrate =
trackedKeys.has(query.queryHash) &&
// !passedKeys.has(query.queryHash) &&
query.state.status !== 'loading'

// passedKeys.add(query.queryHash);
return shouldDehydrate
},
})
trackedKeys.clear()

if (!dehydratedState.queries.length) {
return []
}

return [dehydratedState]
}}
// Happens in browser:
onEntries={(entries) => {
for (const hydratedState of entries) {
hydrate(queryClient, hydratedState)
}
}}
// Handle BigInts etc using superjson
transformer={props.transformer}
>
{props.children}
</stream.Provider>
)
}
1 change: 1 addition & 0 deletions packages/react-query-next-experimental/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ReactQueryStreamedHydration } from './ReactQueryStreamedHydration'
7 changes: 7 additions & 0 deletions packages/react-query-next-experimental/test-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { act } from '@testing-library/react'
import { notifyManager } from '@tanstack/react-query'

// Wrap notifications with act to make sure React knows about React Query updates
notifyManager.setNotifyFunction((fn) => {
act(fn)
})
7 changes: 7 additions & 0 deletions packages/react-query-next-experimental/tsconfig.eslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["**/*.ts", "**/*.tsx", ".eslintrc.cjs", "rollup.config.js"]
}
Loading