Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,36 @@ Registers a capture-phase click listener on `document`. Returns a cleanup functi
- Mutating `ctx.url` automatically updates `anchor.href`
- Calling `ctx.preventDefault()` cancels navigation

## Framework Router Coexistence

The interceptor captures **all** `<a>` clicks in the capture phase, including those rendered by framework router components (`<router-link>`, React Router `<Link>`, etc.). This works correctly because:

- If your `onInternalLink` calls `ctx.preventDefault()`, the router component's own handler sees `event.defaultPrevented === true` and skips its navigation — no double navigation occurs.
- If your `onInternalLink` does **not** call `ctx.preventDefault()` (e.g. analytics only), the router component handles navigation normally.

### Gotcha: `replace` and other router component props

When the callback calls `ctx.preventDefault()` and `router.push()`, props like `replace` on `<router-link replace>` are silently ignored — the interceptor has no way to read component props from the DOM.

To preserve router component behavior for specific links, add a `data-no-intercept` attribute and skip `preventDefault()` in the callback:

```html
<router-link to="/home" replace data-no-intercept>Home</router-link>
```

```ts
onInternalLink(ctx) {
if (ctx.anchor.hasAttribute('data-no-intercept')) {
// Let the router component handle navigation (preserves replace, etc.)
return
}
ctx.preventDefault()
router.push(ctx.path)
},
```

