Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
last,
nullReplaceEqualDeep,
replaceEqualDeep,
safeStringify,
} from './utils'
import {
buildRouteBranch,
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,3 +725,53 @@ export function arraysEqual<T>(a: Array<T>, b: Array<T>) {
}
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<object>()

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<string, any> = {}
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))
}
72 changes: 72 additions & 0 deletions packages/router-core/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
escapeHtml,
isPlainArray,
replaceEqualDeep,
safeStringify,
} from '../src/utils'

describe('replaceEqualDeep', () => {
Expand Down Expand Up @@ -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)
})
})