From f7707584fd7a81d5c13e12c0dc704e1957a3a7ec Mon Sep 17 00:00:00 2001
From: Manuel Schiller <manuel.schiller@caligano.de>
Date: Tue, 30 Sep 2025 20:21:43 +0200
Subject: [PATCH] fix: allow to express dependencies between serialization
 adapters

---
 .../serialization-adapters/src/data.tsx       | 32 +++++++
 .../src/routeTree.gen.ts                      | 27 +++++-
 .../src/routes/ssr/nested.tsx                 | 56 +++++++++++++
 .../serialization-adapters/src/start.tsx      | 10 ++-
 .../serialization-adapters/tests/app.spec.ts  | 21 +++++
 .../src/ssr/serializer/transformer.ts         | 84 ++++++++++++++-----
 .../start-client-core/src/createMiddleware.ts |  5 +-
 packages/start-client-core/src/createStart.ts | 23 +++++
 .../src/createStartHandler.ts                 |  3 +-
 9 files changed, 229 insertions(+), 32 deletions(-)
 create mode 100644 e2e/react-start/serialization-adapters/src/routes/ssr/nested.tsx

diff --git a/e2e/react-start/serialization-adapters/src/data.tsx b/e2e/react-start/serialization-adapters/src/data.tsx
index ea1922fedc..b145e419ea 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 b5af1618a3..7da84b7b8f 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 0000000000..22ce85e0bb
--- /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 (
+      <div data-testid="data-only-container">
+        <h2 data-testid="data-only-heading">data-only</h2>
+        <div data-testid="shout-container">
+          <h3>shout</h3>
+          <div>
+            expected:{' '}
+            <div data-testid="shout-expected-state">
+              {JSON.stringify(expectedShoutState)}
+            </div>
+          </div>
+          <div>
+            actual:{' '}
+            <div data-testid="shout-actual-state">
+              {JSON.stringify(shoutState)}
+            </div>
+          </div>
+        </div>
+        <div data-testid="whisper-container">
+          <h3>whisper</h3>
+          <div>
+            expected:{' '}
+            <div data-testid="whisper-expected-state">
+              {JSON.stringify(expectedWhisperState)}
+            </div>
+          </div>
+          <div>
+            actual:{' '}
+            <div data-testid="whisper-actual-state">
+              {JSON.stringify(whisperState)}
+            </div>
+          </div>
+        </div>
+      </div>
+    )
+  },
+})
diff --git a/e2e/react-start/serialization-adapters/src/start.tsx b/e2e/react-start/serialization-adapters/src/start.tsx
index 7536376963..602c932654 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 2cfaaf727d..d7c28ab559 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 cf58b4f596..f8448414cd 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<AnySerializationAdapter>,
+> = 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<AnySerializationAdapter>
+    | never = never,
 >(
-  opts: CreateSerializationAdapterOptions<TInput, TOutput>,
-): SerializationAdapter<TInput, TOutput> {
-  return opts as unknown as SerializationAdapter<TInput, TOutput>
+  opts: CreateSerializationAdapterOptions<TInput, TOutput, TExtendsAdapters>,
+): SerializationAdapter<TInput, TOutput, TExtendsAdapters> {
+  return opts as unknown as SerializationAdapter<
+    TInput,
+    TOutput,
+    TExtendsAdapters
+  >
 }
 
