Skip to content

buildRouteTree sorts sibling routes with an inconsistent comparator ((d) => d), producing non-deterministic routeTree.gen.ts ordering #7750

Description

@0x80

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions