From 114c63b10cabe9bc5fc131a2d9e5855ce01caa7f Mon Sep 17 00:00:00 2001
From: isonimus <19539979+Isonimus@users.noreply.github.com>
Date: Sun, 7 Jun 2026 16:35:52 +0200
Subject: [PATCH 1/2] fix(vue): nest in-route spans under the navigation span
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Vue Router integration cleared the route span in a separate beforeEach guard
and only re-created it in afterEach, leaving a window where getRouteContext() was
root. Clicks/fetch/errors fired in that gap — and intermittently the nav-link
click that started the navigation — orphaned into their own root traces instead
of nesting under the route span.
Clear + re-create now happen atomically inside afterEach (matching the React
adapter), so a route span is active at all times.
- router.ts: drop the beforeEach clear; call clearRouteSpan() within afterEach
- test: assert no beforeEach guard is registered + clear-before-set ordering
- changeset: patch @tindalabs/blindspot-vue
---
.changeset/fix-vue-route-span-nesting.md | 12 ++++++++++
packages/vue/src/router.ts | 12 ++++++----
packages/vue/test/router.test.ts | 28 +++++++++++++++++++-----
3 files changed, 43 insertions(+), 9 deletions(-)
create mode 100644 .changeset/fix-vue-route-span-nesting.md
diff --git a/.changeset/fix-vue-route-span-nesting.md b/.changeset/fix-vue-route-span-nesting.md
new file mode 100644
index 0000000..e7bb540
--- /dev/null
+++ b/.changeset/fix-vue-route-span-nesting.md
@@ -0,0 +1,12 @@
+---
+"@tindalabs/blindspot-vue": patch
+---
+
+Fix: in-route activity (clicks, fetch, errors, form submits) now nests under the
+navigation span instead of orphaning into its own root trace. The Vue Router
+integration cleared the route span in a separate `beforeEach` guard and only
+re-created it in `afterEach`, leaving a window where the active context was root;
+any span started in that gap (and intermittently the nav-link click that started
+the navigation) became a standalone root trace. The clear and re-create now happen
+atomically in `afterEach`, so a route span is active at all times — matching the
+React adapter's behavior.
diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts
index 0a8a531..d3058c5 100644
--- a/packages/vue/src/router.ts
+++ b/packages/vue/src/router.ts
@@ -10,14 +10,18 @@ export function installBlindspotRouter(router: Router): void {
let isFirst = true;
let prevPath = '';
- router.beforeEach(() => {
- clearRouteSpan();
- });
-
router.afterEach((to) => {
const toPath = to.fullPath;
const trigger = isFirst ? 'initial' : 'user';
isFirst = false;
+
+ // End the previous route span and open the new one atomically, so a route
+ // span is active at all times. Doing the clear in a separate beforeEach left
+ // a window where getRouteContext() was root — any click/fetch/error fired in
+ // that gap orphaned into its own root trace instead of nesting under the
+ // route. Mirrors the React adapter's single-effect clear → create → set.
+ clearRouteSpan();
+
const parentContext =
trigger === 'initial' ? loadRouteContextAfterReload() : undefined;
const span = getTracer().startSpan(
diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts
index 6c76ee1..f255274 100644
--- a/packages/vue/test/router.test.ts
+++ b/packages/vue/test/router.test.ts
@@ -22,13 +22,15 @@ import { installBlindspotRouter } from '../src/router.js';
function makeRouter() {
let beforeHook: (() => void) | undefined;
let afterHook: ((to: { fullPath: string }) => void) | undefined;
+ let beforeRegistered = false;
return {
- beforeEach(fn: () => void) { beforeHook = fn; },
+ beforeEach(fn: () => void) { beforeRegistered = true; beforeHook = fn; },
afterEach(fn: (to: { fullPath: string }) => void) { afterHook = fn; },
navigate(to: string) {
beforeHook?.();
afterHook?.({ fullPath: to });
},
+ get beforeRegistered() { return beforeRegistered; },
};
}
@@ -57,15 +59,31 @@ describe('installBlindspotRouter', () => {
expect(mockSetRouteSpan).toHaveBeenCalledTimes(1);
});
- it('clears the previous span on beforeEach', () => {
+ it('does not register a beforeEach guard (avoids the root-context gap)', () => {
+ const router = makeRouter();
+ installBlindspotRouter(router as never);
+ // A separate beforeEach clear ended the route span before afterEach re-set
+ // it, leaving a window where getRouteContext() was root — in-route clicks/
+ // fetch/errors fired in that gap orphaned into their own root traces.
+ expect(router.beforeRegistered).toBe(false);
+ });
+
+ it('clears then re-sets the route span atomically within afterEach', () => {
const router = makeRouter();
installBlindspotRouter(router as never);
router.navigate('/home');
- vi.clearAllMocks();
- mockLoadRouteContextAfterReload.mockReturnValue(undefined);
- router.navigate('/about');
+ // Both happen in the single afterEach callback, clear before set, so a route
+ // span is active at all times and in-route activity nests under it.
expect(mockClearRouteSpan).toHaveBeenCalledTimes(1);
+ expect(mockSetRouteSpan).toHaveBeenCalledTimes(1);
+ expect(mockClearRouteSpan.mock.invocationCallOrder[0]!).toBeLessThan(
+ mockSetRouteSpan.mock.invocationCallOrder[0]!,
+ );
+
+ router.navigate('/about');
+ expect(mockClearRouteSpan).toHaveBeenCalledTimes(2);
+ expect(mockSetRouteSpan).toHaveBeenCalledTimes(2);
});
it('records the from/to path on subsequent navigation', () => {
From 2ec43a33cc63f5ccd3f090fd5f41bdc9535ae827 Mon Sep 17 00:00:00 2001
From: isonimus <19539979+Isonimus@users.noreply.github.com>
Date: Mon, 8 Jun 2026 09:45:46 +0200
Subject: [PATCH 2/2] fix(svelte,next): nest in-route spans under the
navigation span
The same route-span orphaning bug as the Vue adapter, found in two more
integrations during an audit:
- svelte: cleared the route span in beforeNavigate, re-created it in afterNavigate
- next pages-router: cleared on routeChangeStart, re-created on routeChangeComplete
Both left a window where getRouteContext() was root, so in-route clicks/fetch/
errors (and intermittently the navigation-triggering click) orphaned into their
own root traces. The clear + re-create now happen atomically when the new route
span is established, matching the React and Next app-router adapters (verified
safe). The web base routing instrumentation was already atomic.
- svelte/router.ts: drop beforeNavigate; clearRouteSpan() inside afterNavigate
- next/pages-router.tsx: drop routeChangeStart; clearRouteSpan() in routeChangeComplete
- tests: assert no separate clear guard + clear-before-set ordering
- changeset: cover blindspot-vue / -svelte / -next (patch)
---
.changeset/fix-adapter-route-span-nesting.md | 17 ++++++++++++++
.changeset/fix-vue-route-span-nesting.md | 12 ----------
packages/next/src/pages-router.tsx | 11 ++++-----
packages/next/test/pages-router.test.tsx | 19 +++++++++++-----
packages/svelte/src/router.ts | 11 +++++----
packages/svelte/test/router.test.ts | 24 ++++++++++++++++----
6 files changed, 62 insertions(+), 32 deletions(-)
create mode 100644 .changeset/fix-adapter-route-span-nesting.md
delete mode 100644 .changeset/fix-vue-route-span-nesting.md
diff --git a/.changeset/fix-adapter-route-span-nesting.md b/.changeset/fix-adapter-route-span-nesting.md
new file mode 100644
index 0000000..3f6eda8
--- /dev/null
+++ b/.changeset/fix-adapter-route-span-nesting.md
@@ -0,0 +1,17 @@
+---
+"@tindalabs/blindspot-vue": patch
+"@tindalabs/blindspot-svelte": patch
+"@tindalabs/blindspot-next": patch
+---
+
+Fix: in-route activity (clicks, fetch, errors, form submits) now nests under the
+navigation span instead of orphaning into its own root trace.
+
+The Vue, Svelte, and Next.js (pages-router) integrations cleared the route span
+in a step separate from re-creating it — Vue/Svelte in a `beforeEach` /
+`beforeNavigate` guard, the Next pages-router on `routeChangeStart` — leaving a
+window where the active context was root. Any span started in that window (and
+intermittently the navigation-triggering click) became a standalone root trace.
+The clear and re-create now happen atomically when the new route span is
+established (`afterEach` / `afterNavigate` / `routeChangeComplete`), so a route
+span is active at all times — matching the React and Next app-router adapters.
diff --git a/.changeset/fix-vue-route-span-nesting.md b/.changeset/fix-vue-route-span-nesting.md
deleted file mode 100644
index e7bb540..0000000
--- a/.changeset/fix-vue-route-span-nesting.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-"@tindalabs/blindspot-vue": patch
----
-
-Fix: in-route activity (clicks, fetch, errors, form submits) now nests under the
-navigation span instead of orphaning into its own root trace. The Vue Router
-integration cleared the route span in a separate `beforeEach` guard and only
-re-created it in `afterEach`, leaving a window where the active context was root;
-any span started in that gap (and intermittently the nav-link click that started
-the navigation) became a standalone root trace. The clear and re-create now happen
-atomically in `afterEach`, so a route span is active at all times — matching the
-React adapter's behavior.
diff --git a/packages/next/src/pages-router.tsx b/packages/next/src/pages-router.tsx
index 751f9a6..d4e017b 100644
--- a/packages/next/src/pages-router.tsx
+++ b/packages/next/src/pages-router.tsx
@@ -29,12 +29,13 @@ export function BlindspotPagesRouter() {
setRouteSpan(span);
prevPath.current = initialPath;
- function onRouteChangeStart() {
- clearRouteSpan();
- }
-
function onRouteChangeComplete(url: string) {
const from = prevPath.current;
+ // End the previous route span and open the new one atomically here, on
+ // routeChangeComplete. Clearing on routeChangeStart instead left the route
+ // context root for the whole navigation, so in-route activity that fired
+ // during the transition orphaned into its own root trace.
+ clearRouteSpan();
const nextSpan = getTracer().startSpan(
`navigation ${from} → ${url}`,
{
@@ -49,11 +50,9 @@ export function BlindspotPagesRouter() {
prevPath.current = url;
}
- router.events.on('routeChangeStart', onRouteChangeStart);
router.events.on('routeChangeComplete', onRouteChangeComplete);
return () => {
- router.events.off('routeChangeStart', onRouteChangeStart);
router.events.off('routeChangeComplete', onRouteChangeComplete);
clearRouteSpan();
};
diff --git a/packages/next/test/pages-router.test.tsx b/packages/next/test/pages-router.test.tsx
index 5b83fbb..7868c2f 100644
--- a/packages/next/test/pages-router.test.tsx
+++ b/packages/next/test/pages-router.test.tsx
@@ -73,22 +73,29 @@ describe('BlindspotPagesRouter', () => {
);
});
- it('registers routeChangeStart and routeChangeComplete handlers', () => {
+ it('registers only a routeChangeComplete handler (no separate routeChangeStart clear)', () => {
render();
const events = mockRouterEvents.on.mock.calls.map(([e]) => e);
- expect(events).toContain('routeChangeStart');
expect(events).toContain('routeChangeComplete');
+ expect(events).not.toContain('routeChangeStart');
});
- it('clears the span on routeChangeStart', () => {
+ it('clears then re-sets the route span atomically within routeChangeComplete', () => {
render();
- const handler = getEventHandler('routeChangeStart');
+ const handler = getEventHandler('routeChangeComplete');
vi.clearAllMocks();
+ mockStartSpan.mockReturnValue({ end: vi.fn(), setAttribute: vi.fn(), addEvent: vi.fn() });
- act(() => { handler?.(); });
+ act(() => { handler?.('/about'); });
+ // Clearing the previous span here (not on routeChangeStart) keeps a route
+ // span active through the navigation, so in-route activity nests.
expect(mockClearRouteSpan).toHaveBeenCalledTimes(1);
+ expect(mockSetRouteSpan).toHaveBeenCalledTimes(1);
+ expect(mockClearRouteSpan.mock.invocationCallOrder[0]!).toBeLessThan(
+ mockSetRouteSpan.mock.invocationCallOrder[0]!,
+ );
});
it('creates a new span on routeChangeComplete', () => {
@@ -118,8 +125,8 @@ describe('BlindspotPagesRouter', () => {
unmount();
- expect(mockRouterEvents.off).toHaveBeenCalledWith('routeChangeStart', expect.any(Function));
expect(mockRouterEvents.off).toHaveBeenCalledWith('routeChangeComplete', expect.any(Function));
+ expect(mockRouterEvents.off).not.toHaveBeenCalledWith('routeChangeStart', expect.any(Function));
expect(mockClearRouteSpan).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/svelte/src/router.ts b/packages/svelte/src/router.ts
index e9b29bd..f12370d 100644
--- a/packages/svelte/src/router.ts
+++ b/packages/svelte/src/router.ts
@@ -26,15 +26,18 @@ export function installBlindspotRouter(hooks: NavigationHooks): void {
let isFirst = true;
let prevPath = '';
- hooks.beforeNavigate(() => {
- clearRouteSpan();
- });
-
hooks.afterNavigate((nav) => {
const search = nav.to.url.search;
const toPath = nav.to.url.pathname + (search ? search : '');
const trigger = isFirst ? 'initial' : 'user';
isFirst = false;
+
+ // End the previous route span and open the new one atomically, so a route
+ // span is active at all times. Clearing in a separate beforeNavigate left a
+ // window where getRouteContext() was root — in-route clicks/fetch/errors
+ // fired in that gap orphaned into their own root trace instead of nesting.
+ clearRouteSpan();
+
const parentContext =
trigger === 'initial' ? loadRouteContextAfterReload() : undefined;
const span = getTracer().startSpan(
diff --git a/packages/svelte/test/router.test.ts b/packages/svelte/test/router.test.ts
index a5ef0ef..64a878f 100644
--- a/packages/svelte/test/router.test.ts
+++ b/packages/svelte/test/router.test.ts
@@ -23,9 +23,12 @@ import type { NavigationEvent } from '../src/router.js';
function makeHooks() {
let beforeHook: ((nav: { cancel: () => void }) => void) | undefined;
let afterHook: ((nav: NavigationEvent) => void) | undefined;
+ let beforeRegistered = false;
return {
+ get beforeRegistered() { return beforeRegistered; },
beforeNavigate(fn: (nav: { cancel: () => void }) => void) {
+ beforeRegistered = true;
beforeHook = fn;
},
afterNavigate(fn: (nav: NavigationEvent) => void) {
@@ -75,15 +78,28 @@ describe('installBlindspotRouter', () => {
expect(mockSetRouteSpan).toHaveBeenCalledTimes(1);
});
- it('clears the previous span on beforeNavigate', () => {
+ it('does not register a beforeNavigate guard (avoids the root-context gap)', () => {
+ const hooks = makeHooks();
+ installBlindspotRouter(hooks);
+ // Clearing in a separate beforeNavigate ended the route span before
+ // afterNavigate re-set it — in-route activity in that gap orphaned.
+ expect(hooks.beforeRegistered).toBe(false);
+ });
+
+ it('clears then re-sets the route span atomically within afterNavigate', () => {
const hooks = makeHooks();
installBlindspotRouter(hooks);
hooks.navigate(null, '/home');
- vi.clearAllMocks();
- mockLoadRouteContextAfterReload.mockReturnValue(undefined);
- hooks.navigate('/home', '/about');
expect(mockClearRouteSpan).toHaveBeenCalledTimes(1);
+ expect(mockSetRouteSpan).toHaveBeenCalledTimes(1);
+ expect(mockClearRouteSpan.mock.invocationCallOrder[0]!).toBeLessThan(
+ mockSetRouteSpan.mock.invocationCallOrder[0]!,
+ );
+
+ hooks.navigate('/home', '/about');
+ expect(mockClearRouteSpan).toHaveBeenCalledTimes(2);
+ expect(mockSetRouteSpan).toHaveBeenCalledTimes(2);
});
it('records from/to path on subsequent navigation', () => {