From 24a12ee054c1b22ac43ee4b15dfaecf6840e502c Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Mon, 7 Jul 2025 22:22:26 +0200 Subject: [PATCH 1/6] feat: use seroval for SSR also stream all queries in react-router-with-query --- .../react/api/router/RouterOptionsType.md | 16 - .../react-router-with-query/src/index.tsx | 171 ++++---- packages/react-router/src/ScriptOnce.tsx | 2 +- packages/react-router/src/index.tsx | 15 - packages/react-start-client/src/index.tsx | 10 +- .../src/serializer.ts | 2 +- packages/router-core/package.json | 2 + packages/router-core/src/Matches.ts | 2 +- packages/router-core/src/index.ts | 18 - packages/router-core/src/router.ts | 90 +--- packages/router-core/src/ssr/client.ts | 12 +- .../src/ssr/createRequestHandler.ts | 2 +- .../router-core/src/ssr/serializeString.ts | 52 +++ packages/router-core/src/ssr/server.ts | 15 +- packages/router-core/src/ssr/ssr-client.ts | 161 ++----- packages/router-core/src/ssr/ssr-server.ts | 402 +++++------------- .../src/ssr/transformStreamWithRouter.ts | 1 + packages/router-core/src/ssr/tsrScript.ts | 93 +--- packages/solid-router/src/ScriptOnce.tsx | 2 +- packages/solid-router/src/index.tsx | 13 - packages/solid-start-client/src/index.tsx | 9 - .../start-client-core/src/createMiddleware.ts | 2 +- .../start-client-core/src/createServerFn.ts | 19 +- packages/start-client-core/src/index.tsx | 28 +- .../src/serializer.ts | 25 +- pnpm-lock.yaml | 72 ++-- 26 files changed, 407 insertions(+), 829 deletions(-) rename packages/{react-router => react-start-client}/src/serializer.ts (72%) create mode 100644 packages/router-core/src/ssr/serializeString.ts rename packages/{router-core => start-client-core}/src/serializer.ts (88%) 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/packages/react-router-with-query/src/index.tsx b/packages/react-router-with-query/src/index.tsx index 7858816513..0865e33f69 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,79 +39,19 @@ export function routerWithQueryClient( queryClient: QueryClient, additionalOpts?: AdditionalOptions, ): TRouter { - const seenQueryKeys = new Set() - const streamedQueryKeys = new Set() + let queryStream: PushableStream + + if (router.isServer) { + queryStream = createPushableStream() + } 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, + dehydrate: { + shouldDehydrateQuery: () => true, + ...ogClientOptions.dehydrate, + }, }) if (additionalOpts?.handleRedirects ?? true) { @@ -142,22 +85,62 @@ export function routerWithQueryClient( return ogQueryCacheConfig.onError?.(error, _query) }, } + if (router.isServer) { + queryClient.getQueryCache().subscribe((event) => { + if (event.type === 'added') { + if (!router.serverSsr!.isDehydrated()) { + return + } + if (queryStream!.isClosed) { + console.warn( + `tried to stream query ${event.query.queryHash} after stream was already closed`, + ) + } + queryStream!.enqueue( + queryDehydrate(queryClient, { + shouldDehydrateQuery: (query) => { + if (query.queryHash === event.query.queryHash) { + return ( + ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? + true + ) + } + return false + }, + }), + ) + } + }) + } } const ogOptions = router.options router.options = { ...router.options, - dehydrate: () => { + dehydrate: async (): Promise => { + router.serverSsr!.onRenderFinished(() => queryStream.close()) + const ogDehydrated = await ogOptions.dehydrate?.() return { - ...ogOptions.dehydrate?.(), + ...ogDehydrated, // When critical data is dehydrated, we also dehydrate the query client - dehydratedQueryClient: dehydrate(queryClient), + dehydratedQueryClient: queryDehydrate(queryClient), + // prepare the stream for queries coming up during rendering + queryStream: queryStream.stream, } }, - hydrate: (dehydrated: any) => { - ogOptions.hydrate?.(dehydrated) + hydrate: async (dehydrated: DehydratedRouterQueryState) => { + console.log('dehydrated', dehydrated) + await ogOptions.hydrate?.(dehydrated) // On the client, hydrate the query client with the dehydrated data - hydrate(queryClient, dehydrated.dehydratedQueryClient) + queryHydrate(queryClient, dehydrated.dehydratedQueryClient) + const reader = dehydrated.queryStream.getReader() + reader.read().then(function handle({ done, value }): Promise { + queryHydrate(queryClient, value) + if (done) { + return Promise.resolve() + } + return reader.read().then(handle) + }) }, context: { ...ogOptions.context, @@ -180,3 +163,31 @@ export function routerWithQueryClient( return router } + +type PushableStream = { + stream: ReadableStream + enqueue: (chunk: unknown) => void + close: () => void + isClosed: boolean + error: (err: unknown) => void +} +function createPushableStream(): PushableStream { + let controllerRef: ReadableStreamDefaultController | undefined + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller + }, + }) + let isClosed = false + + return { + stream, + enqueue: (chunk) => controllerRef?.enqueue(chunk), + close: () => { + controllerRef?.close() + isClosed = true + }, + isClosed, + error: (err: unknown) => controllerRef?.error(err), + } +} diff --git a/packages/react-router/src/ScriptOnce.tsx b/packages/react-router/src/ScriptOnce.tsx index fbee641bbd..1a301f8357 100644 --- a/packages/react-router/src/ScriptOnce.tsx +++ b/packages/react-router/src/ScriptOnce.tsx @@ -22,7 +22,7 @@ export function ScriptOnce({ ? `console.info(\`Injected From Server: ${jsesc(children.toString(), { quotes: 'backtick' })}\`)` : '', - 'if (typeof __TSR_SSR__ !== "undefined") __TSR_SSR__.cleanScripts()', + 'if (typeof $_TSR !== "undefined") $_TSR.c()', ] .filter(Boolean) .join('\n'), diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index f092c0df13..2d32671951 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -39,13 +39,6 @@ export { export type { AnyRoute, - TsrSerializer, - StartSerializer, - Serializable, - SerializerParse, - SerializerParseBy, - SerializerStringify, - SerializerStringifyBy, DeferredPromiseState, DeferredPromise, ParsedLocation, @@ -93,10 +86,6 @@ export type { SearchSerializer, SearchParser, TrailingSlashOption, - ExtractedEntry, - ExtractedStream, - ExtractedPromise, - StreamState, Manifest, RouterManagedTag, ControlledPromise, @@ -125,12 +114,9 @@ export type { FileRoutesByPath, RouteById, RootRouteOptions, - SerializerExtensions, CreateFileRoute, } from '@tanstack/router-core' -export type * from './serializer' - export { createHistory, createBrowserHistory, @@ -228,7 +214,6 @@ export type { ControllablePromise, InjectedHtmlEntry, RouterOptions, - RouterErrorSerializer, RouterState, ListenerFn, BuildNextOptions, diff --git a/packages/react-start-client/src/index.tsx b/packages/react-start-client/src/index.tsx index cdce7ec8c8..d728c17e0f 100644 --- a/packages/react-start-client/src/index.tsx +++ b/packages/react-start-client/src/index.tsx @@ -12,19 +12,10 @@ export { } from '@tanstack/start-client-core' export { type DehydratedRouter, - type ClientExtractedBaseEntry, - type StartSsrGlobal, - type ClientExtractedEntry, - type SsrMatch, - type ClientExtractedPromise, - type ClientExtractedStream, - type ResolvePromiseState, type IsomorphicFn, type ServerOnlyFn, type ClientOnlyFn, type IsomorphicFnBase, - type ServerFn as FetchFn, - type ServerFnCtx as FetchFnCtx, type CompiledFetcherFnOptions, type CompiledFetcherFn, type Fetcher, @@ -68,3 +59,4 @@ export { Scripts } from './Scripts' export { StartClient } from './StartClient' export { renderRsc } from './renderRSC' export { useServerFn } from './useServerFn' +export * from './serializer' diff --git a/packages/react-router/src/serializer.ts b/packages/react-start-client/src/serializer.ts similarity index 72% rename from packages/react-router/src/serializer.ts rename to packages/react-start-client/src/serializer.ts index 9a1743c18a..4ae5650859 100644 --- a/packages/react-router/src/serializer.ts +++ b/packages/react-start-client/src/serializer.ts @@ -1,6 +1,6 @@ import type * as React from 'react' -declare module '@tanstack/router-core' { +declare module '@tanstack/start-client-core' { export interface SerializerExtensions { ReadableStream: React.JSX.Element } diff --git a/packages/router-core/package.json b/packages/router-core/package.json index d51a105e0a..4df0cc8317 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -83,6 +83,8 @@ "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "jsesc": "^3.1.0", + "seroval": "^1.3.2", + "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 970e8fcc63..0daec3d4eb 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -141,7 +141,7 @@ export interface RouteMatch< loaderPromise?: ControlledPromise loaderData?: TLoaderData __routeContext: Record - __beforeLoadContext: Record + __beforeLoadContext?: Record context: TAllContext search: TFullSearchSchema _strictSearch: TFullSearchSchema diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 8c1723d508..2a96db63c1 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -65,17 +65,6 @@ export type { CreateLazyFileRoute, } from './fileRoute' -export type { - TsrSerializer, - StartSerializer, - Serializable, - SerializerParse, - SerializerParseBy, - SerializerStringify, - SerializerStringifyBy, - SerializerExtensions, -} from './serializer' - export type { ParsedLocation } from './location' export type { Manifest, RouterManagedTag } from './manifest' export { isMatch } from './Matches' @@ -216,11 +205,6 @@ export { } from './router' export type { ViewTransitionOptions, - ExtractedBaseEntry, - ExtractedStream, - ExtractedPromise, - ExtractedEntry, - StreamState, TrailingSlashOption, Register, AnyRouter, @@ -245,7 +229,6 @@ export type { InvalidateFn, ControllablePromise, InjectedHtmlEntry, - RouterErrorSerializer, EmitFn, LoadFn, GetMatchFn, @@ -256,7 +239,6 @@ export type { MatchRoutesFn, StartTransitionFn, LoadRouteChunkFn, - ServerSrr, ClearCacheFn, CreateRouterFn, ProcessRouteTreeResult, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 14f50088c8..0806b518f8 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -46,7 +46,6 @@ import type { Updater, } from './utils' import type { ParsedLocation } from './location' -import type { DeferredPromiseState } from './defer' import type { AnyContext, AnyRoute, @@ -78,7 +77,6 @@ import type { NavigateFn, } from './RouterProvider' import type { Manifest } from './manifest' -import type { TsrSerializer } from './serializer' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { NotFoundError } from './not-found' @@ -288,7 +286,7 @@ export interface RouterOptions< * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#dehydrate-method) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#critical-dehydrationhydration) */ - dehydrate?: () => TDehydrated + dehydrate?: () => Awaitable /** * A function that will be called when the router is hydrated. * @@ -451,7 +449,7 @@ type NavigationEventInfo = { hashChanged: boolean } -export type RouterEvents = { +export interface RouterEvents { onBeforeNavigate: { type: 'onBeforeNavigate' } & NavigationEventInfo @@ -467,10 +465,6 @@ export type RouterEvents = { onBeforeRouteMount: { type: 'onBeforeRouteMount' } & NavigationEventInfo - onInjectedHtml: { - type: 'onInjectedHtml' - promise: Promise - } onRendered: { type: 'onRendered' } & NavigationEventInfo @@ -485,6 +479,11 @@ export type RouterListener = { fn: ListenerFn } +export type SubscribeFn = ( + eventType: TType, + fn: ListenerFn, +) => () => void + export interface MatchRoutesOpts { preload?: boolean throwOnError?: boolean @@ -522,11 +521,6 @@ export type RouterConstructorOptions< > & RouterContextOptions -export interface RouterErrorSerializer { - serialize: (err: unknown) => TSerializedError - deserialize: (err: TSerializedError) => unknown -} - export type PreloadRouteFn< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -623,11 +617,6 @@ export type CommitLocationFn = ({ export type StartTransitionFn = (fn: () => void) => void -export type SubscribeFn = ( - eventType: TType, - fn: ListenerFn, -) => () => void - export interface MatchRoutesFn { ( pathname: string, @@ -657,7 +646,7 @@ export type ClearCacheFn = (opts?: { filter?: (d: MakeRouteMatchUnion) => boolean }) => void -export interface ServerSrr { +export interface ServerSsr { injectedHtml: Array injectHtml: (getHtml: () => string | Promise) => Promise injectScript: ( @@ -666,7 +655,8 @@ export interface ServerSrr { ) => Promise streamValue: (key: string, value: any) => void streamedKeys: Set - onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any + isDehydrated: () => boolean + onRenderFinished: (listener: () => void) => void } export type AnyRouterWithContext = RouterCore< @@ -709,29 +699,6 @@ export function defaultSerializeError(err: unknown) { data: err, } } -export interface ExtractedBaseEntry { - dataType: '__beforeLoadContext' | 'loaderData' - type: string - path: Array - id: number - matchIndex: number -} - -export interface ExtractedStream extends ExtractedBaseEntry { - type: 'stream' - streamState: StreamState -} - -export interface ExtractedPromise extends ExtractedBaseEntry { - type: 'promise' - promiseState: DeferredPromiseState -} - -export type ExtractedEntry = ExtractedStream | ExtractedPromise - -export type StreamState = { - promises: Array> -} export type TrailingSlashOption = 'always' | 'never' | 'preserve' @@ -837,7 +804,7 @@ export class RouterCore< }) if (typeof document !== 'undefined') { - ;(window as any).__TSR_ROUTER__ = this + self.__TSR_ROUTER__ = this } } @@ -1292,7 +1259,7 @@ export class RouterCore< error: undefined, paramsError: parseErrors[index], __routeContext: {}, - __beforeLoadContext: {}, + __beforeLoadContext: undefined, context: {}, abortController: new AbortController(), fetchCount: 0, @@ -2169,10 +2136,6 @@ export class RouterCore< this._handleNotFound(matches, err, { updateMatch, }) - this.serverSsr?.onMatchSettled({ - router: this, - match: this.getMatch(match.id)!, - }) throw err } } @@ -2430,8 +2393,7 @@ export class RouterCore< } const beforeLoadContext = - (await route.options.beforeLoad?.(beforeLoadFnContext)) ?? - {} + await route.options.beforeLoad?.(beforeLoadFnContext) if ( isRedirect(beforeLoadContext) || @@ -2524,10 +2486,6 @@ export class RouterCore< ...prev, ...head, })) - this.serverSsr?.onMatchSettled({ - router: this, - match: this.getMatch(matchId)!, - }) return this.getMatch(matchId)! } else { await potentialPendingMinPromise() @@ -2689,11 +2647,6 @@ export class RouterCore< ...head, })) } - - this.serverSsr?.onMatchSettled({ - router: this, - match: this.getMatch(matchId)!, - }) } catch (err) { const head = await executeHead() @@ -2754,10 +2707,6 @@ export class RouterCore< ...prev, ...head, })) - this.serverSsr?.onMatchSettled({ - router: this, - match: this.getMatch(matchId)!, - }) } } if (!loaderIsRunningAsync) { @@ -3057,20 +3006,9 @@ export class RouterCore< ssr?: { manifest: Manifest | undefined - serializer: TsrSerializer } - serverSsr?: { - injectedHtml: Array - injectHtml: (getHtml: () => string | Promise) => Promise - injectScript: ( - getScript: () => string | Promise, - opts?: { logScript?: boolean }, - ) => Promise - streamValue: (key: string, value: any) => void - streamedKeys: Set - onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any - } + serverSsr?: ServerSsr clientSsr?: { getStreamedValue: (key: string) => T | undefined diff --git a/packages/router-core/src/ssr/client.ts b/packages/router-core/src/ssr/client.ts index 0a92cf0c62..ab42633601 100644 --- a/packages/router-core/src/ssr/client.ts +++ b/packages/router-core/src/ssr/client.ts @@ -2,14 +2,4 @@ export { mergeHeaders, headersInitToObject } from './headers' export { json } from './json' export type { JsonResponse } from './json' export { hydrate } from './ssr-client' -export type { - DehydratedRouter, - ClientExtractedBaseEntry, - TsrSsrGlobal, - ClientExtractedEntry, - SsrMatch, - ClientExtractedPromise, - ClientExtractedStream, - ResolvePromiseState, -} from './ssr-client' -export { tsrSerializer } from '../serializer' +export * from './ssr-client' diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index c0142610e4..faa959fb57 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -39,7 +39,7 @@ export function createRequestHandler({ await router.load() - dehydrateRouter(router) + await dehydrateRouter(router) const responseHeaders = getRequestHeaders({ router, diff --git a/packages/router-core/src/ssr/serializeString.ts b/packages/router-core/src/ssr/serializeString.ts new file mode 100644 index 0000000000..f32b60e4d0 --- /dev/null +++ b/packages/router-core/src/ssr/serializeString.ts @@ -0,0 +1,52 @@ +// copied from https://github.com/lxsmnsyc/seroval/blob/c34445addb6daf2a6f5374d425f29763d1a4c41b/packages/seroval/src/core/string.ts + +export function serializeChar(str: string): string | undefined { + switch (str) { + case '"': + return '\\"' + case '\\': + return '\\\\' + case '\n': + return '\\n' + case '\r': + return '\\r' + case '\b': + return '\\b' + case '\t': + return '\\t' + case '\f': + return '\\f' + case '<': + return '\\x3C' + case '\u2028': + return '\\u2028' + case '\u2029': + return '\\u2029' + default: + return undefined + } +} + +// Written by https://github.com/DylanPiercey and is distributed under the MIT license. +// Creates a JavaScript double quoted string and escapes all characters +// not listed as DoubleStringCharacters on +// Also includes "<" to escape "" and "\" to avoid invalid escapes in the output. +// http://www.ecma-international.org/ecma-262/5.1/#sec-7.8.4 +export function serializeString(str: string): string { + let result = '' + let lastPos = 0 + let replacement: string | undefined + for (let i = 0, len = str.length; i < len; i++) { + replacement = serializeChar(str[i]!) + if (replacement) { + result += str.slice(lastPos, i) + replacement + lastPos = i + 1 + } + } + if (lastPos === 0) { + result = str + } else { + result += str.slice(lastPos) + } + return result +} diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index b7f5736e99..91eaecfc59 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -7,17 +7,4 @@ export { transformStreamWithRouter, transformReadableStreamWithRouter, } from './transformStreamWithRouter' -export { - attachRouterServerSsrUtils, - dehydrateRouter, - extractAsyncLoaderData, - onMatchSettled, - replaceBy, -} from './ssr-server' -export type { - ServerExtractedBaseEntry, - ServerExtractedEntry, - ServerExtractedPromise, - ServerExtractedStream, -} from './ssr-server' -export * from './tsrScript' +export { attachRouterServerSsrUtils, dehydrateRouter } from './ssr-server' diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 4c9f63dd67..e3420b0840 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,94 +1,69 @@ import invariant from 'tiny-invariant' -import { isPlainObject } from '../utils' -import { tsrSerializer } from '../serializer' -import type { DeferredPromiseState } from '../defer' -import type { MakeRouteMatch } from '../Matches' -import type { AnyRouter, ControllablePromise } from '../router' +import type { AnyRouteMatch, MakeRouteMatch } from '../Matches' +import type { AnyRouter } from '../router' import type { Manifest } from '../manifest' import type { RouteContextOptions } from '../route' +import type { GLOBAL_TSR } from './ssr-server' declare global { interface Window { - __TSR_SSR__?: TsrSsrGlobal + [GLOBAL_TSR]?: TsrSsrGlobal } } -export interface TsrSsrGlobal { - matches: Array - streamedValues: Record< - string, - { - value: any - parsed: any +declare module '../router' { + interface RouterEvents { + onStreamedValue: { + type: 'onStreamedValue' + key: string } - > - cleanScripts: () => void - dehydrated?: any - initMatch: (match: SsrMatch) => void - resolvePromise: (opts: { - matchId: string - id: number - promiseState: DeferredPromiseState - }) => void - injectChunk: (opts: { matchId: string; id: number; chunk: string }) => void - closeStream: (opts: { matchId: string; id: number }) => void -} - -export interface SsrMatch { - id: string - __beforeLoadContext: string - loaderData?: string - error?: string - extracted?: Array - updatedAt: MakeRouteMatch['updatedAt'] - status: MakeRouteMatch['status'] - ssr?: boolean | 'data-only' -} - -export type ClientExtractedEntry = - | ClientExtractedStream - | ClientExtractedPromise - -export interface ClientExtractedPromise extends ClientExtractedBaseEntry { - type: 'promise' - value?: ControllablePromise + } } - -export interface ClientExtractedStream extends ClientExtractedBaseEntry { - type: 'stream' - value?: ReadableStream & { controller?: ReadableStreamDefaultController } +export interface TsrSsrGlobal { + r?: DehydratedRouter + // clean scripts + c: () => void + v: Record } -export interface ClientExtractedBaseEntry { - type: string - path: Array +function assignMatch(deyhydratedMatch: DehydratedMatch, match: AnyRouteMatch) { + match = { + ...match, + id: deyhydratedMatch.i, + __beforeLoadContext: deyhydratedMatch.b, + loaderData: deyhydratedMatch.l, + status: deyhydratedMatch.s, + ssr: deyhydratedMatch.ssr, + updatedAt: deyhydratedMatch.u, + } } - -export interface ResolvePromiseState { - matchId: string - id: number - promiseState: DeferredPromiseState +export interface DehydratedMatch { + i: MakeRouteMatch['id'] + b?: MakeRouteMatch['__beforeLoadContext'] + l?: MakeRouteMatch['loaderData'] + e?: MakeRouteMatch['error'] + u: MakeRouteMatch['updatedAt'] + s: MakeRouteMatch['status'] + ssr?: MakeRouteMatch['ssr'] } export interface DehydratedRouter { manifest: Manifest | undefined - dehydratedData: any - lastMatchId: string + dehydratedData?: any + lastMatchId?: string + matches: Array } export async function hydrate(router: AnyRouter): Promise { invariant( - window.__TSR_SSR__?.dehydrated, - 'Expected to find a dehydrated data on window.__TSR_SSR__.dehydrated... but we did not. Please file an issue!', + window.$_TSR?.r, + 'Expected to find a dehydrated data on window.$_TSR.r... but we did not. Please file an issue!', ) - const { manifest, dehydratedData, lastMatchId } = tsrSerializer.parse( - window.__TSR_SSR__.dehydrated, - ) as DehydratedRouter + const { manifest, dehydratedData, lastMatchId } = window.$_TSR.r router.ssr = { manifest, - serializer: tsrSerializer, } router.clientSsr = { @@ -96,18 +71,7 @@ export async function hydrate(router: AnyRouter): Promise { if (router.isServer) { return undefined } - - const streamedValue = window.__TSR_SSR__?.streamedValues[key] - - if (!streamedValue) { - return - } - - if (!streamedValue.parsed) { - streamedValue.parsed = router.ssr!.serializer.parse(streamedValue.value) - } - - return streamedValue.parsed + return window.$_TSR?.v[key] as T | undefined }, } @@ -126,15 +90,15 @@ export async function hydrate(router: AnyRouter): Promise { // First step is to reyhdrate loaderData and __beforeLoadContext let firstNonSsrMatchIndex: number | undefined = undefined matches.forEach((match) => { - const dehydratedMatch = window.__TSR_SSR__!.matches.find( - (d) => d.id === match.id, + const dehydratedMatch = window.$_TSR!.r!.matches.find( + (d) => d.i === match.id, ) - if (!dehydratedMatch) { Object.assign(match, { dehydrated: false, ssr: false }) return } + assignMatch(dehydratedMatch, match) Object.assign(match, dehydratedMatch) if (match.ssr === false) { @@ -153,30 +117,6 @@ export async function hydrate(router: AnyRouter): Promise { if (match.ssr === false) { return } - - // Handle beforeLoadContext - if (dehydratedMatch.__beforeLoadContext) { - match.__beforeLoadContext = router.ssr!.serializer.parse( - dehydratedMatch.__beforeLoadContext, - ) as any - } - - // Handle loaderData - if (dehydratedMatch.loaderData) { - match.loaderData = router.ssr!.serializer.parse( - dehydratedMatch.loaderData, - ) - } - - // Handle error - if (dehydratedMatch.error) { - match.error = router.ssr!.serializer.parse(dehydratedMatch.error) - } - - // Handle extracted - ;(match as unknown as SsrMatch).extracted?.forEach((ex) => { - deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value) - }) }) router.__store.setState((s) => { @@ -274,18 +214,3 @@ export async function hydrate(router: AnyRouter): Promise { return routeChunkPromise } - -function deepMutableSetByPath(obj: T, path: Array, value: any) { - // mutable set by path retaining array and object references - if (path.length === 1) { - ;(obj as any)[path[0]!] = value - } - - const [key, ...rest] = path - - if (Array.isArray(obj)) { - deepMutableSetByPath(obj[Number(key)], rest, value) - } else if (isPlainObject(obj)) { - deepMutableSetByPath((obj as any)[key!], rest, value) - } -} diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 2c002d5d34..103dd92809 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,37 +1,56 @@ import { default as warning } from 'tiny-warning' -import jsesc from 'jsesc' -import { TSR_DEFERRED_PROMISE, defer } from '../defer' -import { isPlainArray, isPlainObject, pick } from '../utils' -import { tsrSerializer } from '../serializer' +import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' +import { ReadableStreamPlugin } from 'seroval-plugins/web' +import invariant from 'tiny-invariant' +import { createControlledPromise } from '../utils' import minifiedTsrBootStrapScript from './tsrScript?script-string' -import type { DeferredPromise } from '../defer' -import type { - ClientExtractedBaseEntry, - DehydratedRouter, - ResolvePromiseState, - SsrMatch, -} from './ssr-client' +import { serializeString } from './serializeString' import type { AnyRouter } from '../router' -import type { Manifest } from '../manifest' +import type { DehydratedMatch } from './ssr-client' +import type { DehydratedRouter } from './client' import type { AnyRouteMatch } from '../Matches' +import type { Manifest } from '../manifest' -export type ServerExtractedEntry = - | ServerExtractedStream - | ServerExtractedPromise - -export interface ServerExtractedBaseEntry extends ClientExtractedBaseEntry { - id: number - matchIndex: number +declare module '../router' { + interface ServerSsr { + streamInternal: (path: Array, value: unknown) => void + setDehydated: () => void + setRenderFinished: () => void + } + interface RouterEvents { + onInjectedHtml: { + type: 'onInjectedHtml' + promise: Promise + } + } } -export interface ServerExtractedStream extends ServerExtractedBaseEntry { - type: 'stream' - stream: ReadableStream -} +export const GLOBAL_TSR = '$_TSR' +// TODO make seroval scopeId configurable, as this is not necessary for React, only for Solid so it does not clash with its SSR +const SCOPE_ID = 'tsr' + +export function getDehydratedMatch(match: AnyRouteMatch): DehydratedMatch { + const terseMatch: DehydratedMatch = { + i: match.id, + u: match.updatedAt, + s: match.status, + } -export interface ServerExtractedPromise extends ServerExtractedBaseEntry { - type: 'promise' - promise: DeferredPromise + if (match.__beforeLoadContext) { + terseMatch.b = match.__beforeLoadContext + } + if (match.loaderData) { + terseMatch.l = match.loaderData + } + + if (match.error) { + terseMatch.e = match.error + } + + if (match.ssr !== undefined) { + terseMatch.ssr = match.ssr + } + return terseMatch } export function attachRouterServerSsrUtils( @@ -40,8 +59,19 @@ export function attachRouterServerSsrUtils( ) { router.ssr = { manifest, - serializer: tsrSerializer, } + const serializationRefs = new Map() + + let initialScriptSent = false + const getInitialScript = () => { + if (initialScriptSent) { + return '' + } + initialScriptSent = true + return `${getCrossReferenceHeader(SCOPE_ID)};${minifiedTsrBootStrapScript};` + } + let dehydrated = false + const listeners: Array<() => void> = [] router.serverSsr = { injectedHtml: [], @@ -56,15 +86,10 @@ export function attachRouterServerSsrUtils( return promise.then(() => {}) }, - injectScript: (getScript, opts) => { + injectScript: (getScript) => { return router.serverSsr!.injectHtml(async () => { const script = await getScript() - return `` + return `` }) }, streamValue: (key, value) => { @@ -72,275 +97,60 @@ ${jsesc(script, { quotes: 'backtick' })}\`)` !router.serverSsr!.streamedKeys.has(key), 'Key has already been streamed: ' + key, ) - 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, - }, - )}}`, - ) + router.serverSsr!.streamInternal(['v', key], value) + // router.serverSsr!.valueStream.next({ key, value }) }, - 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, + streamInternal: (path: Array, value: unknown) => { + const p = createControlledPromise() + crossSerializeStream(value, { + refs: serializationRefs, + // TODO make plugins configurable + plugins: [ReadableStreamPlugin], + onSerialize: (data, initial) => { + let header = '' + if (initial) { + header = + GLOBAL_TSR + + path.map((x) => `["${serializeString(x)}"]`).join('') + + '=' + } + const serialized = initial ? header + data : data + router.serverSsr!.injectScript(() => serialized) }, - )}`, - ) -} - -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, - } - - 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, - } - 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, + 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) + }, + setDehydated() { + dehydrated = true + }, + isDehydrated() { + return dehydrated + }, + onRenderFinished: (listener) => listeners.push(listener), + setRenderFinished: () => { + listeners.forEach((l) => l()) }, - )})` - - 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, - }, - )})` - }) - } - - 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 +export async function dehydrateRouter(router: AnyRouter) { + invariant(router.serverSsr!.isDehydrated, 'router is already dehydrated!') + const matches: Array = + router.state.matches.map(getDehydratedMatch) + const dehydratedRouter: DehydratedRouter = { + manifest: router.ssr!.manifest, + matches, } - - // // 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 + const lastMatchId = router.state.matches[router.state.matches.length - 1]?.id + if (lastMatchId) { + dehydratedRouter.lastMatchId = lastMatchId } - - return obj + dehydratedRouter.dehydratedData = await router.options.dehydrate?.() + router.serverSsr!.streamInternal(['r'], dehydratedRouter) + router.serverSsr!.setDehydated() } 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..e94d867e8a 100644 --- a/packages/router-core/src/ssr/tsrScript.ts +++ b/packages/router-core/src/ssr/tsrScript.ts @@ -1,91 +1,8 @@ -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-once').forEach((o) => { + o.remove() }) }, + v: {}, } - -window.__TSR_SSR__ = __TSR_SSR__ diff --git a/packages/solid-router/src/ScriptOnce.tsx b/packages/solid-router/src/ScriptOnce.tsx index e85cb9c060..68a7047f6d 100644 --- a/packages/solid-router/src/ScriptOnce.tsx +++ b/packages/solid-router/src/ScriptOnce.tsx @@ -21,7 +21,7 @@ export function ScriptOnce({ ? `console.info(\`Injected From Server: ${jsesc(children.toString(), { quotes: 'backtick' })}\`)` : '', - 'if (typeof __TSR_SSR__ !== "undefined") __TSR_SSR__.cleanScripts()', + 'if (typeof $_TSR !== "undefined") $_TSR.c()', ] .filter(Boolean) .join('\n')} diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 7f82dac801..1cdfe6327c 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -38,13 +38,6 @@ export { } from '@tanstack/router-core' export type { - TsrSerializer, - StartSerializer, - Serializable, - SerializerParse, - SerializerParseBy, - SerializerStringify, - SerializerStringifyBy, DeferredPromiseState, DeferredPromise, ParsedLocation, @@ -92,10 +85,6 @@ export type { SearchSerializer, SearchParser, TrailingSlashOption, - ExtractedEntry, - ExtractedStream, - ExtractedPromise, - StreamState, Manifest, RouterManagedTag, ControlledPromise, @@ -206,8 +195,6 @@ export type { RouterConstructorOptions, ControllablePromise, InjectedHtmlEntry, - RouterErrorSerializer, - SerializerExtensions, CreateFileRoute, CreateLazyFileRoute, } from '@tanstack/router-core' diff --git a/packages/solid-start-client/src/index.tsx b/packages/solid-start-client/src/index.tsx index cdce7ec8c8..c2a1ca6365 100644 --- a/packages/solid-start-client/src/index.tsx +++ b/packages/solid-start-client/src/index.tsx @@ -12,19 +12,10 @@ export { } from '@tanstack/start-client-core' export { type DehydratedRouter, - type ClientExtractedBaseEntry, - type StartSsrGlobal, - type ClientExtractedEntry, - type SsrMatch, - type ClientExtractedPromise, - type ClientExtractedStream, - type ResolvePromiseState, type IsomorphicFn, type ServerOnlyFn, type ClientOnlyFn, type IsomorphicFnBase, - type ServerFn as FetchFn, - type ServerFnCtx as FetchFnCtx, type CompiledFetcherFnOptions, type CompiledFetcherFn, type Fetcher, diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index 357adea2f2..815bd3adeb 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -11,8 +11,8 @@ import type { IntersectAssign, ResolveValidatorInput, ResolveValidatorOutput, - SerializerStringify, } from '@tanstack/router-core' +import type { SerializerStringify } from './serializer' export function createMiddleware( options: { diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 1985fdfdfa..980ed2b4f1 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,17 +1,20 @@ import { default as invariant } from 'tiny-invariant' import { default as warning } from 'tiny-warning' import { isNotFound, isRedirect } from '@tanstack/router-core' -import { mergeHeaders, tsrSerializer } from '@tanstack/router-core/ssr/client' +import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { globalMiddleware } from './registerGlobalMiddleware' +import { startSerializer } from './serializer' +import type { + SerializerParse, + SerializerStringify, + SerializerStringifyBy, +} from './serializer' import type { AnyValidator, Constrain, Expand, ResolveValidatorInput, - SerializerParse, - SerializerStringify, - SerializerStringifyBy, Validator, } from '@tanstack/router-core' import type { JsonResponse } from '@tanstack/router-core/ssr/client' @@ -737,7 +740,7 @@ setServerFnStaticCache(() => { const [cachedResult, readError] = await fs .readFile(filePath, 'utf-8') .then((c) => [ - tsrSerializer.parse(c) as { + startSerializer.parse(c) as { ctx: unknown error: any }, @@ -767,7 +770,7 @@ setServerFnStaticCache(() => { await fs.mkdir(path.dirname(filePath), { recursive: true }) // Store the result with fs - await fs.writeFile(filePath, tsrSerializer.stringify(response)) + await fs.writeFile(filePath, startSerializer.stringify(response)) }, fetchItem: async (ctx) => { const hash = jsonToFilenameSafeString(ctx.data) @@ -780,7 +783,7 @@ setServerFnStaticCache(() => { method: 'GET', }) .then((r) => r.text()) - .then((d) => tsrSerializer.parse(d)) + .then((d) => startSerializer.parse(d)) staticClientCache?.set(url, result) } @@ -802,7 +805,7 @@ export function extractFormDataContext(formData: FormData) { } try { - const context = tsrSerializer.parse(serializedContext) + const context = startSerializer.parse(serializedContext) return { context, data: formData, diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index c0572c0fe4..095cb27d3b 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -1,21 +1,21 @@ export type { DehydratedRouter, - ClientExtractedBaseEntry, - TsrSsrGlobal as StartSsrGlobal, - ClientExtractedEntry, JsonResponse, - SsrMatch, - ClientExtractedPromise, - ClientExtractedStream, - ResolvePromiseState, } from '@tanstack/router-core/ssr/client' -export { - hydrate, - json, - mergeHeaders, - tsrSerializer as startSerializer, -} from '@tanstack/router-core/ssr/client' +export { hydrate, json, mergeHeaders } from '@tanstack/router-core/ssr/client' + +export { startSerializer } from './serializer' + +export type { + StartSerializer, + Serializable, + SerializerParse, + SerializerParseBy, + SerializerStringify, + SerializerStringifyBy, + SerializerExtensions, +} from './serializer' export { createIsomorphicFn, @@ -60,8 +60,6 @@ export { globalMiddleware, } from './registerGlobalMiddleware' export type { - ServerFn as FetchFn, - ServerFnCtx as FetchFnCtx, CompiledFetcherFnOptions, CompiledFetcherFn, Fetcher, diff --git a/packages/router-core/src/serializer.ts b/packages/start-client-core/src/serializer.ts similarity index 88% rename from packages/router-core/src/serializer.ts rename to packages/start-client-core/src/serializer.ts index 6bc33dd11f..1fb11eead5 100644 --- a/packages/router-core/src/serializer.ts +++ b/packages/start-client-core/src/serializer.ts @@ -1,17 +1,12 @@ -import { isPlainObject } from './utils' +import { isPlainObject } from '@tanstack/router-core' -export interface TsrSerializer { +export interface StartSerializer { stringify: (obj: unknown) => string parse: (str: string) => unknown encode: (value: T) => T decode: (value: T) => T } -/** - * @deprecated This is re-export of TsrSerializer which is the generic Router serializer interface. Going forward StartSerializer will be used specifically as a Tanstack Start serializer interface. - */ -export interface StartSerializer extends TsrSerializer {} - export type SerializerStringifyBy = T extends TSerializable ? T : T extends (...args: Array) => any @@ -37,7 +32,7 @@ export type Serializable = Date | undefined | Error | FormData | bigint export type SerializerStringify = SerializerStringifyBy export type SerializerParse = SerializerParseBy -export const tsrSerializer: TsrSerializer = { +export const startSerializer: StartSerializer = { stringify: (value: any) => JSON.stringify(value, function replacer(key, val) { const ogVal = this[key] @@ -65,12 +60,15 @@ export const tsrSerializer: TsrSerializer = { encode: (value: any) => { // When encoding, dive first if (Array.isArray(value)) { - return value.map((v) => tsrSerializer.encode(v)) + return value.map((v) => startSerializer.encode(v)) } if (isPlainObject(value)) { return Object.fromEntries( - Object.entries(value).map(([key, v]) => [key, tsrSerializer.encode(v)]), + Object.entries(value).map(([key, v]) => [ + key, + startSerializer.encode(v), + ]), ) } @@ -91,12 +89,15 @@ export const tsrSerializer: TsrSerializer = { } if (Array.isArray(value)) { - return value.map((v) => tsrSerializer.decode(v)) + return value.map((v) => startSerializer.decode(v)) } if (isPlainObject(value)) { return Object.fromEntries( - Object.entries(value).map(([key, v]) => [key, tsrSerializer.decode(v)]), + Object.entries(value).map(([key, v]) => [ + key, + startSerializer.decode(v), + ]), ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33084c2ba1..bb501f22ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,7 @@ importers: version: 4.0.2 nx: specifier: 20.8.1 - version: 20.8.1(@swc/core@1.10.15(@swc/helpers@0.5.15)) + version: 20.8.1(@swc/core@1.10.15) prettier: specifier: ^3.5.0 version: 3.5.0 @@ -3752,10 +3752,10 @@ importers: version: 19.0.3(@types/react@19.0.8) html-webpack-plugin: specifier: ^5.6.3 - version: 5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1) + version: 5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) swc-loader: specifier: ^0.2.6 - version: 0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1) + version: 0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) typescript: specifier: ^5.7.2 version: 5.8.2 @@ -6327,6 +6327,12 @@ importers: jsesc: specifier: ^3.1.0 version: 3.1.0 + seroval: + specifier: ^1.3.2 + version: 1.3.2 + seroval-plugins: + specifier: ^1.3.2 + version: 1.3.2(seroval@1.3.2) tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -6480,7 +6486,7 @@ importers: version: 2.11.6(@testing-library/jest-dom@6.6.3)(solid-js@1.9.5)(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) webpack: specifier: '>=5.92.0' - version: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4) + version: 5.97.1(@swc/core@1.10.15)(esbuild@0.25.4) zod: specifier: ^3.24.2 version: 3.25.57 @@ -14344,10 +14350,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.3.2: + resolution: {integrity: sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.2.0: resolution: {integrity: sha512-GURoU99ko2UiAgUC3qDCk59Jb3Ss4Po8VIMGkG8j5PFo2Q7y0YSMP8QG9NuL/fJCoTz9V1XZUbpNIMXPOfaGpA==} engines: {node: '>=10'} + seroval@1.3.2: + resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} + engines: {node: '>=10'} + serve-index@1.9.1: resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} engines: {node: '>= 0.8.0'} @@ -19699,17 +19715,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.2.0)(webpack@5.97.1)': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack-dev-server@5.2.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) @@ -21748,7 +21764,7 @@ snapshots: html-tags@3.3.1: {} - html-webpack-plugin@5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1): + html-webpack-plugin@5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -22805,7 +22821,7 @@ snapshots: nwsapi@2.2.16: {} - nx@20.8.1(@swc/core@1.10.15(@swc/helpers@0.5.15)): + nx@20.8.1(@swc/core@1.10.15): dependencies: '@napi-rs/wasm-runtime': 0.2.4 '@yarnpkg/lockfile': 1.1.0 @@ -23742,8 +23758,14 @@ snapshots: dependencies: seroval: 1.2.0 + seroval-plugins@1.3.2(seroval@1.3.2): + dependencies: + seroval: 1.3.2 + seroval@1.2.0: {} + seroval@1.3.2: {} + serve-index@1.9.1: dependencies: accepts: 1.3.8 @@ -24046,7 +24068,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swc-loader@0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1): + swc-loader@0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): dependencies: '@swc/core': 1.10.15(@swc/helpers@0.5.15) '@swc/counter': 0.1.3 @@ -24118,26 +24140,26 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 - terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)): + terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4) + webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.10.15(@swc/helpers@0.5.15) esbuild: 0.25.4 - terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1): + terser-webpack-plugin@5.3.11(@swc/core@1.10.15)(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15)(esbuild@0.25.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) + webpack: 5.97.1(@swc/core@1.10.15)(esbuild@0.25.4) optionalDependencies: '@swc/core': 1.10.15(@swc/helpers@0.5.15) esbuild: 0.25.4 @@ -24781,9 +24803,9 @@ snapshots: webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.97.1) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.97.1) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.2.0)(webpack@5.97.1) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack-dev-server@5.2.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.6 @@ -24797,7 +24819,7 @@ snapshots: optionalDependencies: webpack-dev-server: 5.2.0(webpack-cli@5.1.4)(webpack@5.97.1) - webpack-dev-middleware@7.4.2(webpack@5.97.1): + webpack-dev-middleware@7.4.2(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): dependencies: colorette: 2.0.20 memfs: 4.17.0 @@ -24835,7 +24857,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.97.1) + webpack-dev-middleware: 7.4.2(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) ws: 8.18.0 optionalDependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) @@ -24858,7 +24880,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4): + webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.7 @@ -24880,15 +24902,17 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)) + terser-webpack-plugin: 5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) watchpack: 2.4.2 webpack-sources: 3.2.3 + optionalDependencies: + webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) transitivePeerDependencies: - '@swc/core' - esbuild - uglify-js - webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4): + webpack@5.97.1(@swc/core@1.10.15)(esbuild@0.25.4): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.7 @@ -24910,11 +24934,9 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1) + terser-webpack-plugin: 5.3.11(@swc/core@1.10.15)(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15)(esbuild@0.25.4)) watchpack: 2.4.2 webpack-sources: 3.2.3 - optionalDependencies: - webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) transitivePeerDependencies: - '@swc/core' - esbuild From 23ae3db159f40e2e65baf9b804d63ff323e4bfff Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 10 Jul 2025 21:09:38 +0200 Subject: [PATCH 2/6] fix --- .../react/guide/external-data-loading.md | 2 +- .../react-router-with-query/src/index.tsx | 201 +++++++++--------- packages/react-router/src/Matches.tsx | 2 +- packages/react-router/src/ScriptOnce.tsx | 2 +- packages/react-router/src/Transitioner.tsx | 3 +- packages/router-core/src/router.ts | 7 +- .../src/ssr/createRequestHandler.ts | 4 +- .../router-core/src/ssr/serializeString.ts | 52 ----- .../router-core/src/ssr/seroval-plugins.ts | 43 ++++ packages/router-core/src/ssr/server.ts | 2 +- packages/router-core/src/ssr/ssr-client.ts | 44 ++-- packages/router-core/src/ssr/ssr-server.ts | 73 +++---- packages/router-core/src/ssr/tsrScript.ts | 3 +- packages/solid-router/src/Matches.tsx | 2 +- packages/solid-router/src/ScriptOnce.tsx | 2 +- packages/solid-router/src/Transitioner.tsx | 3 +- .../src/createStartHandler.ts | 7 +- packages/start-server-core/src/index.tsx | 1 - 18 files changed, 201 insertions(+), 252 deletions(-) delete mode 100644 packages/router-core/src/ssr/serializeString.ts create mode 100644 packages/router-core/src/ssr/seroval-plugins.ts 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 0865e33f69..47c09f54ba 100644 --- a/packages/react-router-with-query/src/index.tsx +++ b/packages/react-router-with-query/src/index.tsx @@ -39,100 +39,93 @@ export function routerWithQueryClient( queryClient: QueryClient, additionalOpts?: AdditionalOptions, ): TRouter { + const ogOptions = router.options + + router.options = { + ...router.options, + 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 OuterWrapper = additionalOpts?.WrapProvider || Fragment + const OGWrap = ogOptions.Wrap || Fragment + return ( + + + {children} + + + ) + }, + } + let queryStream: PushableStream if (router.isServer) { - queryStream = createPushableStream() - } + router.options.dehydrate = + async (): Promise => { + const ogDehydrated = await ogOptions.dehydrate?.() + const dehydratedQueryClient = queryDehydrate(queryClient) - const ogClientOptions = queryClient.getDefaultOptions() - queryClient.setDefaultOptions({ - ...ogClientOptions, - dehydrate: { - shouldDehydrateQuery: () => true, - ...ogClientOptions.dehydrate, - }, - }) + router.serverSsr!.onRenderFinished(() => queryStream.close()) - 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) + 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 ogMutationCacheConfig.onError?.( - error, - _variables, - _context, - _mutation, - ) - }, - } + return dehydratedRouter + } - 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) - } + queryStream = createPushableStream() - return ogQueryCacheConfig.onError?.(error, _query) + const ogClientOptions = queryClient.getDefaultOptions() + queryClient.setDefaultOptions({ + ...ogClientOptions, + dehydrate: { + shouldDehydrateQuery: () => true, + ...ogClientOptions.dehydrate, }, - } - if (router.isServer) { - queryClient.getQueryCache().subscribe((event) => { - if (event.type === 'added') { - if (!router.serverSsr!.isDehydrated()) { - return - } - if (queryStream!.isClosed) { - console.warn( - `tried to stream query ${event.query.queryHash} after stream was already closed`, - ) - } - queryStream!.enqueue( - queryDehydrate(queryClient, { - shouldDehydrateQuery: (query) => { - if (query.queryHash === event.query.queryHash) { - return ( - ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? - true - ) - } - return false - }, - }), + }) + + queryClient.getQueryCache().subscribe((event) => { + if (event.type === 'added') { + if (!router.serverSsr!.isDehydrated()) { + return + } + if (queryStream!.isClosed) { + console.warn( + `tried to stream query ${event.query.queryHash} after stream was already closed`, ) } - }) - } - } - - const ogOptions = router.options - router.options = { - ...router.options, - dehydrate: async (): Promise => { - router.serverSsr!.onRenderFinished(() => queryStream.close()) - const ogDehydrated = await ogOptions.dehydrate?.() - return { - ...ogDehydrated, - // When critical data is dehydrated, we also dehydrate the query client - dehydratedQueryClient: queryDehydrate(queryClient), - // prepare the stream for queries coming up during rendering - queryStream: queryStream.stream, + queryStream!.enqueue( + queryDehydrate(queryClient, { + shouldDehydrateQuery: (query) => { + if (query.queryHash === event.query.queryHash) { + return ( + ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? + true + ) + } + return false + }, + }), + ) } - }, - hydrate: async (dehydrated: DehydratedRouterQueryState) => { - console.log('dehydrated', dehydrated) + }) + // 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(function handle({ done, value }): Promise { queryHydrate(queryClient, value) @@ -141,24 +134,39 @@ export function routerWithQueryClient( } return reader.read().then(handle) }) - }, - 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 OuterWrapper = additionalOpts?.WrapProvider || Fragment - const OGWrap = ogOptions.Wrap || Fragment - return ( - - - {children} - - - ) - }, + } + 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 @@ -171,6 +179,7 @@ type PushableStream = { isClosed: boolean error: (err: unknown) => void } + function createPushableStream(): PushableStream { let controllerRef: ReadableStreamDefaultController | undefined const stream = new ReadableStream({ 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 1a301f8357..19aa0f3696 100644 --- a/packages/react-router/src/ScriptOnce.tsx +++ b/packages/react-router/src/ScriptOnce.tsx @@ -14,7 +14,7 @@ export function ScriptOnce({ return ( " and "\" to avoid invalid escapes in the output. -// http://www.ecma-international.org/ecma-262/5.1/#sec-7.8.4 -export function serializeString(str: string): string { - let result = '' - let lastPos = 0 - let replacement: string | undefined - for (let i = 0, len = str.length; i < len; i++) { - replacement = serializeChar(str[i]!) - if (replacement) { - result += str.slice(lastPos, i) + replacement - lastPos = i + 1 - } - } - if (lastPos === 0) { - result = str - } else { - result += str.slice(lastPos) - } - return result -} diff --git a/packages/router-core/src/ssr/seroval-plugins.ts b/packages/router-core/src/ssr/seroval-plugins.ts new file mode 100644 index 0000000000..f9f8a3cca9 --- /dev/null +++ b/packages/router-core/src/ssr/seroval-plugins.ts @@ -0,0 +1,43 @@ +import type { SerovalNode } from 'seroval' +import { createPlugin } from 'seroval' + +interface ErrorNode { + message: SerovalNode +} + +/** + * this plugin serializes only the `message` part of an Error + * this helps with serializing e.g. a ZodError which has functions attached that cannot be serialized + */ +export const ShallowErrorPlugin = /* @__PURE__ */ createPlugin< + Error, + ErrorNode +>({ + tag: 'tanstack-start:seroval-plugins/Error', + test(value) { + return value instanceof Error + }, + parse: { + sync(value, ctx) { + return { + message: ctx.parse(value.message), + } + }, + async async(value, ctx) { + return { + message: await ctx.parse(value.message), + } + }, + stream(value, ctx) { + return { + message: ctx.parse(value.message), + } + }, + }, + serialize(node, ctx) { + return 'new Error(' + ctx.serialize(node.message) + ')' + }, + deserialize(node, ctx) { + return new Error(ctx.deserialize(node.message) as string) + }, +}) diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index 91eaecfc59..7b7fac61ba 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -7,4 +7,4 @@ export { transformStreamWithRouter, transformReadableStreamWithRouter, } from './transformStreamWithRouter' -export { attachRouterServerSsrUtils, dehydrateRouter } from './ssr-server' +export { attachRouterServerSsrUtils } from './ssr-server' diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index e3420b0840..40f45df0fd 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,5 +1,5 @@ import invariant from 'tiny-invariant' -import type { AnyRouteMatch, MakeRouteMatch } from '../Matches' +import type { MakeRouteMatch } from '../Matches' import type { AnyRouter } from '../router' import type { Manifest } from '../manifest' import type { RouteContextOptions } from '../route' @@ -11,30 +11,23 @@ declare global { } } -declare module '../router' { - interface RouterEvents { - onStreamedValue: { - type: 'onStreamedValue' - key: string - } - } -} export interface TsrSsrGlobal { - r?: DehydratedRouter - // clean scripts + router?: DehydratedRouter + // clean scripts, shortened since this is sent for each streamed script c: () => void - v: Record } -function assignMatch(deyhydratedMatch: DehydratedMatch, match: AnyRouteMatch) { - match = { - ...match, +function hydrateMatch( + deyhydratedMatch: DehydratedMatch, +): Partial { + return { id: deyhydratedMatch.i, __beforeLoadContext: deyhydratedMatch.b, loaderData: deyhydratedMatch.l, status: deyhydratedMatch.s, ssr: deyhydratedMatch.ssr, updatedAt: deyhydratedMatch.u, + error: deyhydratedMatch.e, } } export interface DehydratedMatch { @@ -56,25 +49,16 @@ export interface DehydratedRouter { export async function hydrate(router: AnyRouter): Promise { invariant( - window.$_TSR?.r, - 'Expected to find a dehydrated data on window.$_TSR.r... but we did not. Please file an issue!', + window.$_TSR?.router, + 'Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!', ) - const { manifest, dehydratedData, lastMatchId } = window.$_TSR.r + const { manifest, dehydratedData, lastMatchId } = window.$_TSR.router router.ssr = { manifest, } - router.clientSsr = { - getStreamedValue: (key: string): T | undefined => { - if (router.isServer) { - return undefined - } - return window.$_TSR?.v[key] as T | undefined - }, - } - // Hydrate the router state const matches = router.matchRoutes(router.state.location) @@ -90,7 +74,7 @@ export async function hydrate(router: AnyRouter): Promise { // First step is to reyhdrate loaderData and __beforeLoadContext let firstNonSsrMatchIndex: number | undefined = undefined matches.forEach((match) => { - const dehydratedMatch = window.$_TSR!.r!.matches.find( + const dehydratedMatch = window.$_TSR!.router!.matches.find( (d) => d.i === match.id, ) if (!dehydratedMatch) { @@ -98,8 +82,7 @@ export async function hydrate(router: AnyRouter): Promise { return } - assignMatch(dehydratedMatch, match) - Object.assign(match, dehydratedMatch) + Object.assign(match, hydrateMatch(dehydratedMatch)) if (match.ssr === false) { match._dehydrated = false @@ -211,6 +194,5 @@ export async function hydrate(router: AnyRouter): Promise { }) }) } - return routeChunkPromise } diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 103dd92809..60b20c6bff 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,20 +1,17 @@ -import { default as warning } from 'tiny-warning' import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' import { ReadableStreamPlugin } from 'seroval-plugins/web' import invariant from 'tiny-invariant' import { createControlledPromise } from '../utils' import minifiedTsrBootStrapScript from './tsrScript?script-string' -import { serializeString } from './serializeString' import type { AnyRouter } from '../router' import type { DehydratedMatch } from './ssr-client' import type { DehydratedRouter } from './client' import type { AnyRouteMatch } from '../Matches' import type { Manifest } from '../manifest' +import { ShallowErrorPlugin } from './seroval-plugins' declare module '../router' { interface ServerSsr { - streamInternal: (path: Array, value: unknown) => void - setDehydated: () => void setRenderFinished: () => void } interface RouterEvents { @@ -26,7 +23,6 @@ declare module '../router' { } export const GLOBAL_TSR = '$_TSR' -// TODO make seroval scopeId configurable, as this is not necessary for React, only for Solid so it does not clash with its SSR const SCOPE_ID = 'tsr' export function getDehydratedMatch(match: AnyRouteMatch): DehydratedMatch { @@ -70,12 +66,11 @@ export function attachRouterServerSsrUtils( initialScriptSent = true return `${getCrossReferenceHeader(SCOPE_ID)};${minifiedTsrBootStrapScript};` } - let dehydrated = false + let _dehydrated = false const listeners: Array<() => void> = [] router.serverSsr = { injectedHtml: [], - streamedKeys: new Set(), injectHtml: (getHtml) => { const promise = Promise.resolve().then(getHtml) router.serverSsr!.injectedHtml.push(promise) @@ -89,33 +84,33 @@ export function attachRouterServerSsrUtils( injectScript: (getScript) => { return router.serverSsr!.injectHtml(async () => { const script = await getScript() - return `` + return `` }) }, - streamValue: (key, value) => { - warning( - !router.serverSsr!.streamedKeys.has(key), - 'Key has already been streamed: ' + key, - ) - router.serverSsr!.streamedKeys.add(key) - router.serverSsr!.streamInternal(['v', key], value) - // router.serverSsr!.valueStream.next({ key, value }) - }, - streamInternal: (path: Array, value: unknown) => { + dehydrate: async () => { + invariant(!_dehydrated, 'router is already dehydrated!') + const matches: Array = + router.state.matches.map(getDehydratedMatch) + + const dehydratedRouter: DehydratedRouter = { + manifest: router.ssr!.manifest, + matches, + } + const lastMatchId = + router.state.matches[router.state.matches.length - 1]?.id + if (lastMatchId) { + dehydratedRouter.lastMatchId = lastMatchId + } + dehydratedRouter.dehydratedData = await router.options.dehydrate?.() + _dehydrated = true + const p = createControlledPromise() - crossSerializeStream(value, { + crossSerializeStream(dehydratedRouter, { refs: serializationRefs, // TODO make plugins configurable - plugins: [ReadableStreamPlugin], + plugins: [ReadableStreamPlugin, ShallowErrorPlugin], onSerialize: (data, initial) => { - let header = '' - if (initial) { - header = - GLOBAL_TSR + - path.map((x) => `["${serializeString(x)}"]`).join('') + - '=' - } - const serialized = initial ? header + data : data + const serialized = initial ? `${GLOBAL_TSR}["router"]=` + data : data router.serverSsr!.injectScript(() => serialized) }, scopeId: SCOPE_ID, @@ -125,11 +120,8 @@ export function attachRouterServerSsrUtils( // make sure the stream is kept open until the promise is resolved router.serverSsr!.injectHtml(() => p) }, - setDehydated() { - dehydrated = true - }, isDehydrated() { - return dehydrated + return _dehydrated }, onRenderFinished: (listener) => listeners.push(listener), setRenderFinished: () => { @@ -137,20 +129,3 @@ export function attachRouterServerSsrUtils( }, } } - -export async function dehydrateRouter(router: AnyRouter) { - invariant(router.serverSsr!.isDehydrated, 'router is already dehydrated!') - const matches: Array = - router.state.matches.map(getDehydratedMatch) - const dehydratedRouter: DehydratedRouter = { - manifest: router.ssr!.manifest, - matches, - } - const lastMatchId = router.state.matches[router.state.matches.length - 1]?.id - if (lastMatchId) { - dehydratedRouter.lastMatchId = lastMatchId - } - dehydratedRouter.dehydratedData = await router.options.dehydrate?.() - router.serverSsr!.streamInternal(['r'], dehydratedRouter) - router.serverSsr!.setDehydated() -} diff --git a/packages/router-core/src/ssr/tsrScript.ts b/packages/router-core/src/ssr/tsrScript.ts index e94d867e8a..bd8c2a0e0f 100644 --- a/packages/router-core/src/ssr/tsrScript.ts +++ b/packages/router-core/src/ssr/tsrScript.ts @@ -1,8 +1,7 @@ self.$_TSR = { c: () => { - document.querySelectorAll('.tsr-once').forEach((o) => { + document.querySelectorAll('.\\$tsr').forEach((o) => { o.remove() }) }, - v: {}, } 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 68a7047f6d..74d811b249 100644 --- a/packages/solid-router/src/ScriptOnce.tsx +++ b/packages/solid-router/src/ScriptOnce.tsx @@ -14,7 +14,7 @@ export function ScriptOnce({ return (