Which project does this relate to?
@tanstack/router-generator (affects @tanstack/react-start / @tanstack/router-plugin codegen). Reproduced on main and in the latest release (1.167.18).
Describe the bug
The generated routeTree.gen.ts reorders sibling routes non-deterministically across machines/CI, even with identical route files. Two routes that tie on segment-count and index-token (e.g. /account/beta-features and /account/busy-guard) swap places between generations, dirtying the committed route tree with semantically-identical churn. In a shared repo this repeatedly leaks unrelated route-tree diffs into PRs, since each author's machine may regenerate a different order than what's committed.
It is not filesystem-read-order dependent (a stable read order still hits it) — the cause is a broken comparator, so the resulting order depends on V8's sort internals, which vary by Node version and by the surrounding set of routes.
Root cause
In packages/router-generator/src/generator.ts, buildRouteTree sorts the route nodes with multiSortBy, whose final accessor is (d) => d:
const sortedRouteNodes = multiSortBy(acc.routeNodes, [
(d) => (d.routePath?.includes(`/${rootPathId}`) ? -1 : 1),
(d) =>
d.routePath === undefined
? undefined
: countSlashSeparatedParts(d.routePath),
(d) => {
const segments = d.routePath?.split('/').filter(Boolean) ?? []
const last = segments[segments.length - 1] ?? ''
return indexTokenSegmentRegex.test(last) ? -1 : 1
},
(d) => d, // <-- compares whole route-node objects
])
multiSortBy's comparator ends with return ao > bo ? 1 : -1. When ao/bo are route-node objects, both coerce to the string "[object Object]", so ao === bo is false (distinct references) and ao > bo is false — meaning it returns -1 for both (a, b) and (b, a). That is an inconsistent comparator, so Array.prototype.sort leaves routes that tie on the earlier keys in an order that depends on the engine's sort implementation, the input order, and the array size — hence the cross-machine churn.
Note the pre-sort in the same file ends with (d) => d.routePath (a proper string total order), which is almost certainly what buildRouteTree's sort intended as well.
Your Example Website or App / Steps to Reproduce
Any file-based project with two sibling routes that share a parent and segment depth (e.g. src/routes/account.beta-features.tsx and src/routes/account.busy-guard.tsx). Generate the route tree on two different Node versions (or after adding/removing an unrelated route) and diff routeTree.gen.ts — the two entries swap places with no other change.
Expected behavior
A fresh generation is byte-identical regardless of Node version or route-set composition, so the committed routeTree.gen.ts stays stable.
Proposed fix
Change the final accessor in buildRouteTree's multiSortBy from (d) => d to (d) => d.routePath, mirroring the pre-sort and giving the tiebreaker a total order. PR incoming.
Platform
- router-generator:
1.167.18 (and main)
- Reproduced across Node 20 / 22 / 24
Which project does this relate to?
@tanstack/router-generator(affects@tanstack/react-start/@tanstack/router-plugincodegen). Reproduced onmainand in the latest release (1.167.18).Describe the bug
The generated
routeTree.gen.tsreorders sibling routes non-deterministically across machines/CI, even with identical route files. Two routes that tie on segment-count and index-token (e.g./account/beta-featuresand/account/busy-guard) swap places between generations, dirtying the committed route tree with semantically-identical churn. In a shared repo this repeatedly leaks unrelated route-tree diffs into PRs, since each author's machine may regenerate a different order than what's committed.It is not filesystem-read-order dependent (a stable read order still hits it) — the cause is a broken comparator, so the resulting order depends on V8's sort internals, which vary by Node version and by the surrounding set of routes.
Root cause
In
packages/router-generator/src/generator.ts,buildRouteTreesorts the route nodes withmultiSortBy, whose final accessor is(d) => d:multiSortBy's comparator ends withreturn ao > bo ? 1 : -1. Whenao/boare route-node objects, both coerce to the string"[object Object]", soao === boisfalse(distinct references) andao > boisfalse— meaning it returns-1for both(a, b)and(b, a). That is an inconsistent comparator, soArray.prototype.sortleaves routes that tie on the earlier keys in an order that depends on the engine's sort implementation, the input order, and the array size — hence the cross-machine churn.Note the pre-sort in the same file ends with
(d) => d.routePath(a proper string total order), which is almost certainly whatbuildRouteTree's sort intended as well.Your Example Website or App / Steps to Reproduce
Any file-based project with two sibling routes that share a parent and segment depth (e.g.
src/routes/account.beta-features.tsxandsrc/routes/account.busy-guard.tsx). Generate the route tree on two different Node versions (or after adding/removing an unrelated route) and diffrouteTree.gen.ts— the two entries swap places with no other change.Expected behavior
A fresh generation is byte-identical regardless of Node version or route-set composition, so the committed
routeTree.gen.tsstays stable.Proposed fix
Change the final accessor in
buildRouteTree'smultiSortByfrom(d) => dto(d) => d.routePath, mirroring the pre-sort and giving the tiebreaker a total order. PR incoming.Platform
1.167.18(andmain)