-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
feat: react-query-next-experimental package #5598
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
Changes from 7 commits
42c4613
5777555
f6344ca
526c2e3
dd7233c
6ddb1ca
2d7265b
c428e49
40efb37
7336754
c5169c5
fede312
9ce9513
a83ca67
cb6d33f
17edfd5
09ac023
ed8baa5
6ebe4f3
595c732
bd75087
c6727fe
83ec6f6
4ef87c1
ba57ad8
acf0da6
137433d
63c2891
2d45542
4cad0d4
9d67d8b
5bf15c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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" | ||
| } | ||
| } |
| 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', | ||
| }), | ||
| ) |
| 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>() { | ||
| 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.
Outdated
|
||
|
|
||
| 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.
Outdated
|
||
|
|
||
| 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.
Outdated
|
||
|
|
||
| // Client: consume cache: | ||
| const onEntries = React.useCallback( | ||
|
TkDodo marked this conversation as resolved.
Outdated
|
||
| (...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`) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this check should be removed, it's pointless
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably be changed to an empty array instead
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Outdated
|
||
| 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think they would be hydrated automatically, too. Given that the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Outdated
|
||
| 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> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ReactQueryStreamedHydration } from './ReactQueryStreamedHydration' |
| 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) | ||
| }) |
| 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"] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.