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) + }) +})