Skip to content
2 changes: 2 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ export {
escapeHtml,
isDangerousProtocol,
buildDevStylesUrl,
safeStringify,
defaultStringifyLoaderDeps,
} from './utils'
export type {
NoInfer,
Expand Down
20 changes: 18 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DEFAULT_PROTOCOL_ALLOWLIST,
decodePath,
deepEqual,
defaultStringifyLoaderDeps,
encodePathLikeUrl,
findLast,
functionalUpdate,
Expand Down Expand Up @@ -199,6 +200,20 @@ 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 `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 defaultStringifyLoaderDeps
*/
stringifyLoaderDeps?: SearchSerializer
/**
* If `false`, routes will not be preloaded by default in any way.
*
Expand Down Expand Up @@ -1093,7 +1108,7 @@ export class RouterCore<
TRouterHistory,
TDehydrated
>,
'stringifySearch' | 'parseSearch' | 'context'
'stringifySearch' | 'parseSearch' | 'stringifyLoaderDeps' | 'context'
>
history!: TRouterHistory
rewrite?: LocationRewrite
Expand Down Expand Up @@ -1140,6 +1155,7 @@ export class RouterCore<
notFoundMode: options.notFoundMode ?? 'fuzzy',
stringifySearch: options.stringifySearch ?? defaultStringifySearch,
parseSearch: options.parseSearch ?? defaultParseSearch,
stringifyLoaderDeps: options.stringifyLoaderDeps ?? defaultStringifyLoaderDeps,
protocolAllowlist:
options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST,
})
Expand Down Expand Up @@ -1604,7 +1620,7 @@ export class RouterCore<
route.options.loaderDeps?.({
search: preMatchSearch,
}) ?? ''
loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) || '' : ''
loaderDepsHash = loaderDeps ? this.options.stringifyLoaderDeps(loaderDeps) || '' : ''
} catch (cause) {
if (opts?.throwOnError) {
throw cause
Expand Down
81 changes: 81 additions & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,3 +728,84 @@ 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 → 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<object>()

const escape = (s: string): string =>
s.startsWith('\u0000') ? `\u0000${s}` : s

function serialize(val: unknown): unknown {
if (val === null) return null
if (typeof val === 'undefined') return '\u0000undefined'

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') {
if (seen.has(val)) return '\u0000circular'
seen.add(val)

if (val instanceof Set) {
return ['\u0000set', Array.from(val).map(serialize)]
}
if (val instanceof Map) {
return [
'\u0000map',
Array.from(val.entries()).map(([k, v]) => [
serialize(k),
serialize(v),
]),
]
}
if (val instanceof Date) return `\u0000date:${val.toISOString()}`
if (Array.isArray(val)) return val.map(serialize)

const obj: Record<string, unknown> = {}
for (const key of Object.keys(val).sort()) {
obj[escape(key)] = serialize((val as Record<string, unknown>)[key])
}
return obj
}

return val
}

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, any>): string {
if (!value) return ''
try {
return JSON.stringify(value) || ''
} catch {
return safeStringify(value)
}
}
90 changes: 89 additions & 1 deletion packages/router-core/tests/callbacks.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -227,6 +227,94 @@ 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 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', () => {
type LoaderDeps = { id: bigint }

const createBigintRouter = (stringifyLoaderDeps?: SearchSerializer) => {
const rootRoute = new BaseRootRoute({})
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/foo',
loaderDeps: (): 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 and navigates successfully', async () => {
const { safeStringify } = await import('../src/utils')
const stringifyLoaderDeps = vi.fn((v: Record<string, any>) => safeStringify(v))
const router = createBigintRouter(stringifyLoaderDeps)

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

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 () => {
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: (): 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)
Expand Down
Loading