From 0ff9f9934ae89b3ffc039b9b40292cd3bc6d84c8 Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Mon, 10 Aug 2026 11:19:54 -0300 Subject: [PATCH 1/4] chore: empty commit to refresh mergeability and CI checks From ccca3e5b48b37eb3826af8ab2bebdacdd331a684 Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Thu, 16 Jul 2026 07:06:02 -0300 Subject: [PATCH 2/4] fix(router-core): use safeStringify for loader dependency hash keys Replace JSON.stringify with safeStringify in loaderDepsHash computation to handle types that JSON.stringify cannot serialize (bigint, Set, Map, circular references, functions, symbols, etc.). The previous approach (PR #7818) attempted to use the configured stringifySearch serializer, but as schiller-manuel pointed out, loader deps are not necessarily search params and should not be tied to the search stringifier. This approach is less invasive: it replaces the serializer inline without changing the API or coupling loader deps to search params. Fixes #7787 --- packages/router-core/src/router.ts | 3 +- packages/router-core/src/utils.ts | 50 ++++++++++++++++ packages/router-core/tests/utils.test.ts | 72 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739d..037072ad581 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -13,6 +13,7 @@ import { last, nullReplaceEqualDeep, replaceEqualDeep, + safeStringify, } from './utils' import { buildRouteBranch, @@ -1535,7 +1536,7 @@ export class RouterCore< search: preMatchSearch, }) ?? '' - const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' + const loaderDepsHash = loaderDeps ? safeStringify(loaderDeps) : '' const { interpolatedPath, usedParams } = interpolatePath({ path: route.fullPath, diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 91011bfa7f0..f2abf80e866 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -725,3 +725,53 @@ export function arraysEqual(a: Array, b: Array) { } return true } + +/** + * Safely stringify a value for use as a hash key, handling types that + * JSON.stringify cannot serialize (bigint, Set, Map, circular refs, etc.). + * + * - bigint → string representation (e.g. "123n") + * - Set/Map → sorted array / entries array + * - undefined → empty string + * - Circular references → detected and replaced with "[Circular]" + * - Functions/Symbols → replaced with "[Function]" / "[Symbol]" + */ +export function safeStringify(value: unknown): string { + const seen = new WeakSet() + + function serialize(val: unknown): any { + if (val === null) return null + if (val === undefined) return '' + + if (typeof val === 'bigint') return val.toString() + 'n' + if (typeof val === 'symbol') return val.description ?? '[Symbol]' + if (typeof val === 'function') return '[Function]' + + if (typeof val === 'object') { + if (seen.has(val as object)) return '[Circular]' + seen.add(val as object) + + if (val instanceof Set) { + return Array.from(val).map(serialize) + } + if (val instanceof Map) { + return Array.from(val.entries()).map(([k, v]) => [ + serialize(k), + serialize(v), + ]) + } + if (val instanceof Date) return val.toISOString() + if (Array.isArray(val)) return val.map(serialize) + + const obj: Record = {} + for (const key of Object.keys(val as object).sort()) { + obj[key] = serialize((val as any)[key]) + } + return obj + } + + return val + } + + return JSON.stringify(serialize(value)) +} diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index fff911df377..b9cbb00d6f0 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -6,6 +6,7 @@ import { escapeHtml, isPlainArray, replaceEqualDeep, + safeStringify, } from '../src/utils' describe('replaceEqualDeep', () => { @@ -1047,3 +1048,74 @@ describe('encodePathLikeUrl', () => { ) }) }) + +describe('safeStringify', () => { + it('should stringify plain objects like JSON.stringify', () => { + expect(safeStringify({ a: 1, b: 'hello' })).toBe('{"a":1,"b":"hello"}') + }) + + it('should handle bigint values', () => { + expect(safeStringify({ a: 123n })).toBe('{"a":"123n"}') + }) + + it('should handle Set values', () => { + const result = safeStringify({ a: new Set([3, 1, 2]) }) + const parsed = JSON.parse(result) + expect(parsed.a).toBeInstanceOf(Array) + expect(parsed.a).toHaveLength(3) + }) + + it('should handle Map values', () => { + expect(safeStringify({ a: new Map([['x', 1], ['y', 2]]) })).toBe( + '{"a":[["x",1],["y",2]]}', + ) + }) + + it('should handle undefined values', () => { + expect(safeStringify({ a: undefined })).toBe('{"a":""}') + }) + + it('should handle null values', () => { + expect(safeStringify({ a: null })).toBe('{"a":null}') + }) + + it('should handle circular references without throwing', () => { + const obj: any = { a: 1 } + obj.self = obj + const result = safeStringify(obj) + expect(result).toContain('[Circular]') + }) + + it('should handle functions', () => { + expect(safeStringify({ a: () => {} })).toBe('{"a":"[Function]"}') + }) + + it('should handle symbols', () => { + expect(safeStringify({ a: Symbol('test') })).toBe('{"a":"test"}') + }) + + it('should handle Date objects', () => { + const date = new Date('2024-01-01') + expect(safeStringify({ a: date })).toBe('{"a":"2024-01-01T00:00:00.000Z"}') + }) + + it('should handle nested objects with mixed types', () => { + const val = { + num: 42, + big: 9007199254740993n, + set: new Set(['a', 'b']), + nested: { inner: new Map([[1, 'one']]) }, + } + const result = safeStringify(val) + expect(result).toBe( + '{"big":"9007199254740993n","nested":{"inner":[[1,"one"]]},"num":42,"set":["a","b"]}', + ) + }) + + it('should be deterministic (same input = same output)', () => { + const a = { b: 2, a: 1 } + const result1 = safeStringify(a) + const result2 = safeStringify({ a: 1, b: 2 }) + expect(result1).toBe(result2) + }) +}) From 359fb9bb2115cd4a16c58e3cfccb6e63af755dcd Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Thu, 20 Aug 2026 14:33:03 -0300 Subject: [PATCH 3/4] fix(router-core): make loaderDeps stringifier configurable via stringifyLoaderDeps Replace the forced safeStringify usage in loaderDepsHash with a new stringifyLoaderDeps router option (default JSON.stringify). This keeps the bundle-size impact opt-in instead of adding it for all users, per maintainer feedback on PR #7834. safeStringify stays exported from utils so users with non-serializable loader deps (bigint, Set, Map, circular refs) can pass it. --- packages/router-core/src/index.ts | 1 + packages/router-core/src/router.ts | 19 ++++++- packages/router-core/src/utils.ts | 10 ++-- packages/router-core/tests/callbacks.test.ts | 58 ++++++++++++++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a296629ffa3..3b6aebe7f3e 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -319,6 +319,7 @@ export { escapeHtml, isDangerousProtocol, buildDevStylesUrl, + safeStringify, } from './utils' export type { NoInfer, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7c16ce52be0..e0a852431cb 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -12,7 +12,6 @@ import { last, nullReplaceEqualDeep, replaceEqualDeep, - safeStringify, } from './utils' import { buildRouteBranch, @@ -201,6 +200,19 @@ export interface RouterOptions< * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization) */ parseSearch?: SearchParser + /** + * A function that will be used to stringify `loaderDeps` values when + * computing the `loaderDepsHash` used for loader data caching. + * + * Defaults to `JSON.stringify`, which covers plain-object loader deps. If + * your loader deps contain values that `JSON.stringify` cannot serialize + * (bigint, Set, Map, circular references, functions, symbols, etc.), pass a + * custom serializer such as `safeStringify` so those deps produce a stable + * hash and are cached correctly. + * + * @default JSON.stringify + */ + stringifyLoaderDeps?: SearchSerializer /** * If `false`, routes will not be preloaded by default in any way. * @@ -1098,7 +1110,7 @@ export class RouterCore< TRouterHistory, TDehydrated >, - 'stringifySearch' | 'parseSearch' | 'context' + 'stringifySearch' | 'parseSearch' | 'stringifyLoaderDeps' | 'context' > history!: TRouterHistory rewrite?: LocationRewrite @@ -1145,6 +1157,7 @@ export class RouterCore< notFoundMode: options.notFoundMode ?? 'fuzzy', stringifySearch: options.stringifySearch ?? defaultStringifySearch, parseSearch: options.parseSearch ?? defaultParseSearch, + stringifyLoaderDeps: options.stringifyLoaderDeps ?? JSON.stringify, protocolAllowlist: options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST, }) @@ -1609,7 +1622,7 @@ export class RouterCore< route.options.loaderDeps?.({ search: preMatchSearch, }) ?? '' - loaderDepsHash = loaderDeps ? safeStringify(loaderDeps) || '' : '' + loaderDepsHash = loaderDeps ? this.options.stringifyLoaderDeps(loaderDeps) || '' : '' } catch (cause) { if (opts?.throwOnError) { throw cause diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index ef1d2c1847a..bd661deb1b7 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -754,9 +754,9 @@ export function safeStringify(value: unknown): string { if (typeof val === 'symbol') return val.description ?? '[Symbol]' if (typeof val === 'function') return '[Function]' - if (typeof val === 'object') { - if (seen.has(val as object)) return '[Circular]' - seen.add(val as object) + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) return '[Circular]' + seen.add(val) if (val instanceof Set) { return Array.from(val).map(serialize) @@ -771,8 +771,8 @@ export function safeStringify(value: unknown): string { if (Array.isArray(val)) return val.map(serialize) const obj: Record = {} - for (const key of Object.keys(val as object).sort()) { - obj[key] = serialize((val as any)[key]) + for (const key of Object.keys(val).sort()) { + obj[key] = serialize((val as Record)[key]) } return obj } diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index 7a1b062d4fb..ea66f638c43 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -227,6 +227,64 @@ describe('callbacks', () => { }) }) + // `stringifyLoaderDeps` lets users provide a custom serializer for loader + // deps that contain values JSON.stringify cannot handle (bigint, Set, Map, + // circular refs, etc.). Default is JSON.stringify, so this is opt-in and + // does not add bundle weight for users that don't need it. + describe('stringifyLoaderDeps option', () => { + const createBigintRouter = (stringifyLoaderDeps?: (v: any) => string) => { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loaderDeps: () => ({ id: 123n }), // bigint breaks JSON.stringify + loader: () => 'data', + }) + return createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + stringifyLoaderDeps, + }) + } + + it('uses the custom stringifier for loaderDepsHash', async () => { + const stringifyLoaderDeps = vi.fn((v: any) => JSON.stringify(v)) + const router = createBigintRouter(stringifyLoaderDeps) + + await router.navigate({ to: '/foo' }) + + expect(stringifyLoaderDeps).toHaveBeenCalled() + // bigint must reach the stringifier as-is (not coerced to a plain number) + expect(stringifyLoaderDeps).toHaveBeenCalledWith(expect.objectContaining({ id: 123n })) + }) + + it('produces a stable hash for bigint loader deps via safeStringify', async () => { + const { safeStringify } = await import('../src/utils') + const loader = vi.fn() + const router = createTestRouter({ + routeTree: (() => { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loaderDeps: () => ({ id: 123n }), + loader, + staleTime: 60_000, + gcTime: 60_000, + }) + return rootRoute.addChildren([fooRoute]) + })(), + history: createMemoryHistory(), + stringifyLoaderDeps: safeStringify, + }) + + await router.navigate({ to: '/foo' }) + await router.navigate({ to: '/foo' }) + // stable hash means the loader is cached and not re-run for equal deps + expect(loader).toHaveBeenCalledTimes(1) + }) + }) + // Verify that router-level subscription events still fire correctly. // These are used by integrations like Sentry's TanStack Router instrumentation // (https://github.com/getsentry/sentry-javascript/blob/develop/packages/react/src/tanstackrouter.ts) From 58344426681620861a08445d51794dfb44fb3e8a Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Thu, 20 Aug 2026 14:50:32 -0300 Subject: [PATCH 4/4] fix(router-core): make safeStringify collision-proof and default robust Address CodeRabbit review on PR #7834: - safeStringify now uses a reserved \u0000 tag prefix for special values (bigint, Set, Map, undefined, Date, functions, symbols, circular refs) so distinct values can never hash identically. User strings and keys that start with the prefix are escaped by doubling it, eliminating collisions like bigint 123n vs string '123n'. Regression tests cover each collision class. - stringifyLoaderDeps default is now defaultStringifyLoaderDeps: JSON.stringify for plain deps with a safeStringify fallback when serialization throws (e.g. bigint), so the default no longer crashes on bigint loader deps. - Replace any usage with unknown / typed loader-deps in the serializer and tests for stricter type safety. --- packages/router-core/src/index.ts | 1 + packages/router-core/src/router.ts | 16 ++-- packages/router-core/src/utils.ts | 71 ++++++++++----- packages/router-core/tests/callbacks.test.ts | 50 ++++++++--- packages/router-core/tests/utils.test.ts | 90 +++++++++++++++++--- 5 files changed, 181 insertions(+), 47 deletions(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 3b6aebe7f3e..22f2dd42641 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -320,6 +320,7 @@ export { isDangerousProtocol, buildDevStylesUrl, safeStringify, + defaultStringifyLoaderDeps, } from './utils' export type { NoInfer, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 524eecff2f5..c9bd05b2d8c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -4,6 +4,7 @@ import { DEFAULT_PROTOCOL_ALLOWLIST, decodePath, deepEqual, + defaultStringifyLoaderDeps, encodePathLikeUrl, findLast, functionalUpdate, @@ -203,13 +204,14 @@ export interface RouterOptions< * A function that will be used to stringify `loaderDeps` values when * computing the `loaderDepsHash` used for loader data caching. * - * Defaults to `JSON.stringify`, which covers plain-object loader deps. If - * your loader deps contain values that `JSON.stringify` cannot serialize - * (bigint, Set, Map, circular references, functions, symbols, etc.), pass a - * custom serializer such as `safeStringify` so those deps produce a stable - * hash and are cached correctly. + * Defaults to `defaultStringifyLoaderDeps`, which uses `JSON.stringify` for + * plain-object loader deps and falls back to `safeStringify` when a value + * throws during serialization (e.g. bigint). If your loader deps contain + * values that `JSON.stringify` silently mishandles (Set, Map, Date, circular + * references, etc.), pass a custom serializer such as `safeStringify` so + * those deps produce a stable hash and are cached correctly. * - * @default JSON.stringify + * @default defaultStringifyLoaderDeps */ stringifyLoaderDeps?: SearchSerializer /** @@ -1151,7 +1153,7 @@ export class RouterCore< notFoundMode: options.notFoundMode ?? 'fuzzy', stringifySearch: options.stringifySearch ?? defaultStringifySearch, parseSearch: options.parseSearch ?? defaultParseSearch, - stringifyLoaderDeps: options.stringifyLoaderDeps ?? JSON.stringify, + stringifyLoaderDeps: options.stringifyLoaderDeps ?? defaultStringifyLoaderDeps, protocolAllowlist: options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST, }) diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 6ed57d08e56..261bff0e587 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -733,42 +733,56 @@ export function arraysEqual(a: Array, b: Array) { * Safely stringify a value for use as a hash key, handling types that * JSON.stringify cannot serialize (bigint, Set, Map, circular refs, etc.). * - * - bigint → string representation (e.g. "123n") - * - Set/Map → sorted array / entries array - * - undefined → empty string - * - Circular references → detected and replaced with "[Circular]" - * - Functions/Symbols → replaced with "[Function]" / "[Symbol]" + * - bigint → tagged string (e.g. "\u0000bigint:123") + * - Set/Map → tagged array (e.g. ["\u0000set", [...]]) + * - undefined → tagged string ("\u0000undefined") + * - Circular references → tagged string ("\u0000circular") + * - Functions/Symbols → tagged string + * - Dates → tagged ISO string + * + * Special values use a reserved \u0000 tag prefix. User strings that start + * with \u0000 are escaped by doubling the prefix, so they can never collide + * with a tagged value (e.g. `123n` vs `'123n'` produce different output). */ export function safeStringify(value: unknown): string { const seen = new WeakSet() - function serialize(val: unknown): any { + const escape = (s: string): string => + s.startsWith('\u0000') ? `\u0000${s}` : s + + function serialize(val: unknown): unknown { if (val === null) return null - if (val === undefined) return '' + if (typeof val === 'undefined') return '\u0000undefined' - if (typeof val === 'bigint') return val.toString() + 'n' - if (typeof val === 'symbol') return val.description ?? '[Symbol]' - if (typeof val === 'function') return '[Function]' + if (typeof val === 'string') return escape(val) + if (typeof val === 'bigint') return `\u0000bigint:${val.toString()}` + if (typeof val === 'symbol') { + return val.description ? `\u0000symbol:${val.description}` : '\u0000symbol' + } + if (typeof val === 'function') return '\u0000function' - if (typeof val === 'object' && val !== null) { - if (seen.has(val)) return '[Circular]' + if (typeof val === 'object') { + if (seen.has(val)) return '\u0000circular' seen.add(val) if (val instanceof Set) { - return Array.from(val).map(serialize) + return ['\u0000set', Array.from(val).map(serialize)] } if (val instanceof Map) { - return Array.from(val.entries()).map(([k, v]) => [ - serialize(k), - serialize(v), - ]) + return [ + '\u0000map', + Array.from(val.entries()).map(([k, v]) => [ + serialize(k), + serialize(v), + ]), + ] } - if (val instanceof Date) return val.toISOString() + if (val instanceof Date) return `\u0000date:${val.toISOString()}` if (Array.isArray(val)) return val.map(serialize) - const obj: Record = {} + const obj: Record = {} for (const key of Object.keys(val).sort()) { - obj[key] = serialize((val as Record)[key]) + obj[escape(key)] = serialize((val as Record)[key]) } return obj } @@ -778,3 +792,20 @@ export function safeStringify(value: unknown): string { return JSON.stringify(serialize(value)) } + +/** + * Default serializer for `loaderDeps` hash keys. + * + * Uses `JSON.stringify` for plain-object loader deps (no added bundle weight + * for the common case) and falls back to `safeStringify` when a value throws + * during serialization (e.g. bigint), which would otherwise crash the hash + * computation. + */ +export function defaultStringifyLoaderDeps(value: Record): string { + if (!value) return '' + try { + return JSON.stringify(value) || '' + } catch { + return safeStringify(value) + } +} diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index ea66f638c43..c878ea1f1db 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' +import { BaseRootRoute, BaseRoute, type SearchSerializer } from '../src' import { createTestRouter } from './routerTestUtils' describe('callbacks', () => { @@ -229,15 +229,18 @@ describe('callbacks', () => { // `stringifyLoaderDeps` lets users provide a custom serializer for loader // deps that contain values JSON.stringify cannot handle (bigint, Set, Map, - // circular refs, etc.). Default is JSON.stringify, so this is opt-in and - // does not add bundle weight for users that don't need it. + // circular refs, etc.). Default is defaultStringifyLoaderDeps (JSON.stringify + // with a safeStringify fallback), so this stays opt-in and does not add + // bundle weight for users that don't need it. describe('stringifyLoaderDeps option', () => { - const createBigintRouter = (stringifyLoaderDeps?: (v: any) => string) => { + type LoaderDeps = { id: bigint } + + const createBigintRouter = (stringifyLoaderDeps?: SearchSerializer) => { const rootRoute = new BaseRootRoute({}) const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', - loaderDeps: () => ({ id: 123n }), // bigint breaks JSON.stringify + loaderDeps: (): LoaderDeps => ({ id: 123n }), // bigint breaks JSON.stringify loader: () => 'data', }) return createTestRouter({ @@ -247,15 +250,42 @@ describe('callbacks', () => { }) } - it('uses the custom stringifier for loaderDepsHash', async () => { - const stringifyLoaderDeps = vi.fn((v: any) => JSON.stringify(v)) + it('uses the custom stringifier for loaderDepsHash and navigates successfully', async () => { + const { safeStringify } = await import('../src/utils') + const stringifyLoaderDeps = vi.fn((v: Record) => safeStringify(v)) const router = createBigintRouter(stringifyLoaderDeps) - await router.navigate({ to: '/foo' }) + await expect(router.navigate({ to: '/foo' })).resolves.not.toThrow() expect(stringifyLoaderDeps).toHaveBeenCalled() // bigint must reach the stringifier as-is (not coerced to a plain number) - expect(stringifyLoaderDeps).toHaveBeenCalledWith(expect.objectContaining({ id: 123n })) + expect(stringifyLoaderDeps).toHaveBeenCalledWith( + expect.objectContaining({ id: 123n }), + ) + }) + + it('default serializer handles bigint loader deps without throwing', async () => { + const loader = vi.fn() + const router = createTestRouter({ + routeTree: (() => { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loaderDeps: (): LoaderDeps => ({ id: 123n }), + loader, + staleTime: 60_000, + gcTime: 60_000, + }) + return rootRoute.addChildren([fooRoute]) + })(), + history: createMemoryHistory(), + }) + + await expect(router.navigate({ to: '/foo' })).resolves.not.toThrow() + await router.navigate({ to: '/foo' }) + // stable hash means the loader is cached and not re-run for equal deps + expect(loader).toHaveBeenCalledTimes(1) }) it('produces a stable hash for bigint loader deps via safeStringify', async () => { @@ -267,7 +297,7 @@ describe('callbacks', () => { const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', - loaderDeps: () => ({ id: 123n }), + loaderDeps: (): LoaderDeps => ({ id: 123n }), loader, staleTime: 60_000, gcTime: 60_000, diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 63cc5fa2e15..27392681e89 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { decodePath, deepEqual, + defaultStringifyLoaderDeps, encodePathLikeUrl, escapeHtml, isPlainArray, @@ -1102,24 +1103,23 @@ describe('safeStringify', () => { }) it('should handle bigint values', () => { - expect(safeStringify({ a: 123n })).toBe('{"a":"123n"}') + expect(safeStringify({ a: 123n })).toBe('{"a":"\\u0000bigint:123"}') }) it('should handle Set values', () => { const result = safeStringify({ a: new Set([3, 1, 2]) }) const parsed = JSON.parse(result) - expect(parsed.a).toBeInstanceOf(Array) - expect(parsed.a).toHaveLength(3) + expect(parsed.a).toEqual(['\u0000set', [3, 1, 2]]) }) it('should handle Map values', () => { expect(safeStringify({ a: new Map([['x', 1], ['y', 2]]) })).toBe( - '{"a":[["x",1],["y",2]]}', + '{"a":["\\u0000map",[["x",1],["y",2]]]}', ) }) it('should handle undefined values', () => { - expect(safeStringify({ a: undefined })).toBe('{"a":""}') + expect(safeStringify({ a: undefined })).toBe('{"a":"\\u0000undefined"}') }) it('should handle null values', () => { @@ -1130,20 +1130,26 @@ describe('safeStringify', () => { const obj: any = { a: 1 } obj.self = obj const result = safeStringify(obj) - expect(result).toContain('[Circular]') + expect(result).toContain('\\u0000circular') }) it('should handle functions', () => { - expect(safeStringify({ a: () => {} })).toBe('{"a":"[Function]"}') + expect(safeStringify({ a: () => {} })).toBe('{"a":"\\u0000function"}') }) it('should handle symbols', () => { - expect(safeStringify({ a: Symbol('test') })).toBe('{"a":"test"}') + expect(safeStringify({ a: Symbol('test') })).toBe('{"a":"\\u0000symbol:test"}') + }) + + it('should handle symbols without description', () => { + expect(safeStringify({ a: Symbol() })).toBe('{"a":"\\u0000symbol"}') }) it('should handle Date objects', () => { const date = new Date('2024-01-01') - expect(safeStringify({ a: date })).toBe('{"a":"2024-01-01T00:00:00.000Z"}') + expect(safeStringify({ a: date })).toBe( + '{"a":"\\u0000date:2024-01-01T00:00:00.000Z"}', + ) }) it('should handle nested objects with mixed types', () => { @@ -1155,7 +1161,7 @@ describe('safeStringify', () => { } const result = safeStringify(val) expect(result).toBe( - '{"big":"9007199254740993n","nested":{"inner":[[1,"one"]]},"num":42,"set":["a","b"]}', + '{"big":"\\u0000bigint:9007199254740993","nested":{"inner":["\\u0000map",[[1,"one"]]]},"num":42,"set":["\\u0000set",["a","b"]]}', ) }) @@ -1165,4 +1171,68 @@ describe('safeStringify', () => { const result2 = safeStringify({ a: 1, b: 2 }) expect(result1).toBe(result2) }) + + it('should not collide bigint with an equal-looking string', () => { + expect(safeStringify({ a: 123n })).not.toBe(safeStringify({ a: '123n' })) + expect(safeStringify({ a: '123n' })).toBe('{"a":"123n"}') + }) + + it('should not collide undefined with empty string', () => { + expect(safeStringify({ a: undefined })).not.toBe( + safeStringify({ a: '' }), + ) + expect(safeStringify({ a: '' })).toBe('{"a":""}') + }) + + it('should not collide a Date with an equal ISO string', () => { + const iso = '2024-01-01T00:00:00.000Z' + expect(safeStringify({ a: new Date(iso) })).not.toBe( + safeStringify({ a: iso }), + ) + expect(safeStringify({ a: iso })).toBe(`{"a":"${iso}"}`) + }) + + it('should not collide a Set with an array that looks like a tag', () => { + const setResult = safeStringify({ a: new Set([1, 2]) }) + const arrayResult = safeStringify({ a: ['\u0000set', [1, 2]] }) + expect(setResult).not.toBe(arrayResult) + }) + + it('should escape user strings and keys that start with the reserved prefix', () => { + // input has a real null byte (the reserved prefix) + expect(safeStringify({ '\u0000key': '\u0000value' })).toBe( + '{"\\u0000\\u0000key":"\\u0000\\u0000value"}', + ) + }) + + it('should not collide a bigint tag with an escaped user string', () => { + expect(safeStringify({ a: 123n })).not.toBe( + safeStringify({ a: '\u0000bigint:123' }), + ) + }) +}) + + +describe('defaultStringifyLoaderDeps', () => { + it('returns an empty string for falsy deps', () => { + expect(defaultStringifyLoaderDeps(undefined as any)).toBe('') + expect(defaultStringifyLoaderDeps(null as any)).toBe('') + }) + + it('stringifies plain objects with JSON.stringify', () => { + expect(defaultStringifyLoaderDeps({ a: 1, b: 'x' })).toBe('{"a":1,"b":"x"}') + }) + + it('falls back to safeStringify for bigint deps instead of throwing', () => { + expect(defaultStringifyLoaderDeps({ id: 123n })).toBe( + '{"id":"\\u0000bigint:123"}', + ) + }) + + it('keeps JSON.stringify behavior for Set deps (serialized as empty object)', () => { + // JSON.stringify does not throw on Set - it silently serializes it as {}. + // The default keeps that behavior; users with Set loader deps should pass + // safeStringify explicitly via stringifyLoaderDeps. + expect(defaultStringifyLoaderDeps({ set: new Set([1, 2]) })).toBe('{"set":{}}') + }) })