diff --git a/docs/router/framework/react/api/router/RouterOptionsType.md b/docs/router/framework/react/api/router/RouterOptionsType.md index 2bd2ecea9a..c6ddbe9def 100644 --- a/docs/router/framework/react/api/router/RouterOptionsType.md +++ b/docs/router/framework/react/api/router/RouterOptionsType.md @@ -260,22 +260,6 @@ const router = createRouter({ - Optional - A route that will be used as the default not found route for every branch of the route tree. This can be overridden on a per-branch basis by providing a not found route to the `NotFoundRoute` option on the root route of the branch. -### `errorSerializer` property - -- Type: [`RouterErrorSerializer`] -- Optional -- The serializer object that will be used to determine how errors are serialized and deserialized between the server and the client. - -#### `errorSerializer.serialize` method - -- Type: `(err: unknown) => TSerializedError` -- This method is called to define how errors are serialized when they are stored in the router's dehydrated state. - -#### `errorSerializer.deserialize` method - -- Type: `(err: TSerializedError) => unknown` -- This method is called to define how errors are deserialized from the router's dehydrated state. - ### `trailingSlash` property - Type: `'always' | 'never' | 'preserve'` diff --git a/docs/router/framework/react/guide/external-data-loading.md b/docs/router/framework/react/guide/external-data-loading.md index 71a31be365..324b2583dc 100644 --- a/docs/router/framework/react/guide/external-data-loading.md +++ b/docs/router/framework/react/guide/external-data-loading.md @@ -141,7 +141,7 @@ Tools that are able can integrate with TanStack Router's convenient Dehydration/ **For critical data needed for the first render/paint**, TanStack Router supports **`dehydrate` and `hydrate`** options when configuring the `Router`. These callbacks are functions that are automatically called on the server and client when the router dehydrates and hydrates normally and allow you to augment the dehydrated data with your own data. -The `dehydrate` function can return any serializable JSON data which will get merged and injected into the dehydrated payload that is sent to the client. This payload is delivered via the `DehydrateRouter` component which, when rendered, provides the data back to you in the `hydrate` function on the client. +The `dehydrate` function can return any serializable JSON data which will get merged and injected into the dehydrated payload that is sent to the client. For example, let's dehydrate and hydrate a TanStack Query `QueryClient` so that our data we fetched on the server will be available for hydration on the client. diff --git a/packages/react-router-with-query/src/index.tsx b/packages/react-router-with-query/src/index.tsx index 7858816513..af0d3eb951 100644 --- a/packages/react-router-with-query/src/index.tsx +++ b/packages/react-router-with-query/src/index.tsx @@ -1,16 +1,15 @@ import { Fragment } from 'react' import { QueryClientProvider, - dehydrate, - hashKey, - hydrate, + dehydrate as queryDehydrate, + hydrate as queryHydrate, } from '@tanstack/react-query' import { isRedirect } from '@tanstack/router-core' +import '@tanstack/router-core/ssr/client' import type { AnyRouter } from '@tanstack/react-router' import type { QueryClient, - QueryObserverResult, - UseQueryOptions, + DehydratedState as QueryDehydratedState, } from '@tanstack/react-query' type AdditionalOptions = { @@ -24,6 +23,10 @@ type AdditionalOptions = { handleRedirects?: boolean } +type DehydratedRouterQueryState = { + dehydratedQueryClient: QueryDehydratedState + queryStream: ReadableStream +} export type ValidateRouter = NonNullable extends { queryClient: QueryClient @@ -36,129 +39,10 @@ export function routerWithQueryClient( queryClient: QueryClient, additionalOpts?: AdditionalOptions, ): TRouter { - const seenQueryKeys = new Set() - const streamedQueryKeys = 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) - - const hash = options.queryKeyHashFn || hashKey - // On the server, check if we've already seen the query before - if (router.isServer) { - if (seenQueryKeys.has(hash(options.queryKey))) { - return - } - - seenQueryKeys.add(hash(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.clientSsr!.getStreamedValue( - '__QueryClient__' + hash(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 - const hash = options.queryKeyHashFn || hashKey - if ( - router.isServer && - !(options as any).__skipInjection && - queryClient.getQueryData(options.queryKey) !== undefined && - !streamedQueryKeys.has(hash(options.queryKey)) - ) { - streamedQueryKeys.add(hash(options.queryKey)) - - router.serverSsr!.streamValue( - '__QueryClient__' + hash(options.queryKey), - dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - hash(query.queryKey) === hash(options.queryKey), - }), - ) - } - - // Call the original afterQuery - ;(ogClientOptions.queries as any)?._experimental_afterQuery?.( - options, - _result, - ) - }, - } as any, - }) - - if (additionalOpts?.handleRedirects ?? true) { - const ogMutationCacheConfig = queryClient.getMutationCache().config - queryClient.getMutationCache().config = { - ...ogMutationCacheConfig, - onError: (error, _variables, _context, _mutation) => { - if (isRedirect(error)) { - error.options._fromLocation = router.state.location - return router.navigate(router.resolveRedirect(error).options) - } - - return ogMutationCacheConfig.onError?.( - error, - _variables, - _context, - _mutation, - ) - }, - } - - const ogQueryCacheConfig = queryClient.getQueryCache().config - queryClient.getQueryCache().config = { - ...ogQueryCacheConfig, - onError: (error, _query) => { - if (isRedirect(error)) { - error.options._fromLocation = router.state.location - return router.navigate(router.resolveRedirect(error).options) - } - - return ogQueryCacheConfig.onError?.(error, _query) - }, - } - } - 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 @@ -178,5 +62,148 @@ export function routerWithQueryClient( }, } + if (router.isServer) { + const queryStream = createPushableStream() + + router.options.dehydrate = + async (): Promise => { + const ogDehydrated = await ogOptions.dehydrate?.() + const dehydratedQueryClient = queryDehydrate(queryClient) + + router.serverSsr!.onRenderFinished(() => queryStream.close()) + + const dehydratedRouter = { + ...ogDehydrated, + // When critical data is dehydrated, we also dehydrate the query client + dehydratedQueryClient, + // prepare the stream for queries coming up during rendering + queryStream: queryStream.stream, + } + + return dehydratedRouter + } + + const ogClientOptions = queryClient.getDefaultOptions() + queryClient.setDefaultOptions({ + ...ogClientOptions, + dehydrate: { + shouldDehydrateQuery: () => true, + ...ogClientOptions.dehydrate, + }, + }) + + queryClient.getQueryCache().subscribe((event) => { + if (event.type === 'added') { + // before rendering starts, we do not stream individual queries + // instead we dehydrate the entire query client in router's dehydrate() + if (!router.serverSsr!.isDehydrated()) { + return + } + if (queryStream.isClosed()) { + console.warn( + `tried to stream query ${event.query.queryHash} after stream was already closed`, + ) + return + } + queryStream.enqueue( + queryDehydrate(queryClient, { + shouldDehydrateQuery: (query) => { + if (query.queryHash === event.query.queryHash) { + return ( + ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? + true + ) + } + return false + }, + }), + ) + } + }) + // on the client + } else { + router.options.hydrate = async (dehydrated: DehydratedRouterQueryState) => { + await ogOptions.hydrate?.(dehydrated) + // On the client, hydrate the query client with the dehydrated data + queryHydrate(queryClient, dehydrated.dehydratedQueryClient) + + const reader = dehydrated.queryStream.getReader() + reader + .read() + .then(async function handle({ done, value }) { + queryHydrate(queryClient, value) + if (done) { + return + } + const result = await reader.read() + return handle(result) + }) + .catch((err) => { + console.error('Error reading query stream:', err) + }) + } + if (additionalOpts?.handleRedirects ?? true) { + const ogMutationCacheConfig = queryClient.getMutationCache().config + queryClient.getMutationCache().config = { + ...ogMutationCacheConfig, + onError: (error, _variables, _context, _mutation) => { + if (isRedirect(error)) { + error.options._fromLocation = router.state.location + return router.navigate(router.resolveRedirect(error).options) + } + + return ogMutationCacheConfig.onError?.( + error, + _variables, + _context, + _mutation, + ) + }, + } + + const ogQueryCacheConfig = queryClient.getQueryCache().config + queryClient.getQueryCache().config = { + ...ogQueryCacheConfig, + onError: (error, _query) => { + if (isRedirect(error)) { + error.options._fromLocation = router.state.location + return router.navigate(router.resolveRedirect(error).options) + } + + return ogQueryCacheConfig.onError?.(error, _query) + }, + } + } + } + return router } + +type PushableStream = { + stream: ReadableStream + enqueue: (chunk: unknown) => void + close: () => void + isClosed: () => boolean + error: (err: unknown) => void +} + +function createPushableStream(): PushableStream { + let controllerRef: ReadableStreamDefaultController + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller + }, + }) + let _isClosed = false + + return { + stream, + enqueue: (chunk) => controllerRef.enqueue(chunk), + close: () => { + controllerRef.close() + _isClosed = true + }, + isClosed: () => _isClosed, + error: (err: unknown) => controllerRef.error(err), + } +} diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 5fde58695b..5b34093f09 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -48,7 +48,7 @@ export function Matches() { // Do not render a root Suspense during SSR or hydrating from SSR const ResolvedSuspense = - router.isServer || (typeof document !== 'undefined' && router.clientSsr) + router.isServer || (typeof document !== 'undefined' && router.ssr) ? SafeFragment : React.Suspense diff --git a/packages/react-router/src/ScriptOnce.tsx b/packages/react-router/src/ScriptOnce.tsx index fbee641bbd..19aa0f3696 100644 --- a/packages/react-router/src/ScriptOnce.tsx +++ b/packages/react-router/src/ScriptOnce.tsx @@ -14,7 +14,7 @@ export function ScriptOnce({ return ( ` + return `` }) }, - streamValue: (key, value) => { - warning( - !router.serverSsr!.streamedKeys.has(key), - 'Key has already been streamed: ' + key, - ) + dehydrate: async () => { + invariant(!_dehydrated, 'router is already dehydrated!') + const matches = router.state.matches.map(dehydrateMatch) - router.serverSsr!.streamedKeys.add(key) - router.serverSsr!.injectScript( - () => - `__TSR_SSR__.streamedValues['${key}'] = { value: ${jsesc( - router.ssr!.serializer.stringify(value), - { - isScriptContext: true, - wrap: true, - json: true, - }, - )}}`, - ) - }, - onMatchSettled, - } - - router.serverSsr.injectScript(() => minifiedTsrBootStrapScript, { - logScript: false, - }) -} - -export function dehydrateRouter(router: AnyRouter) { - const dehydratedRouter: DehydratedRouter = { - manifest: router.ssr!.manifest, - dehydratedData: router.options.dehydrate?.(), - lastMatchId: - router.state.matches[router.state.matches.length - 1]?.id || '', - } - - router.serverSsr!.injectScript( - () => - `__TSR_SSR__.dehydrated = ${jsesc( - router.ssr!.serializer.stringify(dehydratedRouter), - { - isScriptContext: true, - wrap: true, - json: true, - }, - )}`, - ) -} - -export function extractAsyncLoaderData( - loaderData: any, - ctx: { - match: AnyRouteMatch - router: AnyRouter - }, -) { - const extracted: Array = [] - - const replaced = replaceBy(loaderData, (value, path) => { - // If it's a stream, we need to tee it so we can read it multiple times - if (value instanceof ReadableStream) { - const [copy1, copy2] = value.tee() - const entry: ServerExtractedStream = { - type: 'stream', - path, - id: extracted.length, - matchIndex: ctx.match.index, - stream: copy2, + const dehydratedRouter: DehydratedRouter = { + manifest: router.ssr!.manifest, + matches, } - - extracted.push(entry) - return copy1 - } else if (value instanceof Promise) { - const deferredPromise = defer(value) - const entry: ServerExtractedPromise = { - type: 'promise', - path, - id: extracted.length, - matchIndex: ctx.match.index, - promise: deferredPromise, + const lastMatchId = + router.state.matches[router.state.matches.length - 1]?.id + if (lastMatchId) { + dehydratedRouter.lastMatchId = lastMatchId } - extracted.push(entry) - } - - return value - }) - - return { replaced, extracted } -} - -export function onMatchSettled(opts: { - router: AnyRouter - match: AnyRouteMatch -}) { - const { router, match } = opts - - let extracted: Array | undefined = undefined - let serializedLoaderData: any = undefined - if (match.loaderData !== undefined) { - const result = extractAsyncLoaderData(match.loaderData, { - router, - match, - }) - match.loaderData = result.replaced - extracted = result.extracted - serializedLoaderData = extracted.reduce( - (acc: any, entry: ServerExtractedEntry) => { - return deepImmutableSetByPath(acc, ['temp', ...entry.path], undefined) - }, - { temp: result.replaced }, - ).temp - } - - const initCode = `__TSR_SSR__.initMatch(${jsesc( - { - id: match.id, - __beforeLoadContext: router.ssr!.serializer.stringify( - match.__beforeLoadContext, - ), - loaderData: router.ssr!.serializer.stringify(serializedLoaderData), - error: router.ssr!.serializer.stringify(match.error), - extracted: extracted?.map((entry) => pick(entry, ['type', 'path'])), - updatedAt: match.updatedAt, - status: match.status, - ssr: match.ssr, - } satisfies SsrMatch, - { - isScriptContext: true, - wrap: true, - json: true, - }, - )})` - - router.serverSsr!.injectScript(() => initCode) - - if (extracted) { - extracted.forEach((entry) => { - if (entry.type === 'promise') return injectPromise(entry) - return injectStream(entry) - }) - } - - function injectPromise(entry: ServerExtractedPromise) { - router.serverSsr!.injectScript(async () => { - await entry.promise - - return `__TSR_SSR__.resolvePromise(${jsesc( - { - matchId: match.id, - id: entry.id, - promiseState: entry.promise[TSR_DEFERRED_PROMISE], - } satisfies ResolvePromiseState, - { - isScriptContext: true, - wrap: true, - json: true, + dehydratedRouter.dehydratedData = await router.options.dehydrate?.() + _dehydrated = true + + const p = createControlledPromise() + crossSerializeStream(dehydratedRouter, { + refs: serializationRefs, + // TODO make plugins configurable + plugins: [ReadableStreamPlugin, ShallowErrorPlugin], + onSerialize: (data, initial) => { + const serialized = initial ? `${GLOBAL_TSR}["router"]=` + data : data + router.serverSsr!.injectScript(() => serialized) }, - )})` - }) - } - - function injectStream(entry: ServerExtractedStream) { - // Inject a promise that resolves when the stream is done - // We do this to keep the stream open until we're done - router.serverSsr!.injectHtml(async () => { - // - try { - const reader = entry.stream.getReader() - let chunk: ReadableStreamReadResult | null = null - while (!(chunk = await reader.read()).done) { - if (chunk.value) { - const code = `__TSR_SSR__.injectChunk(${jsesc( - { - matchId: match.id, - id: entry.id, - chunk: chunk.value, - }, - { - isScriptContext: true, - wrap: true, - json: true, - }, - )})` - - router.serverSsr!.injectScript(() => code) - } - } - - router.serverSsr!.injectScript( - () => - `__TSR_SSR__.closeStream(${jsesc( - { - matchId: match.id, - id: entry.id, - }, - { - isScriptContext: true, - wrap: true, - json: true, - }, - )})`, - ) - } catch (err) { - console.error('stream read error', err) - } - - return '' - }) - } -} - -function deepImmutableSetByPath(obj: T, path: Array, value: any): T { - // immutable set by path retaining array and object references - if (path.length === 0) { - return value - } - - const [key, ...rest] = path - - if (Array.isArray(obj)) { - return obj.map((item, i) => { - if (i === Number(key)) { - return deepImmutableSetByPath(item, rest, value) - } - return item - }) as T - } - - if (isPlainObject(obj)) { - return { - ...obj, - [key!]: deepImmutableSetByPath((obj as any)[key!], rest, value), - } - } - - return obj -} - -export function replaceBy( - obj: T, - cb: (value: any, path: Array) => any, - path: Array = [], -): T { - if (isPlainArray(obj)) { - return obj.map((value, i) => replaceBy(value, cb, [...path, `${i}`])) as any - } - - if (isPlainObject(obj)) { - // Do not allow objects with illegal - const newObj: any = {} - - for (const key in obj) { - newObj[key] = replaceBy(obj[key], cb, [...path, key]) - } - - return newObj - } - - // // Detect classes, functions, and other non-serializable objects - // // and return undefined. Exclude some known types that are serializable - // if ( - // typeof obj === 'function' || - // (typeof obj === 'object' && - // ![Object, Promise, ReadableStream].includes((obj as any)?.constructor)) - // ) { - // console.info(obj) - // warning(false, `Non-serializable value ☝️ found at ${path.join('.')}`) - // return undefined as any - // } - - const newObj = cb(obj, path) - - if (newObj !== obj) { - return newObj + scopeId: SCOPE_ID, + onDone: () => p.resolve(''), + onError: (err) => p.reject(err), + }) + // make sure the stream is kept open until the promise is resolved + router.serverSsr!.injectHtml(() => p) + }, + isDehydrated() { + return _dehydrated + }, + onRenderFinished: (listener) => listeners.push(listener), + setRenderFinished: () => { + listeners.forEach((l) => l()) + }, } - - return obj } diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 884058fc8e..54c0197bb5 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -242,6 +242,7 @@ export function transformStreamWithRouter( onEnd: () => { // Mark the app as done rendering isAppRendering = false + router.serverSsr!.setRenderFinished() // If there are no pending promises, resolve the injectedHtmlDonePromise if (processingCount === 0) { diff --git a/packages/router-core/src/ssr/tsrScript.ts b/packages/router-core/src/ssr/tsrScript.ts index c84243868b..bd8c2a0e0f 100644 --- a/packages/router-core/src/ssr/tsrScript.ts +++ b/packages/router-core/src/ssr/tsrScript.ts @@ -1,91 +1,7 @@ -import type { ControllablePromise } from '../router' -import type { TsrSsrGlobal } from './ssr-client' - -const __TSR_SSR__: TsrSsrGlobal = { - matches: [], - streamedValues: {}, - initMatch: (match) => { - __TSR_SSR__.matches.push(match) - - match.extracted?.forEach((ex) => { - if (ex.type === 'stream') { - let controller - ex.value = new ReadableStream({ - start(c) { - controller = { - enqueue: (chunk: unknown) => { - try { - c.enqueue(chunk) - } catch {} - }, - close: () => { - try { - c.close() - } catch {} - }, - } - }, - }) - ex.value.controller = controller - } else { - let resolve: ControllablePromise['reject'] | undefined - let reject: ControllablePromise['reject'] | undefined - - ex.value = new Promise((_resolve, _reject) => { - reject = _reject - resolve = _resolve - }) as ControllablePromise - ex.value.reject = reject! - ex.value.resolve = resolve! - } - }) - - return true - }, - resolvePromise: ({ matchId, id, promiseState }) => { - const match = __TSR_SSR__.matches.find((m) => m.id === matchId) - if (match) { - const ex = match.extracted?.[id] - if ( - ex && - ex.type === 'promise' && - ex.value && - promiseState.status === 'success' - ) { - ex.value.resolve(promiseState.data) - return true - } - } - return false - }, - injectChunk: ({ matchId, id, chunk }) => { - const match = __TSR_SSR__.matches.find((m) => m.id === matchId) - - if (match) { - const ex = match.extracted?.[id] - if (ex && ex.type === 'stream' && ex.value?.controller) { - ex.value.controller.enqueue(new TextEncoder().encode(chunk.toString())) - return true - } - } - return false - }, - closeStream: ({ matchId, id }) => { - const match = __TSR_SSR__.matches.find((m) => m.id === matchId) - if (match) { - const ex = match.extracted?.[id] - if (ex && ex.type === 'stream' && ex.value?.controller) { - ex.value.controller.close() - return true - } - } - return false - }, - cleanScripts: () => { - document.querySelectorAll('.tsr-once').forEach((el) => { - el.remove() +self.$_TSR = { + c: () => { + document.querySelectorAll('.\\$tsr').forEach((o) => { + o.remove() }) }, } - -window.__TSR_SSR__ = __TSR_SSR__ diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index c5d62a2611..0735f16078 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -44,7 +44,7 @@ export function Matches() { // Do not render a root Suspense during SSR or hydrating from SSR const ResolvedSuspense = - router.isServer || (typeof document !== 'undefined' && router.clientSsr) + router.isServer || (typeof document !== 'undefined' && router.ssr) ? SafeFragment : Solid.Suspense diff --git a/packages/solid-router/src/ScriptOnce.tsx b/packages/solid-router/src/ScriptOnce.tsx index e85cb9c060..74d811b249 100644 --- a/packages/solid-router/src/ScriptOnce.tsx +++ b/packages/solid-router/src/ScriptOnce.tsx @@ -14,14 +14,14 @@ export function ScriptOnce({ return (