Skip to content

Commit 1e6d745

Browse files
Goosterhofclaude
andcommitted
fix(router): cycle-guard the middleware redirect chain + correct the RouterLink CHANGELOG
Addresses the two Majors from the #147 review (war-room the-general + Jasper via the town-crier bus, both confirmed): - **Redirect-loop cycle guard.** The new middleware redirect-return chain had no bound: two middleware redirecting into each other's guarded routes (or a self-redirect) recursed forever, each dispatch re-entering beforeEach. Add a depth counter capped at MAX_REDIRECT_DEPTH (10, mirroring vue-router's own ceiling), reset the moment a chain terminates (proceed or plain-true cancel) so the cap only ever trips on a genuine loop, never on unrelated back-to-back navigations. On trip it logs and aborts instead of hanging. - **CHANGELOG defect description.** The 0.2.0 RouterLink entry claimed a styled link "previously dropped its class." Reproduced against Vue 3.5.39: class/style already fell through under Vue's automatic single-root attr inheritance. The real defect closed is a consumer-supplied `href` silently overriding the computed navigation href. Rewrote the entry to name the actual bug. Also folds in the confirmed Minor on the same lines: the fire-and-forget redirect dispatch now carries a .catch() reporter so a rejection from a downstream guard or lazy component surfaces instead of an unhandled rejection. Tests: +5 covering loop abort (self + mutual), reset-on-cap resume, no cross-nav depth accumulation, blocked-hop-not-committed, and the .catch reporter path. Router package 115/115, 100% coverage, Stryker 90.46% (2 residual survivors equivalent-inert: the >= cap boundary and a return value the dispatch supersedes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WeEAdAxqp9zVurriGzyRAs
1 parent 0a32ddb commit 1e6d745

3 files changed

Lines changed: 166 additions & 7 deletions

File tree

packages/router/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
- **Middleware redirect-return.** A before-route middleware may now return a typed redirect object instead of just a boolean. Returning `{name, id?, query?, parentId?, replace?}` **cancels the pending navigation and navigates to the target in one step**`goToRoute` (push) by default, or a replace when `replace: true`. A boolean return keeps its exact meaning (truthy cancels, falsy continues), and an object return short-circuits the middleware chain identically to a truthy boolean. This retires the hand-rolled cancel-then-`goToRoute` two-step consumers wrote against `registerBeforeRouteMiddleware`. Additive and backward-compatible: widening the middleware's return union is assignability-safe, so every existing `(to, from) => boolean` middleware still satisfies the type. New exports: `MiddlewareRedirect` and `BeforeRouteMiddlewareResult`.
88
- **New export: `replaceRoute`.** A replace-semantics sibling of `goToRoute` with the identical signature (`name, id?, query?, parentId?`), delegating to `router.replace` with the same param resolution. Use it for landings that should not pollute the history stack (OAuth callbacks, 404-home bounces, dead-read redirects) — where a push would leave the consumed URL reachable via Back.
99
- **New option: `notFoundComponent`.** `createRouterService(routes, { notFoundComponent })` lets `RouterView` render a declared component in place of the bare `404` fallback when no route matches at the requested depth. The default (a bare `404`) is unchanged when the option is omitted.
10-
- **`RouterLink` attribute fallthrough.** `RouterLink` now forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered `<a>`. A styled `<RouterLink class="…">` previously dropped its class; those attributes now land on the anchor. The owned `href` and `onClick` stay authoritative (fallthrough attrs are merged first, so a consumer-supplied `href` cannot override the computed one).
10+
- **`RouterLink` attribute fallthrough.** `RouterLink` now explicitly forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered `<a>` via `inheritAttrs: false` plus an explicit attrs merge, keeping the owned `href` and `onClick` authoritative. `class` and `style` already fell through under Vue's automatic single-root attribute inheritance (Vue special-cases and concatenates them); the real defect this closes is that a **consumer-supplied `href` silently overrode the computed navigation `href`** — Vue's automatic merge lets an external non-class/style attribute win, so `<RouterLink :to="…" href="…">` would navigate to the wrong target. Merging fallthrough attrs _before_ the owned props makes the computed `href`/`onClick` win, and non-navigational attrs (`class`, `data-*`, `aria-*`, …) still land on the anchor.

packages/router/src/router.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,25 +79,63 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
7979
const getUrlForRouteName: RouterService<Routes>['getUrlForRouteName'] = (name, id, query, parentId) =>
8080
router.resolve({name, params: resolveRouteParams(name as string, id, parentId), query}).fullPath;
8181

