Skip to content

Commit 98a02d7

Browse files
authored
fix(router-core): avoid infinite serializer recursion for arrays (#5199)
Fixes some issues I was having with https://conform.guide (also fixes #4533) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Compile-time serialization validation now recognizes readonly arrays and tuples for inputs and results, preserving tuple shapes and improving type inference and editor experience; no runtime changes. * **Tests** * Added type-level tests covering recursive, nested, readonly array and tuple scenarios to verify compile-time validation and type propagation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 49e5fad commit 98a02d7

File tree

2 files changed

+134
-22
lines changed

2 files changed

+134
-22
lines changed

packages/router-core/src/ssr/serializer/transformer.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,22 @@ export interface CreateSerializationAdapterOptions<TInput, TOutput> {
3030
fromSerializable: (value: TOutput) => TInput
3131
}
3232

33-
export type ValidateSerializable<T, TSerializable> = T extends TSerializable
34-
? T
35-
: T extends (...args: Array<any>) => any
36-
? 'Function is not serializable'
37-
: T extends Promise<any>
38-
? ValidateSerializablePromise<T, TSerializable>
39-
: T extends ReadableStream<any>
40-
? ValidateReadableStream<T, TSerializable>
41-
: T extends Set<any>
42-
? ValidateSerializableSet<T, TSerializable>
43-
: T extends Map<any, any>
44-
? ValidateSerializableMap<T, TSerializable>
45-
: {
46-
[K in keyof T]: ValidateSerializable<T[K], TSerializable>
47-
}
33+
export type ValidateSerializable<T, TSerializable> =
34+
T extends ReadonlyArray<unknown>
35+
? ResolveArrayShape<T, TSerializable, 'input'>
36+
: T extends TSerializable
37+
? T
38+
: T extends (...args: Array<any>) => any
39+
? 'Function is not serializable'
40+
: T extends Promise<any>
41+
? ValidateSerializablePromise<T, TSerializable>
42+
: T extends ReadableStream<any>
43+
? ValidateReadableStream<T, TSerializable>
44+
: T extends Set<any>
45+
? ValidateSerializableSet<T, TSerializable>
46+
: T extends Map<any, any>
47+
? ValidateSerializableMap<T, TSerializable>
48+
: { [K in keyof T]: ValidateSerializable<T[K], TSerializable> }
4849

4950
export type ValidateSerializablePromise<T, TSerializable> =
5051
T extends Promise<infer TAwaited>
@@ -173,13 +174,15 @@ export type ValidateSerializableInputResult<TRegister, T> =
173174
ValidateSerializableResult<T, RegisteredSerializableInput<TRegister>>
174175

175176
export type ValidateSerializableResult<T, TSerializable> =
176-
T extends TSerializable
177-
? T
178-
: unknown extends SerializerExtensions['ReadableStream']
179-
? { [K in keyof T]: ValidateSerializableResult<T[K], TSerializable> }
180-
: T extends SerializerExtensions['ReadableStream']
181-
? ReadableStream
182-
: { [K in keyof T]: ValidateSerializableResult<T[K], TSerializable> }
177+
T extends ReadonlyArray<unknown>
178+
? ResolveArrayShape<T, TSerializable, 'result'>
179+
: T extends TSerializable
180+
? T
181+
: unknown extends SerializerExtensions['ReadableStream']
182+
? { [K in keyof T]: ValidateSerializableResult<T[K], TSerializable> }
183+
: T extends SerializerExtensions['ReadableStream']
184+
? ReadableStream
185+
: { [K in keyof T]: ValidateSerializableResult<T[K], TSerializable> }
183186

184187
export type RegisteredSSROption<TRegister> =
185188
unknown extends RegisteredConfigType<TRegister, 'defaultSsr'>
@@ -213,3 +216,32 @@ export type ValidateSerializableLifecycleResultSSR<
213216
: RegisteredSSROption<TRegister> extends false
214217
? any
215218
: ValidateSerializableInput<TRegister, LooseReturnType<TFn>>
219+
220+
type ResolveArrayShape<
221+
T extends ReadonlyArray<unknown>,
222+
TSerializable,
223+
TMode extends 'input' | 'result',
224+
> = number extends T['length']
225+
? T extends Array<infer U>
226+
? Array<ArrayModeResult<TMode, U, TSerializable>>
227+
: ReadonlyArray<ArrayModeResult<TMode, T[number], TSerializable>>
228+
: ResolveTupleShape<T, TSerializable, TMode>
229+
230+
type ResolveTupleShape<
231+
T extends ReadonlyArray<unknown>,
232+
TSerializable,
233+
TMode extends 'input' | 'result',
234+
> = T extends readonly [infer THead, ...infer TTail]
235+
? readonly [
236+
ArrayModeResult<TMode, THead, TSerializable>,
237+
...ResolveTupleShape<Readonly<TTail>, TSerializable, TMode>,
238+
]
239+
: T
240+
241+
type ArrayModeResult<
242+
TMode extends 'input' | 'result',
243+
TValue,
244+
TSerializable,
245+
> = TMode extends 'input'
246+
? ValidateSerializable<TValue, TSerializable>
247+
: ValidateSerializableResult<TValue, TSerializable>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
3+
import type {
4+
Serializable,
5+
ValidateSerializable,
6+
ValidateSerializableResult,
7+
} from '../src/ssr/serializer/transformer'
8+
9+
describe('ValidateSerializable array handling', () => {
10+
it('preserves nested array payloads for input validation', () => {
11+
type Input = Array<{ value: string; nested: Array<{ id: number }> }>
12+
expectTypeOf<
13+
ValidateSerializable<Input, Serializable>
14+
>().branded.toEqualTypeOf<Input>()
15+
})
16+
17+
it('preserves tuple structure for input validation', () => {
18+
type InputTuple = readonly [{ name: string }, { count: number }]
19+
expectTypeOf<
20+
ValidateSerializable<InputTuple, Serializable>
21+
>().branded.toEqualTypeOf<InputTuple>()
22+
})
23+
24+
it('preserves readonly array structure for input validation', () => {
25+
type InputReadonlyArray = ReadonlyArray<{ value: string }>
26+
expectTypeOf<
27+
ValidateSerializable<InputReadonlyArray, Serializable>
28+
>().branded.toEqualTypeOf<InputReadonlyArray>()
29+
})
30+
31+
it('preserves recursive payloads wrapped in Promise for input validation', () => {
32+
type Recursive = { value: number; next?: Recursive }
33+
type InputPromise = Promise<Recursive>
34+
expectTypeOf<
35+
ValidateSerializable<InputPromise, Serializable>
36+
>().branded.toEqualTypeOf<InputPromise>()
37+
})
38+
39+
it('preserves recursive payloads wrapped in Promise<Array> for input validation', () => {
40+
type Recursive = { value: number; children?: Array<Recursive> }
41+
type InputPromiseArray = Promise<Array<Recursive>>
42+
expectTypeOf<
43+
ValidateSerializable<InputPromiseArray, Serializable>
44+
>().branded.toEqualTypeOf<InputPromiseArray>()
45+
})
46+
47+
it('preserves recursive payloads wrapped in ReadableStream for input validation', () => {
48+
type Recursive = { value: number; next?: Recursive }
49+
type InputStream = ReadableStream<Recursive>
50+
expectTypeOf<
51+
ValidateSerializable<InputStream, Serializable>
52+
>().branded.toEqualTypeOf<InputStream>()
53+
})
54+
55+
it('should preserve recursive payload without infinite expansion', () => {
56+
type Result = Array<Result> | { [key: string]: Result }
57+
expectTypeOf<
58+
ValidateSerializableResult<Result, Serializable>
59+
>().branded.toEqualTypeOf<Result>()
60+
})
61+
62+
it('should preserve recursive tuples without infinite expansion', () => {
63+
type ResultTuple = readonly [
64+
ReadonlyArray<ResultTuple>,
65+
{ [key: string]: ResultTuple },
66+
]
67+
expectTypeOf<
68+
ValidateSerializableResult<ResultTuple, Serializable>
69+
>().branded.toEqualTypeOf<ResultTuple>()
70+
})
71+
72+
it('should preserve readonly recursive arrays without infinite expansion', () => {
73+
type ResultReadonlyArray = ReadonlyArray<
74+
ResultReadonlyArray | { [key: string]: ResultReadonlyArray }
75+
>
76+
expectTypeOf<
77+
ValidateSerializableResult<ResultReadonlyArray, Serializable>
78+
>().branded.toEqualTypeOf<ResultReadonlyArray>()
79+
})
80+
})

0 commit comments

Comments
 (0)