Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keep-route-scan-order.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 27 additions & 6 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

config: FileSystemRouterConfig;

constructor(config: FileSystemRouterConfig) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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_);
Expand All @@ -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");
}
}
Expand Down
50 changes: 50 additions & 0 deletions test/convention.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => <h1>Home</h1>;",
"about.tsx": "export default () => <h1>About</h1>;",
"contact.tsx": "export default () => <h1>Contact</h1>;",
"zebra.tsx": "export default () => <h1>Z</h1>;"
});
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));
});
});