82+
// Bounds the middleware redirect-return chain. Each returned `MiddlewareRedirect` cancels the
83+
// pending hop and dispatches a fresh navigation, which re-enters `beforeEach` and re-runs the
84+
// whole chain — so two middleware that redirect into each other's guarded routes (a
85+
// misconfigured login↔dashboard guard) would recurse without bound. `redirectDepth` counts
86+
// consecutive redirects and is reset to 0 the moment a chain terminates — a navigation is
87+
// allowed to proceed, or a middleware cancels without redirecting — so the cap only ever trips
88+
// on a genuine loop, never on unrelated back-to-back navigations. Mirrors vue-router's own
89+
// internal max-redirect ceiling.
90+
const MAX_REDIRECT_DEPTH = 10;
91+
let redirectDepth = 0;
92+
8293
const beforeRouteMiddleware: BeforeRouteMiddleware<Routes>[] = [];
8394
router.beforeEach(async (to, from) => {
8495
const toNormalized = normalizedRouteToSpecificRoute(to);
8596
const fromNormalized = from.name ? normalizedRouteToSpecificRoute(from) : toNormalized;
8697

98+
let cancelled = false;
8799
for (const middleware of beforeRouteMiddleware) {
88100
const result = await middleware(toNormalized, fromNormalized);
89101
if (result === false) continue;
90102

91-
// A truthy object return cancels the pending hop and navigates to the target in
92-
// one step — replace when `replace: true`, push otherwise. A boolean `true` just
93-
// cancels. Either short-circuits the chain (return false stops beforeEach).
103+
// A truthy object return cancels the pending hop and navigates to the target in one
104+
// step — replace when `replace: true`, push otherwise. A boolean `true` just cancels.
94105
if (result !== true) {
95-
if (result.replace) void replaceRoute(result.name, result.id, result.query, result.parentId);
96-
else void goToRoute(result.name, result.id, result.query, result.parentId);
106+
if (redirectDepth >= MAX_REDIRECT_DEPTH) {
107+
redirectDepth = 0;
108+
console.error(
109+
`fs-router: middleware redirect chain exceeded ${MAX_REDIRECT_DEPTH} hops — aborting to break a redirect loop`,
110+
);
111+
112+
return false;
113+
}
114+
115+
redirectDepth += 1;
116+
// Fire-and-forget: the dispatch resolves once the redirect navigation settles.
117+
// `router.push`/`replace` resolve (not reject) for NavigationDuplicated/Aborted, but
118+
// they DO reject when a guard or lazy component further down the redirected chain
119+
// throws — attach a reporter so that surfaces instead of an unhandled rejection.
120+
const dispatch = result.replace ? replaceRoute : goToRoute;
121+
void dispatch(result.name, result.id, result.query, result.parentId).catch((error: unknown) => {
122+
console.error('fs-router: middleware redirect navigation failed', error);
123+
});
124+
125+
// The chain continues in the dispatched navigation — do NOT reset the depth here.
126+
return false;
97127
}
98128

99-
return false;
129+
// Plain `true` cancels the hop without redirecting; the chain terminates.
130+
cancelled = true;
131+
break;
100132
}
133+
134+
// The chain terminated without dispatching a redirect (a `true` cancel or a clean proceed),
135+
// so reset the depth for the next, unrelated navigation.
136+
redirectDepth = 0;
137+
138+
return cancelled ? false : undefined;
101139
});
102140

103141
const afterRouteMiddleware: NavigationHookAfter[] = [...(options?.afterRouteCallbacks ?? [])];

