Summary
With interceptAllLinks: true — which bun-plugin-stx's serve() hardcodes as its base default — the SPA router intercepts every same-origin relative <a href>, whether or not that path is a page the router can render. shouldIntercept() has no notion of a route table: it filters on protocol, target and a handful of opt-out attributes only. Clicking a link to a non-page endpoint (an OAuth redirect, a file download handler, a proxied docs site) makes the router fetch() it, fail to swap it, and then fall back to a real navigation — so the endpoint is hit twice. If the endpoint instead returns a successful cross-origin redirect, fetch follows it and the router swaps a third-party document into the container.
The only opt-out, data-no-router, removes the anchor from routing entirely, so it cannot be used to say "this is a page, just do not treat it as a fragment".
Impact (real app)
In analyticshq the four OAuth sign-in anchors carry data-no-router (resources/views/login.stx:105, :109; resources/views/register.stx:124, :128). The network trace recorded before that fix:
Fetch 503 /api/auth/github/redirect ← router intercepted and fetched it
Document 503 /api/auth/github/redirect ← fallback real navigationTwo hits on an endpoint whose entire job is to mint OAuth state.
Separately, resources/partials/site-nav.stx:53-55 keeps the opt-out on a path the router genuinely cannot render, and says so:
/docsis served by the BunPress proxy, not by stx. A fragment fetch would get a document the router cannot swap, so this one keeps the opt-out.
That is the same missing concept twice: the router has no manifest of which same-origin paths it actually owns. There are 17 data-no-router attributes in resources/ today, and every new link has to remember whether it needs one.
Evidence in stx
packages/router/src/client.ts:1211-1221
function shouldIntercept(link){
if(!link)return false;
var href=link.getAttribute('href');
if(!href)return false;
if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
if(link.target==='_blank')return false;
if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
if(href.startsWith('?'))return true;
if(href===location.pathname)return false;
if(!getContainer())return false;
return true;
}No route-table consultation, no method/idempotency notion, no /api exemption.
client.ts:1229-1244— capture-phase click handler; witho.interceptAllLinksit upgrades anya[href]that passesshouldIntercept.client.ts:554-555—fetch(url, …).then(r => { if(!r.ok) throw new Error(r.status) … }); the rejection path ends inlocation.href = url, producing the second request.client.ts:495— a cross-origin target is handled correctly when it is the anchor's own href, but a same-origin URL that 302s off-site is followed byfetchand the resulting body is swapped in.packages/bun-plugin/src/serve.ts:2302—interceptAllLinks: trueis the base of the merged router config on the serve path.
(For the record: hover prefetch is not implicated — client.ts:1254-1258 only prefetches [data-stx-link], so a plain anchor is never speculatively fetched.)
Prior art
- Nuxt:
<NuxtLink>only routes paths that resolve in the route manifest; anything else falls through to a native navigation.prefetch="false"is separate fromexternal. - Next.js:
<Link>routes against the app router's known segments; an unknown path is a plain anchor. - Turbo:
data-turbo="false"(routing) anddata-turbo-prefetch="false"(prefetch) are independent attributes, and Turbo Drive only claims same-origin URLs within its configured root.
Ask
- Give the router a manifest of the paths it can render (the server already has one — it is what
discoverFiles()builds) and haveshouldInterceptconsult it, so an unmatched same-origin path falls through to a native navigation instead of being fetched and then re-navigated. - Failing (1), exempt paths the app declares as non-page — e.g. a
router.ignore: ['/api/**', '/docs/**']config array merged intowindow.__stxRouterConfig. - Treat a
fetchwhoseresponse.redirectedis true and whose final URL is cross-origin as "not ours": abort the swap and do a real navigation, rather than injecting a third-party document into the container. - Split the opt-outs: keep
data-no-routerfor "never route this", and add a separate per-link prefetch control so the two decisions are independent.