diff --git a/packages/router-core/src/ssr/serializer/transformer.ts b/packages/router-core/src/ssr/serializer/transformer.ts index 3974a25bf88..564800811f0 100644 --- a/packages/router-core/src/ssr/serializer/transformer.ts +++ b/packages/router-core/src/ssr/serializer/transformer.ts @@ -30,21 +30,22 @@ export interface CreateSerializationAdapterOptions { fromSerializable: (value: TOutput) => TInput } -export type ValidateSerializable = T extends TSerializable - ? T - : T extends (...args: Array) => any - ? 'Function is not serializable' - : T extends Promise - ? ValidateSerializablePromise - : T extends ReadableStream - ? ValidateReadableStream - : T extends Set - ? ValidateSerializableSet - : T extends Map - ? ValidateSerializableMap - : { - [K in keyof T]: ValidateSerializable - } +export type ValidateSerializable = + T extends ReadonlyArray + ? ResolveArrayShape + : T extends TSerializable + ? T + : T extends (...args: Array) => any + ? 'Function is not serializable' + : T extends Promise + ? ValidateSerializablePromise + : T extends ReadableStream + ? ValidateReadableStream + : T extends Set + ? ValidateSerializableSet + : T extends Map + ? ValidateSerializableMap + : { [K in keyof T]: ValidateSerializable } export type ValidateSerializablePromise = T extends Promise @@ -173,13 +174,15 @@ export type ValidateSerializableInputResult = ValidateSerializableResult> export type ValidateSerializableResult = - T extends TSerializable - ? T - : unknown extends SerializerExtensions['ReadableStream'] - ? { [K in keyof T]: ValidateSerializableResult } - : T extends SerializerExtensions['ReadableStream'] - ? ReadableStream - : { [K in keyof T]: ValidateSerializableResult } + T extends ReadonlyArray + ? ResolveArrayShape + : T extends TSerializable + ? T + : unknown extends SerializerExtensions['ReadableStream'] + ? { [K in keyof T]: ValidateSerializableResult } + : T extends SerializerExtensions['ReadableStream'] + ? ReadableStream + : { [K in keyof T]: ValidateSerializableResult } export type RegisteredSSROption = unknown extends RegisteredConfigType @@ -213,3 +216,32 @@ export type ValidateSerializableLifecycleResultSSR< : RegisteredSSROption extends false ? any : ValidateSerializableInput> + +type ResolveArrayShape< + T extends ReadonlyArray, + TSerializable, + TMode extends 'input' | 'result', +> = number extends T['length'] + ? T extends Array + ? Array> + : ReadonlyArray> + : ResolveTupleShape + +type ResolveTupleShape< + T extends ReadonlyArray, + TSerializable, + TMode extends 'input' | 'result', +> = T extends readonly [infer THead, ...infer TTail] + ? readonly [ + ArrayModeResult, + ...ResolveTupleShape, TSerializable, TMode>, + ] + : T + +type ArrayModeResult< + TMode extends 'input' | 'result', + TValue, + TSerializable, +> = TMode extends 'input' + ? ValidateSerializable + : ValidateSerializableResult diff --git a/packages/router-core/tests/serializer-recursion.test-d.ts b/packages/router-core/tests/serializer-recursion.test-d.ts new file mode 100644 index 00000000000..4df82d5b732 --- /dev/null +++ b/packages/router-core/tests/serializer-recursion.test-d.ts @@ -0,0 +1,80 @@ +import { describe, expectTypeOf, it } from 'vitest' + +import type { + Serializable, + ValidateSerializable, + ValidateSerializableResult, +} from '../src/ssr/serializer/transformer' + +describe('ValidateSerializable array handling', () => { + it('preserves nested array payloads for input validation', () => { + type Input = Array<{ value: string; nested: Array<{ id: number }> }> + expectTypeOf< + ValidateSerializable + >().branded.toEqualTypeOf() + }) + + it('preserves tuple structure for input validation', () => { + type InputTuple = readonly [{ name: string }, { count: number }] + expectTypeOf< + ValidateSerializable + >().branded.toEqualTypeOf() + }) + + it('preserves readonly array structure for input validation', () => { + type InputReadonlyArray = ReadonlyArray<{ value: string }> + expectTypeOf< + ValidateSerializable + >().branded.toEqualTypeOf() + }) + + it('preserves recursive payloads wrapped in Promise for input validation', () => { + type Recursive = { value: number; next?: Recursive } + type InputPromise = Promise + expectTypeOf< + ValidateSerializable + >().branded.toEqualTypeOf() + }) + + it('preserves recursive payloads wrapped in Promise for input validation', () => { + type Recursive = { value: number; children?: Array } + type InputPromiseArray = Promise> + expectTypeOf< + ValidateSerializable + >().branded.toEqualTypeOf() + }) + + it('preserves recursive payloads wrapped in ReadableStream for input validation', () => { + type Recursive = { value: number; next?: Recursive } + type InputStream = ReadableStream + expectTypeOf< + ValidateSerializable + >().branded.toEqualTypeOf() + }) + + it('should preserve recursive payload without infinite expansion', () => { + type Result = Array | { [key: string]: Result } + expectTypeOf< + ValidateSerializableResult + >().branded.toEqualTypeOf() + }) + + it('should preserve recursive tuples without infinite expansion', () => { + type ResultTuple = readonly [ + ReadonlyArray, + { [key: string]: ResultTuple }, + ] + expectTypeOf< + ValidateSerializableResult + >().branded.toEqualTypeOf() + }) + + it('should preserve readonly recursive arrays without infinite expansion', () => { + type ResultReadonlyArray = ReadonlyArray< + ResultReadonlyArray | { [key: string]: ResultReadonlyArray } + > + expectTypeOf< + ValidateSerializableResult + >().branded.toEqualTypeOf() + }) +})