0013: Link auto-interception
Status: Accepted Date: 2026-05-10
Context
ADR 0011 shipped navigate(href) as
the programmatic way to change the URL on the client. The pattern at
each call site is:
html`<a
href="/about"
@click=${(e) => {
e.preventDefault();
navigate('/about');
}}
>About</a
>`;
Five lines per link. The SSR example I updated as part of ADR 0011 ended up with four near-identical inline handlers — one per link.
Every router shipping in production handles this with a global click
listener that converts qualifying <a> clicks into client-side
navigations. The exemption list is well-known (modifier keys,
target attribute, download, cross-origin, hash-only). The
implementation is ~30 lines once you write the predicate.
ADR 0011 explicitly punted on this primitive ("link interception is out of scope for Phase 1") because the right shape — global vs. per-component opt-in, default predicate vs. user-supplied — wasn't obvious yet. After spending an iteration writing the same boilerplate four times, the answer is clear: global, opt-in via a single call, with a conservative default predicate and an opt-out attribute for the rare per-link exception.
Decision
Add interceptLinks(options?) to @purityjs/core. Single
global click listener on document. Default predicate exempts
modifier keys, non-_self targets, download links, cross-origin
hrefs, hash-only same-page links, already-prevented events, and
elements carrying a data-no-intercept attribute. Surviving clicks
call event.preventDefault() + navigate(anchor.href).
// entry.client.ts
import { hydrate, interceptLinks } from '@purityjs/core';
import { App } from './app.ts';
hydrate(document.getElementById('app')!, App);
interceptLinks();
Views drop their inline handlers:
// Before (ADR 0011):
html`<a
href="/about"
@click=${(e) => {
e.preventDefault();
navigate('/about');
}}
>About</a
>`;
// After (ADR 0013):
html`<a href="/about">About</a>`;
Concretely:
interceptLinks(options?: { shouldIntercept? }): () => void— installs one global click listener (capture phase isn't needed; the default-bubble listener fires after per-element handlers). Returns a teardown function for tests / HMR. No-op on the server (nodocument). Calling it twice while a previous interception is active is a no-op + console warning — call the prior teardown first.- Default predicate exempts:
event.button !== 0(middle / right-click, follow native UA behavior)- any modifier key (
metaKey,ctrlKey,shiftKey,altKey) targetattribute set to anything other than empty or_selfdownloadattribute present- cross-origin
href(differenta.origin) - hash-only same-page links (
/page#sectionfrom/page— let the browser scroll natively) event.defaultPreventedalready true (another listener bailed)data-no-interceptattribute on the<a>(per-link opt-out)
shouldInterceptoption replaces the default entirely. When supplied, your predicate fully replaces the default — include the exemption checks you still want. This avoids the "additive predicate" footgun where users override one bit and silently inherit edge cases they didn't read about.closest('a')is used to find the link from a nested click target (e.g.<a><span><strong>click</strong></span></a>).
Explicit non-features
- No focus management / scroll restoration. A real router needs to handle scroll position on back-nav, focus on route change, and announce navigation to screen readers. Each is its own follow-up.
- No view transitions API integration.
document.startViewTransitionpairs nicely with this primitive but is a separate ADR. - No prefetch-on-hover. Common in production routers; needs resource-cache integration and adds complexity. Defer.
- No data-prefetch attribute / link priorities. Same.
- No
<purity-link>Custom Element. A Custom Element wrapper would let the framework own the click listener per-instance instead of globally, but it pushes a component boundary onto every link. Global interception is simpler and the right primitive layer.
Consequences
Positive:
- Drops ~5 lines per link in user code. The SSR example shrunk by 20+ lines.
- Zero runtime cost for apps that don't call
interceptLinks()— tree-shaken. - Default predicate is conservative: matches user expectation for cmd-click-to-open-tab, target="_blank", external links, file downloads, etc. Apps don't have to write the exemption logic themselves.
- Works without any change to existing user templates —
<a href>was already correct markup; previously the user had to layer manual handlers on top, now they don't.
Negative:
- One global listener on the document. Negligible cost (single
document.addEventListener); negligible behavior unless the user has many other click listeners that depend on default behavior. The default predicate'sdefaultPreventedcheck prevents most collisions, but unusual flows that rely on the<a>natively navigating after their own handler ran would needdata-no-intercept. - Per-link opt-out is a magic attribute name.
data-no-interceptis reasonable but not standard. The alternative (a CSS class convention, a Symbol-tagged element, etc.) is worse. - The "replace, don't extend" predicate semantic means custom predicates have to re-implement exemptions they want to keep. Documented; explicit beats subtle additive composition.
Neutral:
- Two exports added (
interceptLinks,InterceptLinksOptions). Tree-shaken when unused. - Server-side no-op. Components can call
interceptLinks()from shared code without target-detection branches; it just does nothing on the server. - Per-iteration teardown via the returned function makes test isolation clean and HMR-friendly.
Alternatives considered
Per-component <purity-link> Custom Element. Encapsulates click
handling at the component layer. Rejected: every link in the app
now goes through a Custom Element rather than a native <a>,
losing native semantics (right-click "Open Link in New Tab" still
works, but the surrounding DOM is more complex). Global interception
keeps the markup unchanged.
Additive shouldIntercept that runs after the default. Let the
user veto only a subset. Rejected for the "extend silently" footgun
— a user adding shouldIntercept: (e, a) => a.dataset.spa === 'true'
expecting to opt-in their links would actually skip every link
because the default already returned false for those without the
data attribute. Replace-the-whole-predicate semantics are blunt but
unambiguous.
Auto-install when navigate() is imported. Removes the explicit
call site. Rejected: side-effect imports are an anti-pattern; users
expect importing navigate to import a function, not install a
document-level listener. Explicit interceptLinks() matches the
explicit-by-default contract of every other shipped primitive.
Use capture: true to intercept before user listeners. Would
catch clicks even when downstream listeners do their own preventDefault.
Rejected because that's the wrong default — if a downstream handler
explicitly calls event.preventDefault() for its own flow, the
router should bail. The default predicate respects
event.defaultPrevented for exactly this reason.
Built-in scroll-restoration / focus-on-nav. Each is a deserving follow-up that needs careful design (where to restore scroll for back-navigation, what focus target on programmatic nav, how to integrate with view transitions). Phase 1 stays minimal.