packages/router/tests/router.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,127 @@ describe('router service', () => {
763763
// Assert — the awaited object return redirects just like a sync one
764764
expect(service.currentRouteRef.value.name).toBe('items.overview');
765765
});
766+
767+
it('should not commit the blocked hop when a middleware returns a redirect', async () => {
768+
// Arrange — a single redirect (about → items.overview) with an afterEach recorder that
769+
// logs only *successful* commits (failure === undefined).
770+
const service = createRouterService(createTestRoutes());
771+
await service.goToRoute('home');
772+
await flushPromises();
773+
const committed: (string | symbol | undefined)[] = [];
774+
service.registerAfterRouteMiddleware((to, _from, failure) => {
775+
if (!failure) committed.push(to.name);
776+
});
777+
service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
778+
779+
// Act
780+
await service.goToRoute('about');
781+
await flushPromises();
782+
783+
// Assert — the blocked 'about' hop is aborted, never committed; only the redirect target
784+
// lands (a redirect that returned `true` instead of `false` would commit 'about' first)
785+
expect(committed).not.toContain('about');
786+
expect(committed).toContain('items.overview');
787+
expect(service.currentRouteRef.value.name).toBe('items.overview');
788+
});
789+
790+
it('should abort a self-redirecting middleware loop instead of recursing without bound', async () => {
791+
// Arrange — a middleware that redirects 'about' back to 'about'. The blocked hop never
792+
// commits, so 'from' stays 'home' and every re-dispatch redirects again — an unbounded
793+
// loop without the depth cap.
794+
const service = createRouterService(createTestRoutes());
795+
await service.goToRoute('home');
796+
await flushPromises();
797+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
798+
service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'about'} : false));
799+
800+
// Act
801+
await service.goToRoute('about');
802+
803+
// Assert — the chain hits the depth cap, logs, and stops (a broken guard would hang here)
804+
await vi.waitFor(() => {
805+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('redirect chain exceeded'));
806+
});
807+
await flushPromises();
808+
809+
// ...and the aborted hop is cancelled, not committed — the router stays on 'home'
810+
// (a cap branch that returned `true` would let the final blocked 'about' hop through)
811+
expect(service.currentRouteRef.value.name).toBe('home');
812+
});
813+
814+
it('should resume normal redirects after a loop has been aborted', async () => {
815+
// Arrange — trip the loop guard once...
816+
const service = createRouterService(createTestRoutes());
817+
await service.goToRoute('home');
818+
await flushPromises();
819+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
820+
const unregisterLoop = service.registerBeforeRouteMiddleware((to) =>
821+
to.name === 'about' ? {name: 'about'} : false,
822+
);
823+
await service.goToRoute('about');
824+
await vi.waitFor(() => expect(consoleErrorSpy).toHaveBeenCalled());
825+
await flushPromises();
826+
827+
// ...then tear the loop down and install a single clean redirect.
828+
unregisterLoop();
829+
consoleErrorSpy.mockClear();
830+
service.registerBeforeRouteMiddleware((to) =>
831+
to.name === 'items.create' ? {name: 'items.edit', id: 7} : false,
832+
);
833+
834+
// Act
835+
await service.goToRoute('items.create');
836+
await flushPromises();
837+
838+
// Assert — the depth was reset when the loop aborted, so a fresh redirect still works
839+
// (a dropped reset-on-cap would leave the counter pinned at the cap and abort this too)
840+
expect(service.currentRouteRef.value.name).toBe('items.edit');
841+
expect(consoleErrorSpy).not.toHaveBeenCalled();
842+
});
843+
844+
it('should not accumulate redirect depth across independent navigations', async () => {
845+
// Arrange — a single-hop redirect, exercised far more times than the cap allows.
846+
const service = createRouterService(createTestRoutes());
847+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
848+
service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
849+
850+
// Act — 15 unrelated redirects; each chain terminates cleanly and must reset the depth
851+
for (let index = 0; index < 15; index += 1) {
852+
await service.goToRoute('home');
853+
await flushPromises();
854+
await service.goToRoute('about');
855+
await flushPromises();
856+
}
857+
858+
// Assert — the last redirect still fires and the cap never trips (a dropped reset would
859+
// let the depth climb across the 15 hops and falsely abort after ten)
860+
expect(service.currentRouteRef.value.name).toBe('items.overview');
861+
expect(consoleErrorSpy).not.toHaveBeenCalled();
862+
});
863+
864+
it('should report a rejected redirect dispatch instead of leaving an unhandled rejection', async () => {
865+
// Arrange — the redirect target's own guard throws, so the dispatched navigation rejects.
866+
const service = createRouterService(createTestRoutes());
867+
await service.goToRoute('home');
868+
await flushPromises();
869+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
870+
service.registerBeforeRouteMiddleware((to) => {
871+
if (to.name === 'items.overview') throw new Error('downstream guard boom');
872+
873+
return to.name === 'about' ? {name: 'items.overview'} : false;
874+
});
875+
876+
// Act
877+
await service.goToRoute('about');
878+
879+
// Assert — the fire-and-forget dispatch's rejection is caught and reported
880+
await vi.waitFor(() => {
881+
expect(consoleErrorSpy).toHaveBeenCalledWith(
882+
'fs-router: middleware redirect navigation failed',
883+
expect.any(Error),
884+
);
885+
});
886+
});
766887
});
767888

768889
describe('registerAfterRouteMiddleware', () => {

0 commit comments

Comments
 (0)