See the [playground](https://babu-ch.github.io/link-interceptor/) Internal Links page for a live demo.

## Use Cases

| Use Case | Link Type | Example |
Expand Down
9 changes: 5 additions & 4 deletions playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ console.log = (...args: unknown[]) => {
</button>
</div>
<nav>
<a href="/">{{ $t("nav.home") }}</a>
<a href="/internal">{{ $t("nav.internal") }}</a>
<a href="/external">{{ $t("nav.external") }}</a>
<router-link to="/">{{ $t("nav.home") }}</router-link>
<router-link to="/internal">{{ $t("nav.internal") }}</router-link>
<router-link to="/external">{{ $t("nav.external") }}</router-link>
<a href="/prevent">{{ $t("nav.prevent") }}</a>
<a href="/analytics">{{ $t("nav.analytics") }}</a>
<a href="/confirm">{{ $t("nav.confirm") }}</a>
Expand Down Expand Up @@ -119,7 +119,8 @@ nav a {
border-bottom: 2px solid transparent;
}

nav a:hover {
nav a:hover,
nav a.router-link-active {
border-bottom-color: #4361ee;
}

Expand Down
16 changes: 16 additions & 0 deletions playground/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ export default {
nested: "Nested elements",
nestedDesc: "Clicks on child elements inside {tag} are also detected",
nestedLink: "Decorated link",
routerLink: "Router Link coexistence",
routerLinkDesc:
"Both <router-link> and plain <a> tags work side by side. The interceptor captures both in the capture phase. RouterLink checks event.defaultPrevented and skips its own navigation when the interceptor has already handled it.",
routerLinkToHome: "router-link to Home",
plainLinkToExternal: "plain <a> to External Links",
routerLinkNote:
"Both links appear in the console — the interceptor handles all <a> clicks regardless of whether they originate from <router-link> or plain HTML.",
routerLinkGotcha: "Gotcha: router-link replace",
routerLinkGotchaDesc:
"The interceptor captures <router-link replace> clicks too. If the callback calls ctx.preventDefault() and router.push(), the replace prop is silently ignored — a history entry is added instead of replaced.",
routerLinkReplaceBroken: "without workaround — replace is ignored (click, then press Back to see)",
routerLinkReplaceFixed: "with data-no-intercept — replace works (click, then press Back to compare)",
routerLinkGotchaNote:
"The first link has no workaround: the interceptor calls preventDefault() + router.push(), so replace is lost and a history entry is added. The second link has data-no-intercept: the callback skips preventDefault(), letting RouterLink handle navigation with replace intact.",
routerLinkWorkaround:
"Workaround: add a data-no-intercept attribute to <router-link> elements that need to preserve props like replace. In the callback, check ctx.anchor.hasAttribute('data-no-intercept') and skip ctx.preventDefault() so RouterLink handles navigation itself. See main.ts for the implementation.",
},
external: {
title: "External Links",
Expand Down
16 changes: 16 additions & 0 deletions playground/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ export default {
nested: "ネストされた要素",
nestedDesc: "{tag} 内の子要素をクリックしても検出されます",
nestedLink: "装飾されたリンク",
routerLink: "Router Link との共存",
routerLinkDesc:
"<router-link> と素の <a> タグが共存できます。インターセプターは capture フェーズで両方を捕捉します。RouterLink は event.defaultPrevented を確認し、インターセプターが処理済みの場合は自身のナビゲーションをスキップします。",
routerLinkToHome: "router-link で Home へ",
plainLinkToExternal: "素の <a> で External Links へ",
routerLinkNote:
"どちらのリンクもコンソールに表示されます — インターセプターは <router-link> 由来か素の HTML かに関わらず、全ての <a> クリックを処理します。",
routerLinkGotcha: "ハマりどころ: router-link replace",
routerLinkGotchaDesc:
"インターセプターは <router-link replace> のクリックも捕捉します。コールバックが ctx.preventDefault() と router.push() を呼ぶと、replace プロップが無視され、履歴が置換ではなく追加されます。",
routerLinkReplaceBroken: "回避なし — replace が無視される(クリック後、戻るボタンで確認)",
routerLinkReplaceFixed: "data-no-intercept 付き — replace が機能する(クリック後、戻るボタンで比較)",
routerLinkGotchaNote:
"1つ目のリンクは回避なし: インターセプターが preventDefault() + router.push() を呼ぶため replace が失われ、履歴が追加されます。2つ目は data-no-intercept 付き: コールバックが preventDefault() をスキップし、RouterLink が replace 付きでナビゲーションします。",
routerLinkWorkaround:
"回避方法: replace などのプロップを保持したい <router-link> に data-no-intercept 属性を付けます。コールバックで ctx.anchor.hasAttribute('data-no-intercept') をチェックし、ctx.preventDefault() をスキップして RouterLink にナビゲーションを任せます。実装は main.ts を参照してください。",
},
external: {
title: "External Links",
Expand Down
19 changes: 17 additions & 2 deletions playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { i18n } from "./i18n";
import { router } from "./router";

const SECURITY_ALLOWLIST = ["vuejs.org", "github.com"];
// <router-link> renders href with the base path (e.g. /link-interceptor/internal),
// but router.push() expects a base-relative path (e.g. /internal).
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");

function stripBase(path: string): string {
return BASE && path.startsWith(BASE) ? path.slice(BASE.length) || "/" : path;
}

const app = createApp(App);

Expand All @@ -13,11 +20,19 @@ app.use(i18n);

app.use(linkInterceptorPlugin, {
onInternalLink(ctx) {
// Skip interception for links that should be handled by their own router.
// Add data-no-intercept to preserve RouterLink props like replace.
if (ctx.anchor.hasAttribute("data-no-intercept")) {
console.log("[RouterLink]", ctx.path);
pushAnalyticsEvent("internal", ctx.path);
return;
}

// Form Guard: warn if form has unsaved changes
if (window.__formIsDirty?.()) {
ctx.preventDefault();
if (confirm("Unsaved changes will be lost. Continue?")) {
router.push(ctx.path);
router.push(stripBase(ctx.path));
}
return;
}
Expand All @@ -28,7 +43,7 @@ app.use(linkInterceptorPlugin, {
// Analytics: record event
pushAnalyticsEvent("internal", ctx.path);

router.push(ctx.path);
router.push(stripBase(ctx.path));
},

onExternalLink(ctx) {
Expand Down
21 changes: 21 additions & 0 deletions playground/src/pages/Internal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,26 @@ const dynamicHtml = () => `<p>${t("internal.vhtmlContent")}</p>`;
- {{ $t("internal.nestedDesc", { tag: "<a>" }) }}
</p>
</div>

<div class="demo-section">
<h3>{{ $t("internal.routerLink") }}</h3>
<p>{{ $t("internal.routerLinkDesc") }}</p>
<ul>
<li><router-link to="/">{{ $t("internal.routerLinkToHome") }}</router-link></li>
<li><a href="/external">{{ $t("internal.plainLinkToExternal") }}</a></li>
</ul>
<p><small>{{ $t("internal.routerLinkNote") }}</small></p>
</div>

<div class="demo-section">
<h3>{{ $t("internal.routerLinkGotcha") }}</h3>
<p>{{ $t("internal.routerLinkGotchaDesc") }}</p>
<ul>
<li><router-link to="/" replace>{{ $t("internal.routerLinkReplaceBroken") }}</router-link></li>
<li><router-link to="/" replace data-no-intercept>{{ $t("internal.routerLinkReplaceFixed") }}</router-link></li>
</ul>
<p><small>{{ $t("internal.routerLinkGotchaNote") }}</small></p>
<p><small>{{ $t("internal.routerLinkWorkaround") }}</small></p>
</div>
</div>
</template>
Loading