-export interface CreateSerializationAdapterOptions<TInput, TOutput> {
+export interface CreateSerializationAdapterOptions<
+  TInput,
+  TOutput,
+  TExtendsAdapters extends ReadonlyArray<AnySerializationAdapter> | never,
+> {
   key: string
+  extends?: TExtendsAdapters
   test: (value: unknown) => value is TInput
-  toSerializable: (value: TInput) => ValidateSerializable<TOutput, Serializable>
+  toSerializable: (
+    value: TInput,
+  ) => ValidateSerializable<
+    TOutput,
+    Serializable | UnionizeSerializationAdaptersInput<TExtendsAdapters>
+  >
   fromSerializable: (value: TOutput) => TInput
 }
 
@@ -90,28 +111,42 @@ export interface DefaultSerializerExtensions {
 
 export interface SerializerExtensions extends DefaultSerializerExtensions {}
 
-export interface SerializationAdapter<TInput, TOutput> {
-  '~types': SerializationAdapterTypes<TInput, TOutput>
+export interface SerializationAdapter<
+  TInput,
+  TOutput,
+  TExtendsAdapters extends ReadonlyArray<AnySerializationAdapter>,
+> {
+  '~types': SerializationAdapterTypes<TInput, TOutput, TExtendsAdapters>
   key: string
+  extends?: TExtendsAdapters
   test: (value: unknown) => value is TInput
   toSerializable: (value: TInput) => TOutput
   fromSerializable: (value: TOutput) => TInput
-  makePlugin: (options: { didRun: boolean }) => Plugin<TInput, SerovalNode>
 }
 
-export interface SerializationAdapterTypes<TInput, TOutput> {
-  input: TInput
+export interface SerializationAdapterTypes<
+  TInput,
+  TOutput,
+  TExtendsAdapters extends ReadonlyArray<AnySerializationAdapter>,
+> {
+  input: TInput | UnionizeSerializationAdaptersInput<TExtendsAdapters>
   output: TOutput
+  extends: TExtendsAdapters
 }
 
-export type AnySerializationAdapter = SerializationAdapter<any, any>
+export type AnySerializationAdapter = SerializationAdapter<any, any, any>
 
-export function makeSsrSerovalPlugin<TInput, TOutput>(
-  serializationAdapter: SerializationAdapter<TInput, TOutput>,
+export function makeSsrSerovalPlugin(
+  serializationAdapter: AnySerializationAdapter,
   options: { didRun: boolean },
-) {
-  return createPlugin<TInput, SerovalNode>({
+): Plugin<any, SerovalNode> {
+  return createPlugin<any, SerovalNode>({
     tag: '$TSR/t/' + serializationAdapter.key,
+    extends: serializationAdapter.extends
+      ? (serializationAdapter.extends as Array<AnySerializationAdapter>).map(
+          (ext) => makeSsrSerovalPlugin(ext, options),
+        )
+      : undefined,
     test: serializationAdapter.test,
     parse: {
       stream(value, ctx) {
@@ -134,11 +169,16 @@ export function makeSsrSerovalPlugin<TInput, TOutput>(
   })
 }
 
-export function makeSerovalPlugin<TInput, TOutput>(
-  serializationAdapter: SerializationAdapter<TInput, TOutput>,
-) {
-  return createPlugin<TInput, SerovalNode>({
+export function makeSerovalPlugin(
+  serializationAdapter: AnySerializationAdapter,
+): Plugin<any, SerovalNode> {
+  return createPlugin<any, SerovalNode>({
     tag: '$TSR/t/' + serializationAdapter.key,
+    extends: serializationAdapter.extends
+      ? (serializationAdapter.extends as Array<AnySerializationAdapter>).map(
+          makeSerovalPlugin,
+        )
+      : undefined,
     test: serializationAdapter.test,
     parse: {
       sync(value, ctx) {
@@ -154,9 +194,7 @@ export function makeSerovalPlugin<TInput, TOutput>(
     // 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 751c0d290e..d204b37c84 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<TRegister>, // TODO: This enabled global middleware
-    // type inference, but creates a circular types issue. No idea how to fix this.
-    // AnyContext,
+    GlobalServerRequestContext<TRegister>,
     __AssignAllServerRequestContext<TMiddlewares, TSendContext, TServerContext>
   >
 >
diff --git a/packages/start-client-core/src/createStart.ts b/packages/start-client-core/src/createStart.ts
index ac90efaeaf..52e62287e0 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<AnySerializationAdapter>,
+  plugins: Array<AnySerializationAdapter>,
+): 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<AnySerializationAdapter> = [],
@@ -102,6 +117,14 @@ export const createStart = <
   return {
     getOptions: async () => {
       const options = await getOptions()
+      if (options.serializationAdapters) {
+        const deduped = new Set<AnySerializationAdapter>()
+        dedupeSerializationAdapters(
+          deduped,
+          options.serializationAdapters as unknown as Array<AnySerializationAdapter>,
+        )
+        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 abe78e1599..8e3b5d688e 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',
     },
