diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a296629ffa..22f2dd4264 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -319,6 +319,8 @@ export { escapeHtml, 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 900609098e..817ebec350 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, @@ -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. * @@ -1093,7 +1108,7 @@ export class RouterCore< TRouterHistory, TDehydrated >, - 'stringifySearch' | 'parseSearch' | 'context' + 'stringifySearch' | 'parseSearch' | 'stringifyLoaderDeps' | 'context' > history!: TRouterHistory rewrite?: LocationRewrite @@ -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, }) @@ -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 diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 997c661bb5..261bff0e58 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -728,3 +728,84 @@ 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 → 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() + + 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 = {} + for (const key of Object.keys(val).sort()) { + obj[escape(key)] = serialize((val as Record)[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 { + 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 7a1b062d4f..c878ea1f1d 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', () => { @@ -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) => 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) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 9e5c14c413..27392681e8 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -2,10 +2,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { decodePath, deepEqual, + defaultStringifyLoaderDeps, encodePathLikeUrl, escapeHtml, isPlainArray, replaceEqualDeep, + safeStringify, } from '../src/utils' describe('replaceEqualDeep', () => { @@ -1094,3 +1096,143 @@ 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":"\\u0000bigint:123"}') + }) + + it('should handle Set values', () => { + const result = safeStringify({ a: new Set([3, 1, 2]) }) + const parsed = JSON.parse(result) + expect(parsed.a).toEqual(['\u0000set', [3, 1, 2]]) + }) + + it('should handle Map values', () => { + expect(safeStringify({ a: new Map([['x', 1], ['y', 2]]) })).toBe( + '{"a":["\\u0000map",[["x",1],["y",2]]]}', + ) + }) + + it('should handle undefined values', () => { + expect(safeStringify({ a: undefined })).toBe('{"a":"\\u0000undefined"}') + }) + + 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('\\u0000circular') + }) + + it('should handle functions', () => { + expect(safeStringify({ a: () => {} })).toBe('{"a":"\\u0000function"}') + }) + + it('should handle symbols', () => { + 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":"\\u0000date: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":"\\u0000bigint:9007199254740993","nested":{"inner":["\\u0000map",[[1,"one"]]]},"num":42,"set":["\\u0000set",["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) + }) + + 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":{}}') + }) +})