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',
},