From cc6f60bd609f49a770661e0cfa536359660e2fad Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Thu, 16 Jul 2026 07:06:02 -0300 Subject: [PATCH] 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 df9251a739..037072ad58 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 91011bfa7f..f2abf80e86 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 fff911df37..b9cbb00d6f 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) + }) +})