diff --git a/babel.config.cjs b/babel.config.cjs index 85c9a6c2bf..62a4cbae45 100644 --- a/babel.config.cjs +++ b/babel.config.cjs @@ -38,6 +38,7 @@ module.exports = { './packages/react-query/**', './packages/react-query-devtools/**', './packages/react-query-persist-client/**', + './packages/react-query-next-experimental/**', ], presets: ['@babel/react'], }, diff --git a/docs/config.json b/docs/config.json index 29e2ac321b..dbe2a967af 100644 --- a/docs/config.json +++ b/docs/config.json @@ -278,6 +278,10 @@ "label": "Next.js", "to": "react/examples/react/nextjs" }, + { + "label": "Next.js app with streaming", + "to": "react/examples/react/nextjs-suspense-streaming" + }, { "label": "React Native", "to": "react/examples/react/react-native" diff --git a/examples/react/nextjs-suspense-streaming/next.config.js b/examples/react/nextjs-suspense-streaming/next.config.js new file mode 100644 index 0000000000..9b0c4c0d96 --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/next.config.js @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + appDir: true, + serverActions: true, + }, + webpack: (config) => { + if (config.name === 'server') config.optimization.concatenateModules = false + + return config + }, +} + +module.exports = nextConfig diff --git a/examples/react/nextjs-suspense-streaming/package.json b/examples/react/nextjs-suspense-streaming/package.json new file mode 100644 index 0000000000..187491930f --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/package.json @@ -0,0 +1,23 @@ +{ + "name": "@tanstack/query-example-nextjs-suspense-streaming", + "private": true, + "license": "MIT", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@tanstack/react-query": "^v5.0.0-alpha.68", + "@tanstack/react-query-devtools": "^v5.0.0-alpha.68", + "next": "^13.4.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "superjson": "^1.12.3" + }, + "devDependencies": { + "@types/node": "20.2.5", + "@types/react": "18.2.8", + "typescript": "5.1.3" + } +} diff --git a/examples/react/nextjs-suspense-streaming/src/app/api/wait/route.ts b/examples/react/nextjs-suspense-streaming/src/app/api/wait/route.ts new file mode 100644 index 0000000000..7fd4825e38 --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/src/app/api/wait/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const wait = Number(searchParams.get('wait')) + + await new Promise((resolve) => setTimeout(resolve, wait)) + + return NextResponse.json(`waited ${wait}ms`) +} diff --git a/examples/react/nextjs-suspense-streaming/src/app/layout.tsx b/examples/react/nextjs-suspense-streaming/src/app/layout.tsx new file mode 100644 index 0000000000..550174f7d9 --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/src/app/layout.tsx @@ -0,0 +1,20 @@ +import { Providers } from './providers' + +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} diff --git a/examples/react/nextjs-suspense-streaming/src/app/page.tsx b/examples/react/nextjs-suspense-streaming/src/app/page.tsx new file mode 100644 index 0000000000..dfe62c1c42 --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/src/app/page.tsx @@ -0,0 +1,89 @@ +'use client' +import { useQuery } from '@tanstack/react-query' +import { Suspense } from 'react' + +// export const runtime = "edge"; // 'nodejs' (default) | 'edge' + +function getBaseURL() { + if (typeof window !== 'undefined') { + return '' + } + if (process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}` + } + return 'http://localhost:3000' +} +const baseUrl = getBaseURL() +function useWaitQuery(props: { wait: number }) { + const query = useQuery({ + queryKey: ['wait', props.wait], + queryFn: async () => { + const path = `/api/wait?wait=${props.wait}` + const url = baseUrl + path + + console.log('fetching', url) + const res: string = await ( + await fetch(url, { + cache: 'no-store', + }) + ).json() + return res + }, + suspense: true, + }) + + return [query.data as string, query] as const +} + +function MyComponent(props: { wait: number }) { + const [data] = useWaitQuery(props) + + return
result: {data}
+} + +export default function MyPage() { + return ( + <> + waiting 100....}> + + + waiting 200....}> + + + waiting 300....}> + + + waiting 400....}> + + + waiting 500....}> + + + waiting 600....}> + + + waiting 700....}> + + + +
+ + combined Suspense-container + + +
waiting 800....
+
waiting 900....
+
waiting 1000....
+ + } + > + + + +
+
+ + ) +} diff --git a/examples/react/nextjs-suspense-streaming/src/app/providers.tsx b/examples/react/nextjs-suspense-streaming/src/app/providers.tsx new file mode 100644 index 0000000000..407367b3c9 --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/src/app/providers.tsx @@ -0,0 +1,29 @@ +// app/providers.jsx +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import React from 'react' +import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental' + +export function Providers(props: { children: React.ReactNode }) { + const [queryClient] = React.useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 1000, + }, + }, + }), + ) + + return ( + + + {props.children} + + + + ) +} diff --git a/examples/react/nextjs-suspense-streaming/tsconfig.json b/examples/react/nextjs-suspense-streaming/tsconfig.json new file mode 100644 index 0000000000..6ef5cd577f --- /dev/null +++ b/examples/react/nextjs-suspense-streaming/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/react-query-devtools/vitest.config.ts b/packages/react-query-devtools/vitest.config.ts index 70eec41e33..f61709f2fc 100644 --- a/packages/react-query-devtools/vitest.config.ts +++ b/packages/react-query-devtools/vitest.config.ts @@ -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'], diff --git a/packages/react-query-next-experimental/.eslintrc.cjs b/packages/react-query-next-experimental/.eslintrc.cjs new file mode 100644 index 0000000000..a9fa6e620d --- /dev/null +++ b/packages/react-query-next-experimental/.eslintrc.cjs @@ -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 diff --git a/packages/react-query-next-experimental/package.json b/packages/react-query-next-experimental/package.json new file mode 100644 index 0000000000..a7d357eafc --- /dev/null +++ b/packages/react-query-next-experimental/package.json @@ -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" + } +} diff --git a/packages/react-query-next-experimental/rollup.config.js b/packages/react-query-next-experimental/rollup.config.js new file mode 100644 index 0000000000..149a2f25d5 --- /dev/null +++ b/packages/react-query-next-experimental/rollup.config.js @@ -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', + }), +) diff --git a/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx new file mode 100644 index 0000000000..7b8699049d --- /dev/null +++ b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx @@ -0,0 +1,184 @@ +'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 = unknown & { + [serializedSymbol]: TData +} + +interface TypedDataTransformer { + serialize: (obj: TData) => Serialized + deserialize: (obj: Serialized) => TData +} + +interface HydrationStreamContext { + 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 { + 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() { + const context = React.createContext>( + 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 `