Skip to content
Open
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/stable-route-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 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.
21 changes: 21 additions & 0 deletions src/order.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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);
}
21 changes: 16 additions & 5 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -71,7 +72,7 @@ export class BaseFileSystemRouter extends EventTarget {
}

async buildRoutes(): Promise<RouteManifestEntry[]> {
for (const src of glob(this.glob()).sort()) {
for (const src of glob(this.glob()).sort(compareStrings)) {
await this.addRoute(src);
}

Expand All @@ -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<RouteManifestEntry, string>();

/**
* 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;
}
Expand All @@ -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) {
Expand All @@ -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_);
Expand Down
5 changes: 4 additions & 1 deletion src/tree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RouteManifestEntry } from "./manifest.ts";
import { compareStrings } from "./order.ts";

/**
* Nesting and group-stripping for the neutral route manifest.
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions test/convention.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => <h1>About</h1>;",
"index.tsx": "export default () => <h1>Home</h1>;",
"zebra.tsx": "export default () => <h1>Z</h1>;"
});
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 () => <h1>Contact</h1>;");
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 () => <h1>X.p</h1>;"
});
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 () => <h1>X</h1>;");
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": `
Expand Down
Loading