From cf02ffc140ec82b6c408e4b933167c5c398a7d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Wed, 21 Oct 2026 21:52:19 -0300 Subject: [PATCH] fix: keep route scan order on watcher updates An updated route was moved to the end of the manifest and new files were appended, so watcher events reordered the manifest and the generated types. Replace existing routes in place and insert new ones where a full scan puts them. --- .changeset/keep-route-scan-order.md | 5 +++ src/router.ts | 33 +++++++++++++++---- test/convention.spec.ts | 50 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 .changeset/keep-route-scan-order.md diff --git a/.changeset/keep-route-scan-order.md b/.changeset/keep-route-scan-order.md new file mode 100644 index 0000000..0eb2932 --- /dev/null +++ b/.changeset/keep-route-scan-order.md @@ -0,0 +1,5 @@ +--- +"filesystem-routing": patch +--- + +Keep the manifest in scan order when the watcher changes a route. An edited route file was moved to the end of the manifest, and a re-created or new file was appended, so the generated types and the manifest order changed during development. Edited routes now stay in place, and added routes are inserted where a full scan would put them. diff --git a/src/router.ts b/src/router.ts index f416e0f..84f00eb 100644 --- a/src/router.ts +++ b/src/router.ts @@ -52,6 +52,9 @@ export function cleanPath(src: string, config: FileSystemRouterConfig) { export class BaseFileSystemRouter extends EventTarget { routes: RouteManifestEntry[]; + /** Source file that first produced each route path, so incremental changes keep scan order. */ + private sources = new Map(); + config: FileSystemRouterConfig; constructor(config: FileSystemRouterConfig) { @@ -92,12 +95,29 @@ export class BaseFileSystemRouter extends EventTarget { throw new Error("Not implemented"); } - _addRoute(route: RouteManifestEntry) { + _addRoute(route: RouteManifestEntry, src?: string) { const idx = this.routes.findIndex(r => r.path === route.path); - if (idx >= 0) this.routes.splice(idx, 1); - this.routes.push(route); + if (idx >= 0) { + // Replace in place: an edited file keeps its position. The source that + // first produced the path still decides it, as in a full scan. + this.routes[idx] = route; + return true; + } + + if (src !== undefined) this.sources.set(route.path, src); + + // A new file goes where a full scan would put it. + const at = + src === undefined + ? -1 + : this.routes.findIndex(r => { + const other = this.sources.get(r.path); + return other !== undefined && other > src; + }); + if (at === -1) this.routes.push(route); + else this.routes.splice(at, 0, route); - return idx >= 0; + return false; } async addRoute(src: string) { @@ -106,7 +126,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 +152,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_); @@ -155,6 +175,7 @@ export class BaseFileSystemRouter extends EventTarget { if (idx === -1) return; this.routes.splice(idx, 1); + this.sources.delete(path); this.reload(path, "remove"); } } diff --git a/test/convention.spec.ts b/test/convention.spec.ts index 4cdebba..f9de5c5 100644 --- a/test/convention.spec.ts +++ b/test/convention.spec.ts @@ -310,4 +310,54 @@ describe("PageFileSystemRouter", () => { "/zebra" ]); }); + + it("keeps scan order when the watcher updates, re-adds or adds routes", async () => { + const dir = createRouteTree({ + "index.tsx": "export default () =>

Home

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

About

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

Contact

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

Z

;" + }); + const router = new PageFileSystemRouter({ dir, extensions: ["tsx"] }); + const paths = () => router.routes.map(route => route.path); + const scan = async () => + (await new PageFileSystemRouter({ dir, extensions: ["tsx"] }).getRoutes()).map( + route => route.path + ); + await router.getRoutes(); + const scanned = await scan(); + + // An edited file must not move to the end of the manifest. + await router.updateRoute(path.join(dir, "about.tsx")); + expect(paths()).toEqual(scanned); + + // A file removed and created again returns to its scan position. + router.removeRoute(path.join(dir, "about.tsx")); + await router.addRoute(path.join(dir, "about.tsx")); + expect(paths()).toEqual(scanned); + + // New files land where a fresh scan puts them, in the middle or at the end. + for (const name of ["blog.tsx", "zzz.tsx"]) { + fs.writeFileSync(path.join(dir, name), "export default () => null;"); + await router.addRoute(path.join(dir, name)); + expect(paths()).toEqual(await scan()); + } + }); + + it("keeps scan order when two files map to the same path", async () => { + const dir = createRouteTree({ + "about-us.tsx": "export default () => null;", + "about.mdx": "# About", + "about.tsx": "export default () => null;" + }); + const extensions = ["tsx", "mdx"]; + const router = new PageFileSystemRouter({ dir, extensions }); + await router.getRoutes(); + + fs.writeFileSync(path.join(dir, "about.q.tsx"), "export default () => null;"); + await router.addRoute(path.join(dir, "about.q.tsx")); + + const fresh = await new PageFileSystemRouter({ dir, extensions }).getRoutes(); + expect(router.routes.map(route => route.path)).toEqual(fresh.map(route => route.path)); + }); });