feat(router): middleware redirect-return, replaceRoute, notFoundComponent, RouterLink fallthrough (fs-router 0.2.0)#147
Conversation
Deploying fs-packages with
|
| Latest commit: |
1e6d745
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1473a813.fs-packages.pages.dev |
| Branch Preview URL: | https://engineer-fs-router-0-2-0.fs-packages.pages.dev |
Goosterhof
left a comment
There was a problem hiding this comment.
COMMENT (own PR — GitHub blocks self-approve). No confirmed blockers. Two Majors and two Minors below; recommend fixing before merge, none of them a ship-stopper on their own.
Findings:
- 🔴 Major —
CHANGELOG.md:10claimsRouterLink"previously dropped its class." Reproduced against the pre-PR component and that's not what happens — the real prior bug is worse (silenthrefoverride). Inline. - 🔴 Major —
router.ts:87-101: no cycle guard on the new redirect-return loop. Body. - 🟡 Minor —
router.ts:95-96: fire-and-forget redirect dispatch with no.catch(). Inline. - 🟡 Minor —
types.ts:81-87: compat proof only covers the sync middleware shape. Body.
On the CHANGELOG claim (finding 1, detail): I mounted the exact pre-PR createRouterLink shape (defineComponent(fn, {props: ['to']}), no inheritAttrs) against Vue 3.5.39 — the version pinned in this repo's node_modules — on both the CSR (happy-dom + createApp) and SSR (renderToString) paths:
h(OldRouterLink, {class: 'nav-link active', 'aria-current': 'page', 'data-testid': 'x', href: '/consumer-supplied'}, ...)
→ <a href="/consumer-supplied" class="nav-link active" aria-current="page" data-testid="x">Users</a>
class, aria-current, data-testid were already forwarded — Vue 3's automatic fallthrough-attrs merges any non-props attribute onto a single-root component's rendered element with zero code on the component's part. Nothing was "dropped." What the repro does show is a real, worse bug: the consumer-supplied href silently overrode the computed navigation href (/consumer-supplied won over /computed-href), because Vue's automatic merge favors the external value for non-class/style attributes. That's what components.ts:50-52's inheritAttrs: false + attrs-spread-before-owned-props actually fixes — and the PR's own new test ("should keep the owned href authoritative over a fallthrough href attribute") proves the fix works. The code is right; the changelog names the wrong defect.
On the redirect-loop gap (finding 2, detail): a middleware returning a MiddlewareRedirect toward a route whose own middleware redirects back (a misconfigured auth guard bouncing login↔dashboard) loops forever — each redirect re-enters beforeEach and re-runs the full chain. This isn't a new exposure class (the old hand-rolled cancel+goToRoute idiom had it too), but promoting it to a declarative single-object-return API plausibly lowers the bar for a consumer to hit it without separately reasoning about cycles — the two-step at least forced them to write the goToRoute call by hand. Worth a documented caveat at minimum, ideally a same-target-twice or depth-capped guard in the loop itself.
On the compat proof (finding 4, detail): the package's own pre-PR docs example used async (to, from) => {...}, and that's very plausibly the dominant real-world shape — but LegacyBooleanMiddlewareStillSatisfies only type-checks the synchronous case. I independently verified with tsc --noEmit --strict that a Promise<boolean>-returning middleware does still satisfy the widened type, so there's no live break — but the delivered proof, which the deployment order frames as "demonstrated, not asserted," doesn't cover the shape most likely to matter.
What landed well: buildRouteLocation (router.ts:48-61) is a clean extraction — goToRoute/replaceRoute share the params/query resolution instead of risking drift between two copies. The redirect-return test suite is genuinely thorough: "should short-circuit later middleware when a redirect object is returned" asserts the blocked hop never reaches a follow-on middleware (exactly the failure mode a naive implementation gets wrong silently), and the push-vs-replace tests discriminate on history.pushState/replaceState spies rather than just the end route, which is the right level of proof.
Scope: consumer touch-ups (BIO registerAuthGuard, town-crier guard collapse/replace restoration) are correctly left out of this PR per the deployment order — separate follow-up dispatches, not folded in here.
CI: all three checks (check, Cloudflare Pages, town-crier/gate) green at review time.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
Town Crier Review · 6/10 · REVISE · 🤝 Confirm — 🔴 1 🟡 1fs-packages #147 · AC anchor: PR description · head Warning Reviewed the fs-router 0.2.0 additive surface (middleware redirect-return, replaceRoute, notFoundComponent, RouterLink fallthrough) against Vue 3.5.39 semantics; this corroborates the-general/Goosterhof's off-bus review — confirming a MAJOR the blind pass missed (no cycle guard on the new middleware redirect-return chain) plus a MINOR compat-proof gap, and agreeing the CHANGELOG mischaracterization and fire-and-forget dispatch risk are real but already live as open inline threads we're answering there rather than re-filing. 2 finding(s) posted inline:
Bus thread · 2 prior review(s):
|
…nent, RouterLink fallthrough (0.2.0) Four additive features on fs-router, backward-compatible (0.1.1 -> 0.2.0): - Middleware redirect-return: BeforeRouteMiddleware may return a typed MiddlewareRedirect object (cancel + navigate one step, push default / replace:true); boolean form unchanged. Return-union widening is assignability-safe (compile-time compat proof in types.ts). - replaceRoute: replace-semantics sibling of goToRoute (shared route builder). - notFoundComponent option: threaded into RouterView's unmatched fallback. - RouterLink attribute fallthrough: setup-context attrs merged onto the anchor, href/onClick stay authoritative (inheritAttrs:false). New barrel exports: MiddlewareRedirect, BeforeRouteMiddlewareResult, replaceRoute. Router package: 110 tests, 100% coverage, 90.87% mutation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMQZfcgUpsJZQM8WR2wWzG
…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
c38fca8 to
1e6d745
Compare
Goosterhof
left a comment
There was a problem hiding this comment.
Re-verifying at head 1e6d7451 (was c38fca8). Both open Majors are genuinely fixed; one open Minor from the cross-harness thread is not addressed by this commit and stays open, non-blocking.
Major 1 — CHANGELOG mischaracterization: fixed. The 0.2.0 entry now correctly names the RouterLink defect. I reproduced the claim against the actual component (components.ts:45-53): attrs is spread before the owned href, so a consumer-supplied href is silently overridden — the entry's new wording matches the code exactly. class/style were never the real gap (Vue's automatic single-root attr inheritance already forwards those); good catch rewriting it rather than just softening the language.
Major 2 — no redirect-loop cycle guard: fixed, and it covers the case that matters. router.ts:90-91,106-136 adds a redirectDepth counter capped at MAX_REDIRECT_DEPTH = 10, incremented on every middleware-initiated redirect dispatch and reset only when a chain terminates (proceed, or a plain-boolean cancel). Because the counter lives in the closure rather than being scoped to a single beforeEach invocation, it persists across the separate navigation dispatches that a redirect chain produces — which is exactly what's needed to bound an indirect cycle (A→B→C→A), not just a direct self-redirect. On trip it resets, logs, and returns false (fails safe, lands on the prior route) rather than hanging.
One gap in the test coverage: router.spec.ts:790-861 exercises a direct self-redirect (about → about) and the depth-reset-on-terminate/reset-across-independent-navigations cases, but there's no test with three or more distinct routes forming the cycle (A→B→C→A). The self-redirect test does exercise the same load-bearing mechanism (depth persisting across separate beforeEach re-entries), so I'm not flagging this as a correctness gap — the code review confirms the guard's reach — but a mutual-redirect test with named intermediate routes would make that indirect-cycle coverage explicit rather than inferred. 🟡 Minor, non-blocking.
Minor (folded in, fixed) — fire-and-forget redirect dispatch unhandled rejection: now carries a .catch() reporter (router.ts:118-121), tested at router.spec.ts:864-882. Matches what both reviews asked for.
Minor (dispatch's finding #24 open, still unaddressed) — compat-proof coverage: types.ts:78-87's LegacyBooleanMiddlewareStillSatisfies only asserts the sync (to, from) => boolean shape stays assignable, not the pre-existing Promise<boolean> async shape. This commit didn't touch types.ts, so that gap is unchanged. Non-blocking — compile-time coverage gap, not a runtime defect — but leaving it open rather than treating it as resolved by this push.
0.2.0 surface scan (replaceRoute, notFoundComponent, RouterLink fallthrough) — nothing egregious. replaceRoute mirrors goToRoute's param-resolution path exactly and delegates to router.replace; notFoundComponent wiring in components.ts is a one-line conditional render, no edge case visible; the fallthrough merge order (attrs first, owned props last) is the fix itself and is correct.
CI green (check job: lint, build, typecheck, test:coverage, test:mutation all passed on 1e6d7451).
No blockers. The two Majors this re-verify was scoped to are resolved — COMMENT (own PR).
Town Crier Review · 8/10 · PASS · 🤝 Confirm — 🟡 1fs-packages #147 · AC anchor: PR description / CHANGELOG (no board) · head Tip Reviewed the fs-router 0.2.0 fix commit (1e6d745) — corroborates the-general/Goosterhof's re-verification that both prior Majors (CHANGELOG mischaracterization, unbounded middleware redirect cycle) and the fire-and-forget dispatch Minor are genuinely fixed in code and tests, with the compat-proof async-shape gap still open but non-blocking. Adds one new MINOR the prior thread didn't raise: the redirect discriminator 1 finding(s) posted inline:
Bus thread · 5 prior review(s):
|
jasperboerhof
left a comment
There was a problem hiding this comment.
Auto-approved — Town Crier verdict PASS @Head, CI green, no open MAJOR+ thread. Our approval is our independent vote (approve-alongside): a peer's review / CHANGES_REQUESTED never withholds it — we verify every blocker ourselves, and a real one drops our own verdict below PASS. Any open 🟡 MINOR threads alongside this approval are non-blocking (TC-0036 R4) — author's choice: fold here or follow-up; if folding into this PR, say so in-thread and disarm auto-merge. See the verdict comment + inline notes.
What — fs-router 0.2.0, four additive features
BeforeRouteMiddlewaremay now return a typedMiddlewareRedirect({name, id?, query?, parentId?, replace?}): cancels the pending hop and navigates to the target in one step, compile-checked against the routes tuple. Push by default (matches vue-router's native guard-redirect intent and the BIO cancel+goToRouteidiom this retires),replace: trueopt-in. Boolean returns keep their exact meaning; a compile-time compat proof intypes.tspins that existing(to, from) => booleanmiddlewares still satisfy the widened type (type-level only — verified absent from the published.d.ts).replaceRoute— replace-semantics sibling ofgoToRoute, identical signature. First consumer need: town-crier's OAuth-callback/404/dead-read bounces (script-development/town-crier#150) degraded fromrouter.replaceto push at adoption.notFoundComponentservice option — a designed 404 in place of the hardcodedh('p', ['404'])unmatched fallback; default unchanged.class/style/data-*/aria-*now land on the rendered anchor (inheritAttrs: false+ explicit attrs merge; ownedhref/onClickstay authoritative). Closes the known ISMS 2026-05-29 weakness.Also fixed in passing: the docs' "Before Navigation" example documented inverted boolean semantics (
return false // prevent navigation— the pipeline cancels on truthy).Consumers
BIO / ISMS / town-crier need zero changes at bump time — everything is additive. Follow-ups after publish: BIO's
registerAuthGuardand town-crier's guard collapse to object-returns; town-crier restores its replace bounces.Verification
Router package 110/110 tests, 100% coverage (145/145 stmts, 68/68 branches), Stryker 90.87% ≥ 90 on a clean non-incremental run (2 residual survivors proven equivalent-inert — detailed in the execution report). Full gate suite green: audit · oxfmt · oxlint · tsdown build · validate:dist · typecheck (all 11 workspaces) · publint+attw · coverage · mutation.
Release
packages/router0.1.1 → 0.2.0 bumped in-PR (+ CHANGELOG + docs page, the #144 shape). Merging triggers the Publish workflow, which waits on thenpm-publishenvironment approval.🤖 Generated with Claude Code
https://claude.ai/code/session_01VMQZfcgUpsJZQM8WR2wWzG