diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md index 9e9359e804..0b9cc09857 100644 --- a/docs/framework/react/api/router/RouterOptionsType.md +++ b/docs/framework/react/api/router/RouterOptionsType.md @@ -241,6 +241,7 @@ const router = createRouter({ - Type: `RouterTransformer` - Optional - The transformer that will be used when sending data between the server and the client during SSR. +- Defaults to a very lightweight transformer that supports a few basic types. See the [SSR guide](../../guides/ssr) for more information. #### `transformer.stringify` method diff --git a/docs/framework/react/guide/ssr.md b/docs/framework/react/guide/ssr.md index 8ffbbfc812..1e1e6cb483 100644 --- a/docs/framework/react/guide/ssr.md +++ b/docs/framework/react/guide/ssr.md @@ -203,7 +203,18 @@ Streaming dehydration/hydration is an advanced pattern that goes beyond markup a ## Data Transformers -When using SSR, data passed between the server and the client must be serialized before it is sent accross network-boundaries. By default, TanStack Router will serialize data using the default `JSON.parse` and `JSON.stringify` implementations. This, however, can lead to incorrect type-definitions when using objects such as `Date`/`Map`/`Set` etc. The Data Transformer API allows the usage of a custom serializer that can allow us to transparently use these data types when communicating across the network. +When using SSR, data passed between the server and the client must be serialized before it is sent accross network-boundaries. By default, TanStack Router will serialize data using a very lightweight serializer that supports a few basic types beyond JSON.stringify/JSON.parse. + +Out of the box, the following types are supported: + +- `Date` +- `undefined` + +If you feel that there are other types that should be supported by default, please open an issue on the TanStack Router repository. + +If you are using more complex data types like `Map`, `Set`, `BigInt`, etc, you may need to use a custom serializer to ensure that your type-definitions are accurate and your data is correctly serialized and deserialized. This is where the `transformer` option on `createRouter` comes in. + +The Data Transformer API allows the usage of a custom serializer that can allow us to transparently use these data types when communicating across the network. The following example shows usage with [SuperJSON](https://github.com/blitz-js/superjson), however, anything that implements [`Router Transformer`](../../api/router/RouterOptionsType#transformer-property) can be used. diff --git a/examples/react/start-basic-react-query/app/router.tsx b/examples/react/start-basic-react-query/app/router.tsx index 4883330873..24604116c1 100644 --- a/examples/react/start-basic-react-query/app/router.tsx +++ b/examples/react/start-basic-react-query/app/router.tsx @@ -1,111 +1,26 @@ -import { - QueryClient, - QueryClientProvider, - dehydrate, - hashKey, - hydrate, -} from '@tanstack/react-query' +import { QueryClient } from '@tanstack/react-query' import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routerWithQueryClient } from '@tanstack/react-router-with-query' import { routeTree } from './routeTree.gen' import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' import { NotFound } from './components/NotFound' -import type { - QueryObserverResult, - UseQueryOptions, -} from '@tanstack/react-query' // NOTE: Most of the integration code found here is experimental and will // definitely end up in a more streamlined API in the future. This is just // to show what's possible with the current APIs. export function createRouter() { - const seenQueryKeys = new Set() - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - _experimental_beforeQuery: (options: UseQueryOptions) => { - // On the server, check if we've already seen the query before - if (router.isServer) { - if (seenQueryKeys.has(hashKey(options.queryKey))) { - return - } - - seenQueryKeys.add(hashKey(options.queryKey)) - - // If we haven't seen the query and we have data for it, - // That means it's going to get dehydrated with critical - // data, so we can skip the injection - if (queryClient.getQueryData(options.queryKey) !== undefined) { - ;(options as any).__skipInjection = true - return - } - } else { - // On the client, pick up the deferred data from the stream - const dehydratedClient = router.getStreamedValue( - '__QueryClient__' + hashKey(options.queryKey), - ) - - // If we have data, hydrate it into the query client - if (dehydratedClient && !dehydratedClient.hydrated) { - dehydratedClient.hydrated = true - hydrate(queryClient, dehydratedClient) - } - } - }, - _experimental_afterQuery: ( - options: UseQueryOptions, - _result: QueryObserverResult, - ) => { - // On the server (if we're not skipping injection) - // send down the dehydrated query - if ( - router.isServer && - !(options as any).__skipInjection && - queryClient.getQueryData(options.queryKey) !== undefined - ) { - router.streamValue( - '__QueryClient__' + hashKey(options.queryKey), - dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - hashKey(query.queryKey) === hashKey(options.queryKey), - }), - ) - } - }, - } as any, - }, - }) - - const router = createTanStackRouter({ - routeTree, - defaultPreload: 'intent', - defaultErrorComponent: DefaultCatchBoundary, - defaultNotFoundComponent: () => , - dehydrate: () => ({ - // When critical data is dehydrated, we also dehydrate the query client - dehydratedQueryClient: dehydrate(queryClient), + const queryClient = new QueryClient() + + return routerWithQueryClient( + createTanStackRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , }), - hydrate: ({ dehydratedQueryClient }) => { - // On the client, hydrate the query client with the dehydrated data - hydrate(queryClient, dehydratedQueryClient) - }, - context: { - // Pass the query client to the context, so we can access it in loaders - queryClient, - }, - // Wrap the app in a QueryClientProvider - Wrap: ({ children }) => { - return ( - - {children} - - ) - }, - }) - - return router + queryClient, + ) } declare module '@tanstack/react-router' { diff --git a/examples/react/start-basic-react-query/app/routes/deferred.tsx b/examples/react/start-basic-react-query/app/routes/deferred.tsx index dc2927401b..ddf853cc04 100644 --- a/examples/react/start-basic-react-query/app/routes/deferred.tsx +++ b/examples/react/start-basic-react-query/app/routes/deferred.tsx @@ -10,6 +10,7 @@ const deferredQueryOptions = () => return { message: `Hello deferred from the server!`, status: 'success', + time: new Date(), } }, }) @@ -46,6 +47,7 @@ function DeferredQuery() {

Deferred Query

Status: {deferredQuery.data.status}
Message: {deferredQuery.data.message}
+
Time: {deferredQuery.data.time.toISOString()}
) } diff --git a/examples/react/start-basic-react-query/package.json b/examples/react/start-basic-react-query/package.json index 63e3f9a7b8..a47dc91bfa 100644 --- a/examples/react/start-basic-react-query/package.json +++ b/examples/react/start-basic-react-query/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.49.2", "@tanstack/react-query-devtools": "^5.48.0", "@tanstack/react-router": "^1.43.3", + "@tanstack/react-router-with-query": "^1.43.2", "@tanstack/router-devtools": "^1.43.3", "@tanstack/router-vite-plugin": "^1.43.1", "@tanstack/start": "^1.43.3", diff --git a/package.json b/package.json index c8c4b231e6..d0af3bbfe0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "type": "module", "scripts": { "clean": "pnpm --filter \"./packages/**\" run clean", - "preinstall": "node -e \"if(process.env.CI == 'true') {console.log('Skipping preinstall...')} else {process.exit(1)}\" || npx -y only-allow pnpm", + "preinstall": "node -e \"if(process.env.CI == 'true') {console.info('Skipping preinstall...')} else {process.exit(1)}\" || npx -y only-allow pnpm", "test": "pnpm run test:ci", "test:pr": "nx affected --targets=test:format,test:eslint,test:unit,test:build,build", "test:ci": "nx run-many --targets=test:eslint,test:unit,test:types,test:build,build", @@ -72,6 +72,7 @@ "@tanstack/router-generator": "workspace:*", "@tanstack/router-plugin": "workspace:*", "@tanstack/router-vite-plugin": "workspace:*", + "@tanstack/react-router-with-query": "workspace:*", "@tanstack/start": "workspace:*", "@tanstack/start-vite-plugin": "workspace:*", "temp-react": "0.0.0-experimental-035a41c4e-20230704", diff --git a/packages/react-router-with-query/README.md b/packages/react-router-with-query/README.md new file mode 100644 index 0000000000..d83bf5fdf4 --- /dev/null +++ b/packages/react-router-with-query/README.md @@ -0,0 +1,31 @@ + + +# TanStack React Router + +![TanStack Router Header](https://github.com/tanstack/router/raw/main/media/header.png) + +🤖 Type-safe router w/ built-in caching & URL state management for React! + + + #TanStack + + + + + + + + semantic-release + + Join the discussion on Github +Best of JS + + + + + + + +Enjoy this library? Try the entire [TanStack](https://tanstack.com)! [React Query](https://github.com/tannerlinsley/react-query), [React Table](https://github.com/tanstack/react-table), [React Charts](https://github.com/tannerlinsley/react-charts), [React Virtual](https://github.com/tannerlinsley/react-virtual) + +## Visit [tanstack.com/router](https://tanstack.com/router) for docs, guides, API and more! diff --git a/packages/react-router-with-query/eslint.config.js b/packages/react-router-with-query/eslint.config.js new file mode 100644 index 0000000000..7d06cc738e --- /dev/null +++ b/packages/react-router-with-query/eslint.config.js @@ -0,0 +1,31 @@ +// @ts-check + +import pluginReact from '@eslint-react/eslint-plugin' +import pluginReactHooks from 'eslint-plugin-react-hooks' +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + files: ['**/*.{ts,tsx}'], + ...pluginReact.configs.recommended, + }, + { + plugins: { + 'react-hooks': pluginReactHooks, + }, + rules: { + '@eslint-react/no-unstable-context-value': 'off', + '@eslint-react/no-unstable-default-props': 'off', + '@eslint-react/dom/no-missing-button-type': 'off', + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/rules-of-hooks': 'error', + }, + }, + { + files: ['**/__tests__/**'], + rules: { + 'ts/no-unnecessary-condition': 'off', + }, + }, +] diff --git a/packages/react-router-with-query/package.json b/packages/react-router-with-query/package.json new file mode 100644 index 0000000000..5069c5bca3 --- /dev/null +++ b/packages/react-router-with-query/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tanstack/react-router-with-query", + "version": "1.43.2", + "description": "", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/router.git", + "directory": "packages/react-router" + }, + "homepage": "https://tanstack.com/router", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf ./dist && rimraf ./coverage", + "test:eslint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:unit": "vitest", + "test:unit:dev": "pnpm run test:unit --watch", + "test:build": "publint --strict", + "build": "vite build" + }, + "keywords": [ + "react", + "location", + "router", + "routing", + "async", + "async router", + "typescript" + ], + "engines": { + "node": ">=12" + }, + "files": [ + "dist", + "src" + ], + "dependencies": {}, + "devDependencies": { + "react": ">=18", + "react-dom": ">=18", + "@tanstack/react-router": "workspace:*", + "@tanstack/react-query": ">=5.49.2" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "@tanstack/react-router": ">=1.43.2", + "@tanstack/react-query": ">=5.49.2" + } +} diff --git a/packages/react-router-with-query/src/index.tsx b/packages/react-router-with-query/src/index.tsx new file mode 100644 index 0000000000..7b1f118ed2 --- /dev/null +++ b/packages/react-router-with-query/src/index.tsx @@ -0,0 +1,122 @@ +import { + QueryClientProvider, + dehydrate, + hashKey, + hydrate, +} from '@tanstack/react-query' +import { type AnyRouterWithContext } from '@tanstack/react-router' +import { Fragment } from 'react' +import type { + QueryClient, + QueryObserverResult, + UseQueryOptions, +} from '@tanstack/react-query' + +export function routerWithQueryClient< + T extends AnyRouterWithContext<{ + queryClient: QueryClient + }>, +>(router: T, queryClient: QueryClient): T { + const seenQueryKeys = new Set() + + const ogClientOptions = queryClient.getDefaultOptions() + + queryClient.setDefaultOptions({ + ...ogClientOptions, + queries: { + ...ogClientOptions.queries, + _experimental_beforeQuery: (options: UseQueryOptions) => { + // Call the original beforeQuery + ;(ogClientOptions.queries as any)?._experimental_beforeQuery?.(options) + + // On the server, check if we've already seen the query before + if (router.isServer) { + if (seenQueryKeys.has(hashKey(options.queryKey))) { + return + } + + seenQueryKeys.add(hashKey(options.queryKey)) + + // If we haven't seen the query and we have data for it, + // That means it's going to get dehydrated with critical + // data, so we can skip the injection + if (queryClient.getQueryData(options.queryKey) !== undefined) { + ;(options as any).__skipInjection = true + return + } + } else { + // On the client, pick up the deferred data from the stream + const dehydratedClient = router.getStreamedValue( + '__QueryClient__' + hashKey(options.queryKey), + ) + + // If we have data, hydrate it into the query client + if (dehydratedClient && !dehydratedClient.hydrated) { + dehydratedClient.hydrated = true + hydrate(queryClient, dehydratedClient) + } + } + }, + _experimental_afterQuery: ( + options: UseQueryOptions, + _result: QueryObserverResult, + ) => { + // Call the original afterQuery + ;(ogClientOptions.queries as any)?._experimental_afterQuery?.( + options, + _result, + ) + + // On the server (if we're not skipping injection) + // send down the dehydrated query + if ( + router.isServer && + !(options as any).__skipInjection && + queryClient.getQueryData(options.queryKey) !== undefined + ) { + router.streamValue( + '__QueryClient__' + hashKey(options.queryKey), + dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + hashKey(query.queryKey) === hashKey(options.queryKey), + }), + ) + } + }, + } as any, + }) + + const ogOptions = router.options + router.options = { + ...router.options, + dehydrate: () => { + return { + ...ogOptions.dehydrate?.(), + // When critical data is dehydrated, we also dehydrate the query client + dehydratedQueryClient: dehydrate(queryClient), + } + }, + hydrate: (dehydrated: any) => { + ogOptions.hydrate?.(dehydrated) + // On the client, hydrate the query client with the dehydrated data + hydrate(queryClient, dehydrated.dehydratedQueryClient) + }, + context: { + ...ogOptions.context, + // Pass the query client to the context, so we can access it in loaders + queryClient, + }, + // Wrap the app in a QueryClientProvider + Wrap: ({ children }) => { + const OGWrap = ogOptions.Wrap || Fragment + return ( + + {children} + + ) + }, + } + + return router +} diff --git a/packages/react-router-with-query/tsconfig.json b/packages/react-router-with-query/tsconfig.json new file mode 100644 index 0000000000..1599b212c0 --- /dev/null +++ b/packages/react-router-with-query/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/packages/react-router-with-query/vite.config.ts b/packages/react-router-with-query/vite.config.ts new file mode 100644 index 0000000000..64ae150c80 --- /dev/null +++ b/packages/react-router-with-query/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import react from '@vitejs/plugin-react' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [react()], + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: './src/index.tsx', + srcDir: './src', + }), +) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index d2a876ebc5..d6cf02af99 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -161,6 +161,7 @@ export { type TrimPathLeft, type TrimPathRight, type RootRouteOptions, + type AnyRouteWithContext, } from './route' export { type ParseRoute, @@ -201,6 +202,7 @@ export { type RouterEvents, type RouterEvent, type RouterListener, + type AnyRouterWithContext, } from './router' export { RouterProvider, diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index e47c5bbed8..ea6eb8bc0b 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -429,6 +429,28 @@ export interface AnyRoute any > {} +export type AnyRouteWithContext = Route< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + TContext, + any, + any, + any, + any +> + export type ResolveAllParamsFromParent< TParentRoute extends AnyRoute, TParams, diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 749ac58fe5..c81a2d3707 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -36,6 +36,7 @@ import type { import type { AnyContext, AnyRoute, + AnyRouteWithContext, AnySearchSchema, ErrorRouteComponent, LoaderFnContext, @@ -83,7 +84,13 @@ declare global { interface Window { __TSR__?: { matches: Array - streamedValues: Record + streamedValues: Record< + string, + { + value: any + parsed: any + } + > cleanScripts: () => void dehydrated?: any } @@ -96,6 +103,12 @@ export interface Register { } export type AnyRouter = Router +export type AnyRouterWithContext = Router< + AnyRouteWithContext, + any, + any, + any +> export type RegisteredRouter = Register extends { router: infer TRouter extends AnyRouter @@ -565,7 +578,6 @@ export class Router< ...options, stringifySearch: options.stringifySearch ?? defaultStringifySearch, parseSearch: options.parseSearch ?? defaultParseSearch, - transformer: options.transformer ?? JSON, }) if (typeof document !== 'undefined') { @@ -2344,19 +2356,19 @@ export class Router< } } - hydrate = async (__do_not_use_server_ctx?: string) => { - let _ctx = __do_not_use_server_ctx + hydrate = async () => { // Client hydrates from window + let ctx: HydrationCtx | undefined + if (typeof document !== 'undefined') { - _ctx = window.__TSR__?.dehydrated + ctx = this.options.transformer.parse(window.__TSR__?.dehydrated) as any } invariant( - _ctx, + ctx, 'Expected to find a dehydrated data on window.__TSR__.dehydrated... but we did not. Please file an issue!', ) - const ctx = this.options.transformer.parse(_ctx) as HydrationCtx this.dehydratedData = ctx.payload as any this.options.hydrate?.(ctx.payload as any) const dehydratedState = ctx.router.state @@ -2397,11 +2409,21 @@ export class Router< return undefined } - return window.__TSR__?.streamedValues[key] + const streamedValue = window.__TSR__?.streamedValues[key] + + if (!streamedValue) { + return + } + + if (!streamedValue.parsed) { + streamedValue.parsed = this.options.transformer.parse(streamedValue.value) + } + + return streamedValue.parsed } streamValue = (key: string, value: any) => { - const children = `window.__TSR__.streamedValues['${key}'] = ${this.serializer?.(value)}` + const children = `window.__TSR__.streamedValues['${key}'] = { value: ${this.serializer?.(this.options.transformer.stringify(value))}}` this.injectedHtml.push( `