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
17 changes: 17 additions & 0 deletions .changeset/fix-adapter-route-span-nesting.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 5 additions & 6 deletions packages/next/src/pages-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
{
Expand All @@ -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();
};
Expand Down
19 changes: 13 additions & 6 deletions packages/next/test/pages-router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,29 @@ describe('BlindspotPagesRouter', () => {
);
});

it('registers routeChangeStart and routeChangeComplete handlers', () => {
it('registers only a routeChangeComplete handler (no separate routeChangeStart clear)', () => {
render(<BlindspotPagesRouter />);

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(<BlindspotPagesRouter />);
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', () => {
Expand Down Expand Up @@ -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);
});
});
11 changes: 7 additions & 4 deletions packages/svelte/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 20 additions & 4 deletions packages/svelte/test/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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', () => {
Expand Down
12 changes: 8 additions & 4 deletions packages/vue/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 23 additions & 5 deletions packages/vue/test/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; },
};
}

Expand Down Expand Up @@ -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', () => {
Expand Down