Skip to content

feat(router): middleware redirect-return, replaceRoute, notFoundComponent, RouterLink fallthrough (fs-router 0.2.0)#147

Merged
Goosterhof merged 2 commits into
mainfrom
engineer/fs-router-0.2.0
Jul 10, 2026
Merged

feat(router): middleware redirect-return, replaceRoute, notFoundComponent, RouterLink fallthrough (fs-router 0.2.0)#147
Goosterhof merged 2 commits into
mainfrom
engineer/fs-router-0.2.0

Conversation

@Goosterhof

Copy link
Copy Markdown
Contributor

What — fs-router 0.2.0, four additive features

  1. Middleware redirect-returnBeforeRouteMiddleware may now return a typed MiddlewareRedirect ({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+goToRoute idiom this retires), replace: true opt-in. Boolean returns keep their exact meaning; a compile-time compat proof in types.ts pins that existing (to, from) => boolean middlewares still satisfy the widened type (type-level only — verified absent from the published .d.ts).
  2. replaceRoute — replace-semantics sibling of goToRoute, identical signature. First consumer need: town-crier's OAuth-callback/404/dead-read bounces (script-development/town-crier#150) degraded from router.replace to push at adoption.
  3. notFoundComponent service option — a designed 404 in place of the hardcoded h('p', ['404']) unmatched fallback; default unchanged.
  4. RouterLink attribute fallthroughclass/style/data-*/aria-* now land on the rendered anchor (inheritAttrs: false + explicit attrs merge; owned href/onClick stay 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 registerAuthGuard and 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/router 0.1.1 → 0.2.0 bumped in-PR (+ CHANGELOG + docs page, the #144 shape). Merging triggers the Publish workflow, which waits on the npm-publish environment approval.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VMQZfcgUpsJZQM8WR2wWzG

@Goosterhof
Goosterhof requested a review from a team as a code owner July 6, 2026 14:44
@Goosterhof Goosterhof added the Agent Review Requested Requesting review of specialized AI review agents. label Jul 6, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 6, 2026

Copy link
Copy Markdown

Deploying fs-packages with  Cloudflare Pages  Cloudflare Pages

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

View logs

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 🔴 Major — CHANGELOG.md:10 claims RouterLink "previously dropped its class." Reproduced against the pre-PR component and that's not what happens — the real prior bug is worse (silent href override). Inline.
  2. 🔴 Major — router.ts:87-101: no cycle guard on the new redirect-return loop. Body.
  3. 🟡 Minor — router.ts:95-96: fire-and-forget redirect dispatch with no .catch(). Inline.
  4. 🟡 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.

Comment thread packages/router/CHANGELOG.md Outdated
Comment thread packages/router/src/router.ts Outdated
@Goosterhof
Goosterhof enabled auto-merge July 9, 2026 18:40
Comment thread packages/router/src/router.ts
Comment thread packages/router/src/types.ts
@jasperboerhof

Copy link
Copy Markdown
Contributor

Town Crier Review · 6/10 · REVISE · 🤝 Confirm — 🔴 1 🟡 1

fs-packages #147 · AC anchor: PR description · head c38fca8e83 · via the town-crier bus (request #352)

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:

  • 🔴 MAJOR · packages/router/src/router.ts:94 — No cycle guard on the middleware redirect-return chain — mutually-redirecting middleware loop unbounded
  • 🟡 MINOR · packages/router/src/types.ts:81 — Compat proof (LegacyBooleanMiddlewareStillSatisfies) covers only the sync middleware shape

Bus thread · 2 prior review(s):

  • the-general (independent): COMMENT (own PR): 2 Majors (CHANGELOG mischaracterizes the RouterLink fix's real prior bug; no redirect-loop cycle guard) + 2 Minors (fire-and-forget redirect dispatch unhandled-rejection risk; …
  • the-general (independent): Hold, no re-review: head c38fca8 is a pure base-merge of main into the branch (war-room update-branch after fs-form #​148 merged); the PR's authored contribution is unchanged at 54a92df. Prior COMMENT …

Goosterhof and others added 2 commits July 10, 2026 14:22
…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
@Goosterhof
Goosterhof force-pushed the engineer/fs-router-0.2.0 branch from c38fca8 to 1e6d745 Compare July 10, 2026 12:37

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread packages/router/CHANGELOG.md
Comment thread packages/router/src/router.ts
Comment thread packages/router/src/types.ts
@Goosterhof

Copy link
Copy Markdown
Contributor Author

Correction to my review above: the open compat-proof Minor is bus finding #25 (types.ts:81), not #24#24 is the cycle-guard Major, which this head fixes. No change to the substance: cycle-guard Major confirmed fixed, compat-proof Minor confirmed still open and non-blocking.

Comment thread packages/router/src/router.ts
@jasperboerhof

Copy link
Copy Markdown
Contributor

Town Crier Review · 8/10 · PASS · 🤝 Confirm — 🟡 1

fs-packages #147 · AC anchor: PR description / CHANGELOG (no board) · head 1e6d74519a · via the town-crier bus (request #352)

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 result !== true at router.ts:105 mis-treats any falsy-non-false middleware return as a redirect object, though TypeScript's return union blocks real-world reach for typed consumers.

1 finding(s) posted inline:

  • 🟡 MINOR · packages/router/src/router.ts:105 — Redirect discriminator result !== true mis-treats falsy-non-false returns as redirect objects

Bus thread · 5 prior review(s):

  • the-general (independent): COMMENT (own PR): 2 Majors (CHANGELOG mischaracterizes the RouterLink fix's real prior bug; no redirect-loop cycle guard) + 2 Minors (fire-and-forget redirect dispatch unhandled-rejection risk; …
  • the-general (independent): Hold, no re-review: head c38fca8 is a pure base-merge of main into the branch (war-room update-branch after fs-form #​148 merged); the PR's authored contribution is unchanged at 54a92df. Prior COMMENT …
  • dispatch (confirm): Confirm — corroborates the-general/Goosterhof's off-bus review at head c38fca8. We carry two claims A's blind pass missed but independently confirm: the new middleware redirect-return chain in …
  • the-general (confirm): dispatch corroborated our review at head c38fca8 (a pure base-merge of main, war-room update-branch — no re-review owed). Our 2 open Majors stand by cross-harness consensus: (1) CHANGELOG …
  • the-general (confirm): Re-verified at head 1e6d745 (from c38fca8). Both open Majors genuinely fixed: (1) CHANGELOG now correctly names the RouterLink href-override defect, verified against components.ts:45-53; (2) …

@jasperboerhof jasperboerhof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Goosterhof
Goosterhof merged commit 6147d5e into main Jul 10, 2026
3 checks passed
@Goosterhof
Goosterhof deleted the engineer/fs-router-0.2.0 branch July 10, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Review Requested Requesting review of specialized AI review agents.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants