diff --git a/e2e/react-start/serialization-adapters/src/data.tsx b/e2e/react-start/serialization-adapters/src/data.tsx index ea1922fedc8..b145e419ea2 100644 --- a/e2e/react-start/serialization-adapters/src/data.tsx +++ b/e2e/react-start/serialization-adapters/src/data.tsx @@ -88,6 +88,38 @@ export function makeData() { }, } } +export class NestedOuter { + constructor(public inner: NestedInner) {} + whisper() { + return this.inner.value.toLowerCase() + } +} + +export class NestedInner { + constructor(public value: string) {} + shout() { + return this.value.toUpperCase() + } +} + +export const nestedInnerAdapter = createSerializationAdapter({ + key: 'nestedInner', + test: (value): value is NestedInner => value instanceof NestedInner, + toSerializable: (inner) => inner.value, + fromSerializable: (value) => new NestedInner(value), +}) + +export const nestedOuterAdapter = createSerializationAdapter({ + key: 'nestedOuter', + extends: [nestedInnerAdapter], + test: (value) => value instanceof NestedOuter, + toSerializable: (outer) => outer.inner, + fromSerializable: (value) => new NestedOuter(value), +}) + +export function makeNested() { + return new NestedOuter(new NestedInner('Hello World')) +} export function RenderData({ id, diff --git a/e2e/react-start/serialization-adapters/src/routeTree.gen.ts b/e2e/react-start/serialization-adapters/src/routeTree.gen.ts index b5af1618a37..7da84b7b8f0 100644 --- a/e2e/react-start/serialization-adapters/src/routeTree.gen.ts +++ b/e2e/react-start/serialization-adapters/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as SsrStreamRouteImport } from './routes/ssr/stream' +import { Route as SsrNestedRouteImport } from './routes/ssr/nested' import { Route as SsrDataOnlyRouteImport } from './routes/ssr/data-only' import { Route as ServerFunctionCustomErrorRouteImport } from './routes/server-function/custom-error' @@ -24,6 +25,11 @@ const SsrStreamRoute = SsrStreamRouteImport.update({ path: '/ssr/stream', getParentRoute: () => rootRouteImport, } as any) +const SsrNestedRoute = SsrNestedRouteImport.update({ + id: '/ssr/nested', + path: '/ssr/nested', + getParentRoute: () => rootRouteImport, +} as any) const SsrDataOnlyRoute = SsrDataOnlyRouteImport.update({ id: '/ssr/data-only', path: '/ssr/data-only', @@ -40,12 +46,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute '/ssr/data-only': typeof SsrDataOnlyRoute + '/ssr/nested': typeof SsrNestedRoute '/ssr/stream': typeof SsrStreamRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute '/ssr/data-only': typeof SsrDataOnlyRoute + '/ssr/nested': typeof SsrNestedRoute '/ssr/stream': typeof SsrStreamRoute } export interface FileRoutesById { @@ -53,6 +61,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute '/ssr/data-only': typeof SsrDataOnlyRoute + '/ssr/nested': typeof SsrNestedRoute '/ssr/stream': typeof SsrStreamRoute } export interface FileRouteTypes { @@ -61,14 +70,21 @@ export interface FileRouteTypes { | '/' | '/server-function/custom-error' | '/ssr/data-only' + | '/ssr/nested' | '/ssr/stream' fileRoutesByTo: FileRoutesByTo - to: '/' | '/server-function/custom-error' | '/ssr/data-only' | '/ssr/stream' + to: + | '/' + | '/server-function/custom-error' + | '/ssr/data-only' + | '/ssr/nested' + | '/ssr/stream' id: | '__root__' | '/' | '/server-function/custom-error' | '/ssr/data-only' + | '/ssr/nested' | '/ssr/stream' fileRoutesById: FileRoutesById } @@ -76,6 +92,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ServerFunctionCustomErrorRoute: typeof ServerFunctionCustomErrorRoute SsrDataOnlyRoute: typeof SsrDataOnlyRoute + SsrNestedRoute: typeof SsrNestedRoute SsrStreamRoute: typeof SsrStreamRoute } @@ -95,6 +112,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SsrStreamRouteImport parentRoute: typeof rootRouteImport } + '/ssr/nested': { + id: '/ssr/nested' + path: '/ssr/nested' + fullPath: '/ssr/nested' + preLoaderRoute: typeof SsrNestedRouteImport + parentRoute: typeof rootRouteImport + } '/ssr/data-only': { id: '/ssr/data-only' path: '/ssr/data-only' @@ -116,6 +140,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ServerFunctionCustomErrorRoute: ServerFunctionCustomErrorRoute, SsrDataOnlyRoute: SsrDataOnlyRoute, + SsrNestedRoute: SsrNestedRoute, SsrStreamRoute: SsrStreamRoute, } export const routeTree = rootRouteImport diff --git a/e2e/react-start/serialization-adapters/src/routes/ssr/nested.tsx b/e2e/react-start/serialization-adapters/src/routes/ssr/nested.tsx new file mode 100644 index 00000000000..22ce85e0bb5 --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/routes/ssr/nested.tsx @@ -0,0 +1,56 @@ +import { createFileRoute } from '@tanstack/react-router' +import { makeNested } from '~/data' + +export const Route = createFileRoute('/ssr/nested')({ + beforeLoad: () => { + return { nested: makeNested() } + }, + loader: ({ context }) => { + return context + }, + component: () => { + const loaderData = Route.useLoaderData() + + const localData = makeNested() + const expectedShoutState = localData.inner.shout() + const expectedWhisperState = localData.whisper() + const shoutState = loaderData.nested.inner.shout() + const whisperState = loaderData.nested.whisper() + + return ( +
+

data-only

+
+

shout

+
+ expected:{' '} +
+ {JSON.stringify(expectedShoutState)} +
+
+
+ actual:{' '} +
+ {JSON.stringify(shoutState)} +
+
+
+
+

whisper

+
+ expected:{' '} +
+ {JSON.stringify(expectedWhisperState)} +
+
+
+ actual:{' '} +
+ {JSON.stringify(whisperState)} +
+
+
+
+ ) + }, +}) diff --git a/e2e/react-start/serialization-adapters/src/start.tsx b/e2e/react-start/serialization-adapters/src/start.tsx index 75363769634..602c9326542 100644 --- a/e2e/react-start/serialization-adapters/src/start.tsx +++ b/e2e/react-start/serialization-adapters/src/start.tsx @@ -1,10 +1,16 @@ import { createStart } from '@tanstack/react-start' -import { carAdapter, fooAdapter } from './data' +import { carAdapter, fooAdapter, nestedOuterAdapter } from './data' import { customErrorAdapter } from './CustomError' export const startInstance = createStart(() => { return { defaultSsr: true, - serializationAdapters: [fooAdapter, carAdapter, customErrorAdapter], + serializationAdapters: [ + fooAdapter, + carAdapter, + customErrorAdapter, + // only register nestedOuterAdapter here, nestedInnerAdapter is registered as an "extends" of nestedOuterAdapter + nestedOuterAdapter, + ], } }) diff --git a/e2e/react-start/serialization-adapters/tests/app.spec.ts b/e2e/react-start/serialization-adapters/tests/app.spec.ts index 2cfaaf727d9..d7c28ab559d 100644 --- a/e2e/react-start/serialization-adapters/tests/app.spec.ts +++ b/e2e/react-start/serialization-adapters/tests/app.spec.ts @@ -49,6 +49,27 @@ test.describe('SSR serialization adapters', () => { await awaitPageLoaded(page) await checkData(page, 'stream') }) + + test('nested', async ({ page }) => { + await page.goto('/ssr/nested') + await awaitPageLoaded(page) + + const expectedShout = await page + .getByTestId(`shout-expected-state`) + .textContent() + expect(expectedShout).not.toBeNull() + await expect(page.getByTestId(`shout-actual-state`)).toContainText( + expectedShout!, + ) + + const expectedWhisper = await page + .getByTestId(`whisper-expected-state`) + .textContent() + expect(expectedWhisper).not.toBeNull() + await expect(page.getByTestId(`whisper-actual-state`)).toContainText( + expectedWhisper!, + ) + }) }) test.describe('server functions serialization adapters', () => { diff --git a/packages/router-core/src/ssr/serializer/transformer.ts b/packages/router-core/src/ssr/serializer/transformer.ts index cf58b4f596e..f8448414cd2 100644 --- a/packages/router-core/src/ssr/serializer/transformer.ts +++ b/packages/router-core/src/ssr/serializer/transformer.ts @@ -23,19 +23,40 @@ export interface SerializableExtensions extends DefaultSerializable {} export type Serializable = SerializableExtensions[keyof SerializableExtensions] +export type UnionizeSerializationAdaptersInput< + TAdapters extends ReadonlyArray, +> = TAdapters[number]['~types']['input'] + export function createSerializationAdapter< TInput = unknown, - TOutput = unknown /* we need to check that this type is actually serializable taking into account all seroval native types and any custom plugin WE=router/start add!!! */, + TOutput = unknown, + const TExtendsAdapters extends + | ReadonlyArray + | never = never, >( - opts: CreateSerializationAdapterOptions, -): SerializationAdapter { - return opts as unknown as SerializationAdapter + opts: CreateSerializationAdapterOptions, +): SerializationAdapter { + return opts as unknown as SerializationAdapter< + TInput, + TOutput, + TExtendsAdapters + > } -export interface CreateSerializationAdapterOptions { +export interface CreateSerializationAdapterOptions< + TInput, + TOutput, + TExtendsAdapters extends ReadonlyArray | never, +> { key: string + extends?: TExtendsAdapters test: (value: unknown) => value is TInput - toSerializable: (value: TInput) => ValidateSerializable + toSerializable: ( + value: TInput, + ) => ValidateSerializable< + TOutput, + Serializable | UnionizeSerializationAdaptersInput + > fromSerializable: (value: TOutput) => TInput } @@ -90,28 +111,42 @@ export interface DefaultSerializerExtensions { export interface SerializerExtensions extends DefaultSerializerExtensions {} -export interface SerializationAdapter { - '~types': SerializationAdapterTypes +export interface SerializationAdapter< + TInput, + TOutput, + TExtendsAdapters extends ReadonlyArray, +> { + '~types': SerializationAdapterTypes key: string + extends?: TExtendsAdapters test: (value: unknown) => value is TInput toSerializable: (value: TInput) => TOutput fromSerializable: (value: TOutput) => TInput - makePlugin: (options: { didRun: boolean }) => Plugin } -export interface SerializationAdapterTypes { - input: TInput +export interface SerializationAdapterTypes< + TInput, + TOutput, + TExtendsAdapters extends ReadonlyArray, +> { + input: TInput | UnionizeSerializationAdaptersInput output: TOutput + extends: TExtendsAdapters } -export type AnySerializationAdapter = SerializationAdapter +export type AnySerializationAdapter = SerializationAdapter -export function makeSsrSerovalPlugin( - serializationAdapter: SerializationAdapter, +export function makeSsrSerovalPlugin( + serializationAdapter: AnySerializationAdapter, options: { didRun: boolean }, -) { - return createPlugin({ +): Plugin { + return createPlugin({ tag: '$TSR/t/' + serializationAdapter.key, + extends: serializationAdapter.extends + ? (serializationAdapter.extends as Array).map( + (ext) => makeSsrSerovalPlugin(ext, options), + ) + : undefined, test: serializationAdapter.test, parse: { stream(value, ctx) { @@ -134,11 +169,16 @@ export function makeSsrSerovalPlugin( }) } -export function makeSerovalPlugin( - serializationAdapter: SerializationAdapter, -) { - return createPlugin({ +export function makeSerovalPlugin( + serializationAdapter: AnySerializationAdapter, +): Plugin { + return createPlugin({ tag: '$TSR/t/' + serializationAdapter.key, + extends: serializationAdapter.extends + ? (serializationAdapter.extends as Array).map( + makeSerovalPlugin, + ) + : undefined, test: serializationAdapter.test, parse: { sync(value, ctx) { @@ -154,9 +194,7 @@ export function makeSerovalPlugin( // we don't generate JS code outside of SSR (for now) serialize: undefined as never, deserialize(node, ctx) { - return serializationAdapter.fromSerializable( - ctx.deserialize(node) as TOutput, - ) + return serializationAdapter.fromSerializable(ctx.deserialize(node)) }, }) } diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index 751c0d290ed..d204b37c842 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -292,11 +292,8 @@ export type AssignAllServerRequestContext< > = Assign< // Fetch Request Context GlobalFetchRequestContext, - // AnyContext, Assign< - GlobalServerRequestContext, // TODO: This enabled global middleware - // type inference, but creates a circular types issue. No idea how to fix this. - // AnyContext, + GlobalServerRequestContext, __AssignAllServerRequestContext > > diff --git a/packages/start-client-core/src/createStart.ts b/packages/start-client-core/src/createStart.ts index ac90efaeaf5..52e62287e06 100644 --- a/packages/start-client-core/src/createStart.ts +++ b/packages/start-client-core/src/createStart.ts @@ -65,6 +65,21 @@ export interface StartInstanceTypes< functionMiddleware: TFunctionMiddlewares } +function dedupeSerializationAdapters( + deduped: Set, + plugins: Array, +): void { + for (let i = 0, len = plugins.length; i < len; i++) { + const current = plugins[i]! + if (!deduped.has(current)) { + deduped.add(current) + if (current.extends) { + dedupeSerializationAdapters(deduped, current.extends) + } + } + } +} + export const createStart = < const TSerializationAdapters extends ReadonlyArray = [], @@ -102,6 +117,14 @@ export const createStart = < return { getOptions: async () => { const options = await getOptions() + if (options.serializationAdapters) { + const deduped = new Set() + dedupeSerializationAdapters( + deduped, + options.serializationAdapters as unknown as Array, + ) + options.serializationAdapters = Array.from(deduped) as any + } return options }, createMiddleware: createMiddleware as any, diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index abe78e15993..8e3b5d688eb 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -13,7 +13,7 @@ import { } from '@tanstack/router-core' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' import { runWithStartContext } from '@tanstack/start-storage-context' -import { getResponseHeaders, requestHandler } from './request-response' +import { requestHandler } from './request-response' import { getStartManifest } from './router-manifest' import { handleServerAction } from './server-functions-handler' @@ -40,7 +40,6 @@ type TODO = any function getStartResponseHeaders(opts: { router: AnyRouter }) { const headers = mergeHeaders( - getResponseHeaders() as Headers, { 'Content-Type': 'text/html; charset=utf-8', },