diff --git a/.changeset/stable-route-order.md b/.changeset/stable-route-order.md new file mode 100644 index 0000000..9a67b36 --- /dev/null +++ b/.changeset/stable-route-order.md @@ -0,0 +1,5 @@ +--- +"filesystem-routing": patch +--- + +Keep the manifest in scan order during dev: every added or changed route is placed by its source file, so an edited route stays put and a new one lands where a fresh scan would put it. `buildRouteTree` now breaks ties by code unit instead of `localeCompare`, so its order no longer depends on the locale. diff --git a/src/order.ts b/src/order.ts new file mode 100644 index 0000000..2ea3382 --- /dev/null +++ b/src/order.ts @@ -0,0 +1,21 @@ +/** + * The one string ordering used for routes: by UTF-16 code unit, the same as + * `Array.prototype.sort()` without a comparator. Unlike `localeCompare` it + * gives the same answer in every locale, so generated output is identical + * across machines. + * + * Dependency-free so the tree module can share it. + */ +export function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Insert `item` into `sorted` (ascending by `key`) after any equal keys, so + * the array stays sorted however it is built up. + */ +export function insertSorted(sorted: T[], item: T, key: (item: T) => string) { + const k = key(item); + const at = sorted.findIndex(other => compareStrings(key(other), k) > 0); + sorted.splice(at >= 0 ? at : sorted.length, 0, item); +} diff --git a/src/router.ts b/src/router.ts index f416e0f..0989919 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,6 +3,7 @@ import micromatch from "micromatch"; import { posix, resolve, sep } from "node:path"; import type { RouteManifestEntry } from "./manifest.ts"; +import { compareStrings, insertSorted } from "./order.ts"; export const glob = (path: string) => fg.sync(path, { absolute: true }); @@ -71,7 +72,7 @@ export class BaseFileSystemRouter extends EventTarget { } async buildRoutes(): Promise { - for (const src of glob(this.glob()).sort()) { + for (const src of glob(this.glob()).sort(compareStrings)) { await this.addRoute(src); } @@ -92,10 +93,20 @@ export class BaseFileSystemRouter extends EventTarget { throw new Error("Not implemented"); } - _addRoute(route: RouteManifestEntry) { + /** Source file of each entry: the key the manifest is ordered by. */ + private sources = new WeakMap(); + + /** + * The only insert into `routes`, for the cold scan and dev changes alike. + * Entries are kept sorted by source file, so an edited route lands back + * where it was and a new one lands where a fresh scan would put it. + */ + _addRoute(route: RouteManifestEntry, src: string) { + this.sources.set(route, src); + const idx = this.routes.findIndex(r => r.path === route.path); if (idx >= 0) this.routes.splice(idx, 1); - this.routes.push(route); + insertSorted(this.routes, route, r => this.sources.get(r)!); return idx >= 0; } @@ -106,7 +117,7 @@ export class BaseFileSystemRouter extends EventTarget { try { const route = this.toRoute(src); if (route) { - this._addRoute(route); + this._addRoute(route, src); this.reload(route.path, "add"); } } catch (e) { @@ -132,7 +143,7 @@ export class BaseFileSystemRouter extends EventTarget { try { const route = this.toRoute(src); if (route) { - const updated = this._addRoute(route); + const updated = this._addRoute(route, src); this.reload(route.path, updated ? "update" : "add"); } else { this.removeRoute(src_); diff --git a/src/tree.ts b/src/tree.ts index 73ad628..4b4d77e 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -1,4 +1,5 @@ import type { RouteManifestEntry } from "./manifest.ts"; +import { compareStrings } from "./order.ts"; /** * Nesting and group-stripping for the neutral route manifest. @@ -45,8 +46,10 @@ export function buildRouteTree(entries: readonly RouteManifestEntry[]): RouteTre ); } + // Parents (path prefixes - therefore always shorter) must be placed before their + // children - the tie-break only fixes sibling order. return [...entries] - .sort((a, b) => (a.path.length - b.path.length) || a.path.localeCompare(b.path)) + .sort((a, b) => (a.path.length - b.path.length) || compareStrings(a.path, b.path)) .reduce((routes: RouteTreeEntry[], route) => { processRoute(routes, route, route.path); return routes; diff --git a/test/convention.spec.ts b/test/convention.spec.ts index 4cdebba..a7c3474 100644 --- a/test/convention.spec.ts +++ b/test/convention.spec.ts @@ -134,6 +134,49 @@ describe("PageFileSystemRouter", () => { expect(events).toEqual(["add:/contact", "remove:/contact"]); }); + it("keeps scan order when routes change or are added", async () => { + const dir = createRouteTree({ + "about.tsx": "export default () =>

About

;", + "index.tsx": "export default () =>

Home

;", + "zebra.tsx": "export default () =>

Z

;" + }); + const router = new PageFileSystemRouter({ dir, extensions: ["tsx"] }); + await router.getRoutes(); + expect(router.routes.map(route => route.path)).toEqual(["/about", "/", "/zebra"]); + + // An edit replaces the route in place instead of moving it to the end. + await router.updateRoute(path.join(dir, "about.tsx")); + expect(router.routes.map(route => route.path)).toEqual(["/about", "/", "/zebra"]); + + // A new file lands where a fresh scan would put it. + const contact = path.join(dir, "contact.tsx"); + fs.writeFileSync(contact, "export default () =>

Contact

;"); + await router.addRoute(contact); + const fresh = await new PageFileSystemRouter({ dir, extensions: ["tsx"] }).getRoutes(); + expect(router.routes.map(route => route.path)).toEqual(fresh.map(route => route.path)); + expect(router.routes.map(route => route.path)).toEqual(["/about", "/contact", "/", "/zebra"]); + }); + + it("orders a route by the file that wins its path", async () => { + // `x.md` and `x.tsx` both map to `/x`; `x.p.tsx` sorts between them. + const dir = createRouteTree({ + "x.md": "# X", + "x.p.tsx": "export default () =>

X.p

;" + }); + const config = { dir, extensions: ["md", "tsx"] }; + const router = new PageFileSystemRouter(config); + await router.getRoutes(); + expect(router.routes.map(route => route.path)).toEqual(["/x", "/x.p"]); + + // `x.tsx` takes over `/x`, so `/x` moves to where `x.tsx` sorts. + const x = path.join(dir, "x.tsx"); + fs.writeFileSync(x, "export default () =>

X

;"); + await router.addRoute(x); + const fresh = await new PageFileSystemRouter(config).getRoutes(); + expect(router.routes.map(route => route.path)).toEqual(fresh.map(route => route.path)); + expect(router.routes.map(route => route.path)).toEqual(["/x.p", "/x"]); + }); + it("omits component refs when components are off", async () => { const dir = createRouteTree({ "index.tsx": `