From 104a7b3d0ba54dbb9eaa5f8e75aa9c04e7330e36 Mon Sep 17 00:00:00 2001 From: lupas Date: Sat, 11 Oct 2025 14:33:16 +0200 Subject: [PATCH 1/6] fix(#688): Fixed non-deterministic sort order of typed-router.d.ts --- src/codegen/generateRouteFileInfoMap.ts | 15 ++- src/core/sortDts.spec.ts | 160 ++++++++++++++++++++++++ src/core/sortDts.ts | 62 +++++++++ 3 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 src/core/sortDts.spec.ts create mode 100644 src/core/sortDts.ts diff --git a/src/codegen/generateRouteFileInfoMap.ts b/src/codegen/generateRouteFileInfoMap.ts index 6bd9bb465..479b4ad4d 100644 --- a/src/codegen/generateRouteFileInfoMap.ts +++ b/src/codegen/generateRouteFileInfoMap.ts @@ -1,6 +1,7 @@ import { relative } from 'pathe' import type { PrefixTree, TreeNode } from '../core/tree' import { formatMultilineUnion, stringToStringType } from '../utils' +import { comparePaths } from '../core/sortDts' export function generateRouteFileInfoMap( node: PrefixTree, @@ -39,16 +40,18 @@ export function generateRouteFileInfoMap( } const code = Array.from(routesInfo.entries()) - .map( - ([file, { routes, views }]) => - ` + .sort(([fa], [fb]) => comparePaths(fa, fb)) + .map(([file, { routes, views }]) => { + const routesSorted = [...routes].sort(comparePaths) + const viewsSorted = [...views].sort(comparePaths) + return ` '${file}': { routes: - ${formatMultilineUnion(routes.map(stringToStringType), 6)} + ${formatMultilineUnion(routesSorted.map(stringToStringType), 6)} views: - ${formatMultilineUnion(views.map(stringToStringType), 6)} + ${formatMultilineUnion(viewsSorted.map(stringToStringType), 6)} }` - ) + }) .join('\n') return `export interface _RouteFileInfoMap { diff --git a/src/core/sortDts.spec.ts b/src/core/sortDts.spec.ts new file mode 100644 index 000000000..6dd14ee5c --- /dev/null +++ b/src/core/sortDts.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest' +import { comparePaths, sortPaths } from './sortDts' + +/** + * Helper for snapshot-friendly, readable arrays of paths. + * It formats each string on its own line, quoted, and wrapped in brackets. + */ +export function formatPaths(paths: string[]): string { + const lines = paths.map((p) => ` '${p}'`) + return `[\n${lines.join(',\n')}\n]` +} + +describe('sortDts', () => { + it('orders REST-like routes hierarchically', () => { + const input = [ + '/users/[id]/other', + '/users', + '/users/[id]', + '/users/[id]/edit', + ] + const sorted = [...input].sort(comparePaths) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + '/users', + '/users/[id]', + '/users/[id]/edit', + '/users/[id]/other' +]" +`) + }) + + it('keeps files before folders (parent.vue before parent/child.vue)', () => { + const input = ['src/pages/parent/child.vue', 'src/pages/parent.vue'] + const sorted = [...input].sort(comparePaths) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'src/pages/parent.vue', + 'src/pages/parent/child.vue' +]" +`) + }) + + it('prioritizes index.vue within its directory', () => { + const input = ['src/pages/a.vue', 'src/pages/index.vue', 'src/pages/b.vue'] + const sorted = [...input].sort(comparePaths) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'src/pages/index.vue', + 'src/pages/a.vue', + 'src/pages/b.vue' +]" +`) + }) + + it('index.vue first, then sibling files, then subfolders', () => { + const input = [ + 'src/pages/about/index.vue', + 'src/pages/about.vue', + 'src/pages/index.vue', + ] + const sorted = [...input].sort(comparePaths) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'src/pages/index.vue', + 'src/pages/about.vue', + 'src/pages/about/index.vue' +]" +`) + }) + + it('handles dynamic segments in names (mixed [id] and :id tokens)', () => { + const input = [ + '/some-nested/file-with-[id]-in-the-middle', + 'file-with-:id-in-the-middle', + '/some-nested/file-with-:id-in-the-middle', + ] + const sorted = sortPaths(input) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'file-with-:id-in-the-middle', + '/some-nested/file-with-:id-in-the-middle', + '/some-nested/file-with-[id]-in-the-middle' +]" +`) + }) + + it('uses numeric-aware order for sibling files', () => { + const input = [ + 'src/pages/file10.vue', + 'src/pages/file2.vue', + 'src/pages/file1.vue', + ] + const sorted = sortPaths(input) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'src/pages/file1.vue', + 'src/pages/file2.vue', + 'src/pages/file10.vue' +]" +`) + }) + + it('keeps hierarchical grouping before plain string order', () => { + const input = ['a/bb/index.vue', 'a/b.vue', 'a/b/c.vue', 'a/a.vue'] + const sorted = [...input].sort(comparePaths) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'a/a.vue', + 'a/b.vue', + 'a/b/c.vue', + 'a/bb/index.vue' +]" +`) + }) + + describe('comparePaths / Unicode normalization (NFC)', () => { + it('treats precomposed and decomposed as equivalent and keeps them adjacent', () => { + // café in two forms: + // - precomposed: "é" (U+00E9) + // - decomposed: "e\u0301" (U+0065 + U+0301) + const pre = 'src/pages/café.vue' + const decomp = 'src/pages/cafe\u0301.vue' + + const input = ['src/pages/cafe.vue', pre, 'src/pages/cafg.vue', decomp] + + const sorted = sortPaths([...input]) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + 'src/pages/cafe.vue', + 'src/pages/café.vue', + 'src/pages/café.vue', + 'src/pages/cafg.vue' +]" +`) + // sanity: they are not byte-equal, but should compare equal under NFC-aware compare + expect(pre).not.toBe(decomp) + }) + + it('normalizes within deeper paths and still applies path rules (index.vue first)', () => { + const pre = '/users/café/index.vue' + const decomp = '/users/cafe\u0301/settings.vue' + const input = [ + '/users/cafe/profile.vue', + decomp, + pre, + '/users/cafe/index.vue', + ] + + const sorted = sortPaths([...input]) + expect(formatPaths(sorted)).toMatchInlineSnapshot(` +"[ + '/users/cafe/index.vue', + '/users/cafe/profile.vue', + '/users/café/index.vue', + '/users/café/settings.vue' +]" +`) + }) + }) +}) diff --git a/src/core/sortDts.ts b/src/core/sortDts.ts new file mode 100644 index 000000000..96188f8e2 --- /dev/null +++ b/src/core/sortDts.ts @@ -0,0 +1,62 @@ +/** + * Deterministic path sorting + * + * Ensures routes, files, and views in typed-router.d.ts are always sorted + * the same way on all systems and locales—preventing developers + * from seeing the file change due to locale differences. + * + * Sorting rules: + * • `index.vue` always comes first in its folder. + * • Files come before folders at the same level. + * • Otherwise, segments are compared with a fixed English collator. + * + * Normalization: + * • Unicode is normalized to NFC so "é" === "é". + */ + +// Fixed English collator for stable, locale-independent string comparison. +const collator = new Intl.Collator('en', { + usage: 'sort', + sensitivity: 'variant', + numeric: true, + caseFirst: 'lower', +}) + +// Normalize to NFC (so "é" === "é"), remove leading "/", and split by "/" into segments. +const toSegments = (p: string) => + p.normalize('NFC').replace(/^\/+/, '').split('/') + +// Compare two paths and return their deterministic sort order. +export function comparePaths(a: string, b: string): number { + const A = toSegments(a) + const B = toSegments(b) + + for (let i = 0, n = Math.max(A.length, B.length); i < n; i++) { + const x = A[i] + const y = B[i] + + if (x === y) { + if (x === undefined) return 0 + continue + } + + if (x === undefined) return -1 + if (y === undefined) return 1 + + if (x === 'index.vue' || y === 'index.vue') + return x === 'index.vue' ? -1 : 1 + + const fileX = i === A.length - 1 && x.includes('.') + const fileY = i === B.length - 1 && y.includes('.') + if (fileX !== fileY) return fileX ? -1 : 1 + + // Benchmarks show `Intl.Collator` is much faster than `localeCompare` here + const c = collator.compare(x, y) + if (c) return c + } + + return 0 +} + +// Sort an array of paths deterministically using comparePaths. +export const sortPaths = (paths: string[]) => [...paths].sort(comparePaths) From b3b7d253f607bef70a1a8ceaa0c551db3f797f0b Mon Sep 17 00:00:00 2001 From: lupas Date: Sat, 11 Oct 2025 20:50:07 +0200 Subject: [PATCH 2/6] fix: move logic to tree.ts to solve problem at root (also fixes named map) --- src/codegen/generateRouteFileInfoMap.ts | 15 ++++++--------- src/core/tree.ts | 5 ++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/codegen/generateRouteFileInfoMap.ts b/src/codegen/generateRouteFileInfoMap.ts index 479b4ad4d..6bd9bb465 100644 --- a/src/codegen/generateRouteFileInfoMap.ts +++ b/src/codegen/generateRouteFileInfoMap.ts @@ -1,7 +1,6 @@ import { relative } from 'pathe' import type { PrefixTree, TreeNode } from '../core/tree' import { formatMultilineUnion, stringToStringType } from '../utils' -import { comparePaths } from '../core/sortDts' export function generateRouteFileInfoMap( node: PrefixTree, @@ -40,18 +39,16 @@ export function generateRouteFileInfoMap( } const code = Array.from(routesInfo.entries()) - .sort(([fa], [fb]) => comparePaths(fa, fb)) - .map(([file, { routes, views }]) => { - const routesSorted = [...routes].sort(comparePaths) - const viewsSorted = [...views].sort(comparePaths) - return ` + .map( + ([file, { routes, views }]) => + ` '${file}': { routes: - ${formatMultilineUnion(routesSorted.map(stringToStringType), 6)} + ${formatMultilineUnion(routes.map(stringToStringType), 6)} views: - ${formatMultilineUnion(viewsSorted.map(stringToStringType), 6)} + ${formatMultilineUnion(views.map(stringToStringType), 6)} }` - }) + ) .join('\n') return `export interface _RouteFileInfoMap { diff --git a/src/core/tree.ts b/src/core/tree.ts index 40ac04bb1..e39aeec0e 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -9,6 +9,7 @@ import { import type { TreeNodeValue } from './treeNodeValue' import { CustomRouteBlock } from './customBlock' import { RouteMeta } from 'vue-router' +import { comparePaths } from '../core/sortDts' export interface TreeNodeOptions extends ResolvedOptions { treeNodeOptions?: TreeNodeValueOptions @@ -167,9 +168,7 @@ export class TreeNode { * @internal */ static compare(a: TreeNode, b: TreeNode): number { - // for this case, ASCII, short list, it's better than Internation Collator - // https://stackoverflow.com/questions/77246375/why-localecompare-can-be-faster-than-collator-compare - return a.path.localeCompare(b.path, 'en') + return comparePaths(a.path, b.path) } /** From 3d6b3375923136ed06a3fe04f4f92e8405845da8 Mon Sep 17 00:00:00 2001 From: lupas Date: Sat, 11 Oct 2025 20:50:35 +0200 Subject: [PATCH 3/6] fix: fixed ... not properly sorted --- src/core/sortDts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/sortDts.ts b/src/core/sortDts.ts index 96188f8e2..33cc52394 100644 --- a/src/core/sortDts.ts +++ b/src/core/sortDts.ts @@ -46,8 +46,8 @@ export function comparePaths(a: string, b: string): number { if (x === 'index.vue' || y === 'index.vue') return x === 'index.vue' ? -1 : 1 - const fileX = i === A.length - 1 && x.includes('.') - const fileY = i === B.length - 1 && y.includes('.') + const fileX = i === A.length - 1 && x.includes('.vue') + const fileY = i === B.length - 1 && y.includes('.vue') if (fileX !== fileY) return fileX ? -1 : 1 // Benchmarks show `Intl.Collator` is much faster than `localeCompare` here From 73619d343b9ea4659ac2425ea5c61f308ade23b9 Mon Sep 17 00:00:00 2001 From: lupas Date: Sat, 11 Oct 2025 20:55:08 +0200 Subject: [PATCH 4/6] refactor: renamed to pathSorting --- src/core/{sortDts.spec.ts => pathSorting.spec.ts} | 4 ++-- src/core/{sortDts.ts => pathSorting.ts} | 0 src/core/tree.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/core/{sortDts.spec.ts => pathSorting.spec.ts} (97%) rename src/core/{sortDts.ts => pathSorting.ts} (100%) diff --git a/src/core/sortDts.spec.ts b/src/core/pathSorting.spec.ts similarity index 97% rename from src/core/sortDts.spec.ts rename to src/core/pathSorting.spec.ts index 6dd14ee5c..da90421d4 100644 --- a/src/core/sortDts.spec.ts +++ b/src/core/pathSorting.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { comparePaths, sortPaths } from './sortDts' +import { comparePaths, sortPaths } from './pathSorting' /** * Helper for snapshot-friendly, readable arrays of paths. @@ -10,7 +10,7 @@ export function formatPaths(paths: string[]): string { return `[\n${lines.join(',\n')}\n]` } -describe('sortDts', () => { +describe('pathSorting', () => { it('orders REST-like routes hierarchically', () => { const input = [ '/users/[id]/other', diff --git a/src/core/sortDts.ts b/src/core/pathSorting.ts similarity index 100% rename from src/core/sortDts.ts rename to src/core/pathSorting.ts diff --git a/src/core/tree.ts b/src/core/tree.ts index e39aeec0e..c0d43ed29 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -9,7 +9,7 @@ import { import type { TreeNodeValue } from './treeNodeValue' import { CustomRouteBlock } from './customBlock' import { RouteMeta } from 'vue-router' -import { comparePaths } from '../core/sortDts' +import { comparePaths } from './pathSorting' export interface TreeNodeOptions extends ResolvedOptions { treeNodeOptions?: TreeNodeValueOptions From f90200ab44305b049ac40fe0cb4b687c71a6d30d Mon Sep 17 00:00:00 2001 From: lupas Date: Sat, 11 Oct 2025 21:14:32 +0200 Subject: [PATCH 5/6] fix: sort routes and views in generateRouteFileInfoMap for consistent output --- src/codegen/generateRouteFileInfoMap.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/codegen/generateRouteFileInfoMap.ts b/src/codegen/generateRouteFileInfoMap.ts index 6bd9bb465..f88833b67 100644 --- a/src/codegen/generateRouteFileInfoMap.ts +++ b/src/codegen/generateRouteFileInfoMap.ts @@ -1,6 +1,7 @@ import { relative } from 'pathe' import type { PrefixTree, TreeNode } from '../core/tree' import { formatMultilineUnion, stringToStringType } from '../utils' +import { comparePaths } from '../core/pathSorting' export function generateRouteFileInfoMap( node: PrefixTree, @@ -39,16 +40,17 @@ export function generateRouteFileInfoMap( } const code = Array.from(routesInfo.entries()) - .map( - ([file, { routes, views }]) => - ` + .map(([file, { routes, views }]) => { + const routesSorted = [...routes].sort(comparePaths) + const viewsSorted = [...new Set(views)].sort() + return ` '${file}': { routes: - ${formatMultilineUnion(routes.map(stringToStringType), 6)} + ${formatMultilineUnion(routesSorted.map(stringToStringType), 6)} views: - ${formatMultilineUnion(views.map(stringToStringType), 6)} + ${formatMultilineUnion(viewsSorted.map(stringToStringType), 6)} }` - ) + }) .join('\n') return `export interface _RouteFileInfoMap { From 45889dd829dde2c2bd676d223518f5156926f913 Mon Sep 17 00:00:00 2001 From: lupas Date: Sat, 11 Oct 2025 21:20:01 +0200 Subject: [PATCH 6/6] remove not needed views sorting --- src/codegen/generateRouteFileInfoMap.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/codegen/generateRouteFileInfoMap.ts b/src/codegen/generateRouteFileInfoMap.ts index f88833b67..8d6150e65 100644 --- a/src/codegen/generateRouteFileInfoMap.ts +++ b/src/codegen/generateRouteFileInfoMap.ts @@ -42,13 +42,12 @@ export function generateRouteFileInfoMap( const code = Array.from(routesInfo.entries()) .map(([file, { routes, views }]) => { const routesSorted = [...routes].sort(comparePaths) - const viewsSorted = [...new Set(views)].sort() return ` '${file}': { routes: ${formatMultilineUnion(routesSorted.map(stringToStringType), 6)} views: - ${formatMultilineUnion(viewsSorted.map(stringToStringType), 6)} + ${formatMultilineUnion(views.map(stringToStringType), 6)} }` }) .join('\n')