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)); + }); });