-
-
Notifications
You must be signed in to change notification settings - Fork 108
fix(#688): Fixed non-deterministic sort order of typed-router.d.ts #733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
104a7b3
fix(#688): Fixed non-deterministic sort order of typed-router.d.ts
lupas b3b7d25
fix: move logic to tree.ts to solve problem at root (also fixes named…
lupas 3d6b337
fix: fixed ... not properly sorted
lupas 73619d3
refactor: renamed to pathSorting
lupas f90200a
fix: sort routes and views in generateRouteFileInfoMap for consistent…
lupas 45889dd
remove not needed views sorting
lupas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
| import { comparePaths, sortPaths } from './pathSorting' | ||
|
|
||
| /** | ||
| * 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('pathSorting', () => { | ||
| 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' | ||
| ]" | ||
| `) | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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('.vue') | ||
| const fileY = i === B.length - 1 && y.includes('.vue') | ||
| if (fileX !== fileY) return fileX ? -1 : 1 | ||
lupas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.