From 4e78e573e1a17ec56e30aba3cde849c6dae67bce Mon Sep 17 00:00:00 2001 From: gonzoblasco Date: Tue, 14 Jul 2026 22:19:27 -0300 Subject: [PATCH 1/2] fix(router-core): use configured search serializer for loader dependency keys The loaderDepsHash was hardcoded to JSON.stringify, ignoring the router's configured stringifySearch option. This meant custom search serializers that support values JSON.stringify cannot handle (e.g. bigint, Set, Map) would fail when those values were used in loaderDeps. Replace JSON.stringify(loaderDeps) with this.options.stringifySearch( loaderDeps) so the hash uses the same serializer configured for URL search state. This continues the work started in #4713. Add regression tests verifying: - Custom serializer with bigint support works for loaderDeps - Different deps values produce different match IDs Fixes #7787 --- packages/router-core/src/router.ts | 4 +- .../tests/loader-deps-serializer.test.ts | 97 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 packages/router-core/tests/loader-deps-serializer.test.ts diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739..1b4c172907 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1535,7 +1535,9 @@ export class RouterCore< search: preMatchSearch, }) ?? '' - const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' + const loaderDepsHash = loaderDeps + ? this.options.stringifySearch(loaderDeps) + : '' const { interpolatedPath, usedParams } = interpolatePath({ path: route.fullPath, diff --git a/packages/router-core/tests/loader-deps-serializer.test.ts b/packages/router-core/tests/loader-deps-serializer.test.ts new file mode 100644 index 0000000000..1b65d1a4f5 --- /dev/null +++ b/packages/router-core/tests/loader-deps-serializer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, stringifySearchWith } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('loaderDepsHash uses configured search serializer', () => { + it('uses stringifySearch for loaderDepsHash instead of JSON.stringify', async () => { + // Custom serializer that supports bigint (which JSON.stringify cannot handle) + const customStringify = (value: any): string => { + if (typeof value === 'bigint') { + return `"${value.toString()}n"` + } + if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value).map( + ([k, v]) => `${k}:${customStringify(v as any)}`, + ) + return `{${entries.join(',')}}` + } + return JSON.stringify(value) + } + + const customStringifySearch = stringifySearchWith(customStringify) + + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + validateSearch: () => ({ count: 0n as bigint }), + loaderDeps: ({ search }) => ({ + count: search.count, + }), + loader: () => 'loaded', + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + stringifySearch: customStringifySearch, + }) + + // This should not throw "Do not know how to serialize a BigInt" + // because the loaderDepsHash should use customStringifySearch + // instead of JSON.stringify + await router.navigate({ to: '/foo' }) + + // Find the foo route match (not the root) + const fooMatch = router.state.matches.find( + (m) => m.routeId === fooRoute.id, + ) + expect(fooMatch).toBeDefined() + }) + + it('produces different match IDs for different serialized deps values', async () => { + const loaderCalls: Array = [] + + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + validateSearch: (input: any) => ({ filter: (input?.filter ?? '') as string }), + loaderDeps: ({ search }) => ({ + filter: search.filter, + }), + loader: ({ deps }) => { + loaderCalls.push(deps.filter) + return 'loaded' + }, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo', search: { filter: 'all' } }) + const firstFooMatch = router.state.matches.find( + (m) => m.routeId === fooRoute.id, + ) + const firstMatchId = firstFooMatch?.id + + await router.navigate({ to: '/foo', search: { filter: 'active' } }) + const secondFooMatch = router.state.matches.find( + (m) => m.routeId === fooRoute.id, + ) + const secondMatchId = secondFooMatch?.id + + // Different deps should produce different match IDs (different hashes) + expect(firstMatchId).not.toBe(secondMatchId) + expect(loaderCalls).toEqual(['all', 'active']) + }) +}) From beb40e4ea7d3886ae5e993c8da1c447def9bb225 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:59:31 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- packages/router-core/tests/loader-deps-serializer.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/router-core/tests/loader-deps-serializer.test.ts b/packages/router-core/tests/loader-deps-serializer.test.ts index 1b65d1a4f5..4a9de00faf 100644 --- a/packages/router-core/tests/loader-deps-serializer.test.ts +++ b/packages/router-core/tests/loader-deps-serializer.test.ts @@ -47,9 +47,7 @@ describe('loaderDepsHash uses configured search serializer', () => { await router.navigate({ to: '/foo' }) // Find the foo route match (not the root) - const fooMatch = router.state.matches.find( - (m) => m.routeId === fooRoute.id, - ) + const fooMatch = router.state.matches.find((m) => m.routeId === fooRoute.id) expect(fooMatch).toBeDefined() }) @@ -61,7 +59,9 @@ describe('loaderDepsHash uses configured search serializer', () => { const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', - validateSearch: (input: any) => ({ filter: (input?.filter ?? '') as string }), + validateSearch: (input: any) => ({ + filter: (input?.filter ?? '') as string, + }), loaderDeps: ({ search }) => ({ filter: search.filter, }),