Skip to content

Commit db56e0b

Browse files
committed
Make Router#register() failure-atomic
register() previously updated #routesByName and removed committed routes from the trie while still resolving later entries in the same batch. If a later resolvePathPattern() threw (e.g. an invalid path template), the router was left with the name map and trie out of sync: a partially replaced route could build() to the new template while neither the old nor the new path resolved through route(). register() now resolves and builds every pending entry locally first, so an invalid template aborts the batch before any mutation. The trie/map are only touched once the whole batch is known to be valid, preserving the previous router state on failure. Added regression tests covering a throwing entry mid-batch on both a populated router (previous route must still resolve and build) and an empty router (no routes may leak). #758 (comment) Assisted-by: Claude Code:claude-opus-4-7
1 parent 13cc78d commit db56e0b

2 files changed

Lines changed: 74 additions & 17 deletions

File tree

packages/uri-template/src/router/router.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,57 @@ test("Router treats re-registration as replacement", async (t) => {
381381
);
382382
});
383383

384+
test("Router#register() is failure-atomic", async (t) => {
385+
await t.step(
386+
"a throwing entry leaves the previous router state intact",
387+
() => {
388+
const router = new Router();
389+
router.add("/old/{id}", "user");
390+
391+
// Batch with a valid replacement for "user" followed by an invalid
392+
// template. The invalid template makes resolvePathPattern() throw
393+
// mid-batch.
394+
throws(() =>
395+
router.register([
396+
["/new/{id}", "user"],
397+
["/bad path", "broken"],
398+
])
399+
);
400+
401+
// The previous "user" route must still resolve and build exactly as
402+
// before the failed batch: no partial mutation may survive.
403+
deepEqual(router.route("/old/1"), {
404+
name: "user",
405+
template: "/old/{id}",
406+
values: { id: "1" },
407+
});
408+
equal(router.build("user", { id: "1" }), "/old/1");
409+
410+
// The aborted replacement and the unrelated invalid name must not leak.
411+
equal(router.route("/new/1"), null);
412+
equal(router.has("broken"), false);
413+
},
414+
);
415+
416+
await t.step(
417+
"a throwing entry does not register any routes on an empty router",
418+
() => {
419+
const router = new Router();
420+
421+
throws(() =>
422+
router.register([
423+
["/users/{id}", "user"],
424+
["foo" as Path, "relative"],
425+
])
426+
);
427+
428+
equal(router.has("user"), false);
429+
equal(router.has("relative"), false);
430+
equal(router.route("/users/1"), null);
431+
},
432+
);
433+
});
434+
384435
test("Router.from() mirrors the constructor", async (t) => {
385436
await t.step("no arguments", () => {
386437
const router = Router.from();

packages/uri-template/src/router/router.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ interface RouteEntry {
8181
export default class Router {
8282
readonly #trie: Trie<RouteEntry>;
8383
readonly #routesByName: Map<string, RouteEntry>;
84-
#nextIndex: number;
84+
#prevIndex: number = -1;
8585

8686
/**
8787
* Whether to ignore trailing slashes when matching paths.
@@ -113,7 +113,6 @@ export default class Router {
113113

114114
this.#trie = new Trie();
115115
this.#routesByName = new Map();
116-
this.#nextIndex = 0;
117116
this.trailingSlashInsensitive = options?.trailingSlashInsensitive ?? false;
118117

119118
if (routes != null) this.register(routes);
@@ -178,7 +177,7 @@ export default class Router {
178177
const previous = this.#routesByName.get(name);
179178
if (previous != null) this.#trie.remove(previous);
180179

181-
const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern });
180+
const entry = createRouteEntry({ index: this.#index, name, pattern });
182181

183182
this.#routesByName.set(name, entry);
184183
this.#trie.insert(entry);
@@ -189,28 +188,35 @@ export default class Router {
189188
* @param routes Iterable of `[pathOrPattern, name]` pairs to register.
190189
*/
191190
register = (routes: Iterable<RouterRoute>): void => {
192-
const pending = new Map<string, RouteEntry>();
193-
194-
for (const [pathOrPattern, name] of routes) {
195-
if (!pending.has(name)) {
196-
const committed = this.#routesByName.get(name);
197-
if (committed != null) this.#trie.remove(committed);
198-
}
191+
const resolved = Array.from(routes).map(
192+
([pathOrPattern, name]): [string, RouterPathPattern] => [
193+
name,
194+
resolvePathPattern(pathOrPattern),
195+
],
196+
);
199197

200-
const pattern = resolvePathPattern(pathOrPattern);
201-
const entry = createRouteEntry({
202-
index: this.#nextIndex++,
198+
const pending = new Map<string, RouteEntry>();
199+
for (const [name, pattern] of resolved) {
200+
pending.set(
203201
name,
204-
pattern,
205-
});
202+
createRouteEntry({ index: this.#index, name, pattern }),
203+
);
204+
}
206205

207-
this.#routesByName.set(name, entry);
208-
pending.set(name, entry);
206+
for (const name of pending.keys()) {
207+
const committed = this.#routesByName.get(name);
208+
if (committed != null) this.#trie.remove(committed);
209209
}
210210

211+
for (const [name, entry] of pending) this.#routesByName.set(name, entry);
212+
211213
this.#trie.insertAll(pending.values());
212214
};
213215

216+
get #index(): number {
217+
return this.#prevIndex++;
218+
}
219+
214220
/**
215221
* Resolves a path name and values from a URI, if any match.
216222
* @param url The URI to resolve.

0 commit comments

Comments
 (0)