Skip to content

Commit 9f08530

Browse files
authored
feat(vue): Vue Router performance tracing (#74)
* docs: vue-router performance tracing design spec * refactor(js): shared insulate/safeInvoke instrumentation guards * refactor(react): use shared insulate/safeInvoke in router integrations * feat(vue): vue-router performance tracing (traceVueRouter) * feat(vue): flareVue { router } option wires vue-router tracing * feat(playgrounds): enable tracing + router option in vue playground * test(e2e): vue-router pageload/navigation trace assertions * docs: vue-router tracing spec — shared instrumentation guards + HMR dedup * fix(vue): guard traceVueRouter cleanup + pin router-tracing edge cases Guard the WeakMap delete in cleanup() so a stale HMR cleanup can't disable dedup for the active guards. Add coverage for the onError empty-name fallback and blocked-initial-navigation pageload naming, and assert the settled route name (not just call count) on the existing onError test. * fix(vue): gate router tracing wiring on enableTracing Skip traceVueRouter when enableTracing is off, so passing { router } without tracing enabled no longer attaches no-op guards to the host's router or registers a dead navigation source. Adds an entry test for the gate and updates the design spec to match. * fix(vue): type FlareErrorBoundary $router mock for active vue-router augmentation
1 parent 694ebc8 commit 9f08530

22 files changed

Lines changed: 1349 additions & 57 deletions

docs/superpowers/specs/2026-07-14-performance-tracing-framework-router-vue-router-design.md

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

e2e/specs/vue.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { testIds } from '../../playgrounds/shared/src';
22
import { expect, test } from '../fixtures/fake-flare';
33
import { logScenariosFor, runLogScenario } from './logShared';
4+
import { attr, hasSpanType, spansOf } from './otlp';
45
import { runScenario, scenariosFor } from './shared';
56

67
test.describe('vue playground', () => {
@@ -30,6 +31,57 @@ test.describe('vue playground', () => {
3031
});
3132
});
3233

34+
test.describe('vue-router tracing', () => {
35+
test('pageload root carries the parameterized route and route source', async ({ page, fakeFlare }) => {
36+
await page.goto('/product/p01');
37+
await page.waitForLoadState('networkidle');
38+
39+
const trace = await fakeFlare.waitForTrace({
40+
timeout: 9000,
41+
predicate: (r) => {
42+
const pl = spansOf(r.bodyJson).find((s) => hasSpanType(s, 'browser_pageload'));
43+
return !!pl && JSON.stringify(attr(pl, 'flare.route.source') ?? '').includes('route');
44+
},
45+
});
46+
const pageload = spansOf(trace.bodyJson).find((s) => hasSpanType(s, 'browser_pageload'));
47+
expect(pageload && attr(pageload, 'flare.entry_point.handler.identifier')).toEqual({
48+
stringValue: '/product/:id',
49+
});
50+
expect(pageload && attr(pageload, 'flare.route.source')).toEqual({ stringValue: 'route' });
51+
});
52+
53+
test('client navigation opens a parameterized browser_navigation root (exactly one)', async ({
54+
page,
55+
fakeFlare,
56+
}) => {
57+
await page.goto('/');
58+
await page.waitForLoadState('networkidle');
59+
60+
await page.locator('a[href="/product/p01"]').first().click();
61+
62+
const trace = await fakeFlare.waitForTrace({
63+
timeout: 9000,
64+
predicate: (r) => {
65+
const nav = spansOf(r.bodyJson).find((s) => hasSpanType(s, 'browser_navigation'));
66+
return (
67+
!!nav &&
68+
JSON.stringify(attr(nav, 'flare.entry_point.handler.identifier') ?? '').includes('/product/:id')
69+
);
70+
},
71+
});
72+
const nav = spansOf(trace.bodyJson).find((s) => hasSpanType(s, 'browser_navigation'));
73+
expect(nav && attr(nav, 'flare.entry_point.handler.identifier')).toEqual({ stringValue: '/product/:id' });
74+
expect(nav && attr(nav, 'flare.route.source')).toEqual({ stringValue: 'route' });
75+
76+
// No-double-roots invariant: registerNavigationSource suppresses the History-based root, so this
77+
// one click produces exactly ONE browser_navigation root across all traces.
78+
const navSpans = (await fakeFlare.traces())
79+
.flatMap((t) => spansOf(t.bodyJson))
80+
.filter((s) => hasSpanType(s, 'browser_navigation'));
81+
expect(navSpans).toHaveLength(1);
82+
});
83+
});
84+
3385
test.describe('vue logging', () => {
3486
for (const scenario of logScenariosFor('vue').filter((s) => s.flushOnTrigger)) {
3587
test(scenario.id, async ({ page, fakeFlare }) => {

packages/js/src/browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export { collectBrowser } from './browser/context/collectBrowser';
5858
export { FetchFileReader } from './browser/FetchFileReader';
5959
export { BrowserFlushScheduler } from './browser/BrowserFlushScheduler';
6060
export { registerNavigationSource, type NavigationSource, type RouteName } from './tracing/browserTracing';
61+
export { insulate, safeInvoke } from './tracing/instrumentationGuard';
6162
export {
6263
activeComponentRoot,
6364
reserveSpanId,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Pure, environment-agnostic guards shared by the framework router integrations. They encode the one
2+
// rule every host-invoked instrumentation callback must obey: a tracing throw can never escape into the
3+
// host's dispatch. Exported from the side-effect-free '@flareapp/js/browser' barrel.
4+
5+
/** Wrap a host-invoked callback (router guard / store subscriber) so a tracing throw can never escape
6+
* into the host's dispatch. A thrown callback resolves to `undefined`. */
7+
export function insulate<A extends unknown[]>(fn: (...a: A) => void): (...a: A) => void {
8+
return (...a: A): void => {
9+
try {
10+
fn(...a);
11+
} catch {
12+
// instrumentation never breaks the host
13+
}
14+
};
15+
}
16+
17+
/** Invoke a teardown fn now (if present), swallowing any throw. For cleanup chains. */
18+
export function safeInvoke(fn: (() => void) | null | undefined): void {
19+
try {
20+
fn?.();
21+
} catch {
22+
// ignore
23+
}
24+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { insulate, safeInvoke } from '../src/tracing/instrumentationGuard';
4+
5+
describe('insulate', () => {
6+
it('forwards args to the wrapped fn', () => {
7+
const fn = vi.fn();
8+
insulate(fn)('a', 1);
9+
expect(fn).toHaveBeenCalledWith('a', 1);
10+
});
11+
12+
it('swallows a throw and returns undefined', () => {
13+
const wrapped = insulate(() => {
14+
throw new Error('boom');
15+
});
16+
expect(wrapped()).toBeUndefined();
17+
expect(() => wrapped()).not.toThrow();
18+
});
19+
});
20+
21+
describe('safeInvoke', () => {
22+
it('invokes the fn', () => {
23+
const fn = vi.fn();
24+
safeInvoke(fn);
25+
expect(fn).toHaveBeenCalledOnce();
26+
});
27+
28+
it('tolerates null / undefined', () => {
29+
expect(() => safeInvoke(null)).not.toThrow();
30+
expect(() => safeInvoke(undefined)).not.toThrow();
31+
});
32+
33+
it('swallows a throw', () => {
34+
expect(() =>
35+
safeInvoke(() => {
36+
throw new Error('boom');
37+
}),
38+
).not.toThrow();
39+
});
40+
});

packages/react/src/react-router.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Electron-safe entry: NO @flareapp/js root import. The navigation-source seam comes from
22
// @flareapp/js/browser (side-effect-free). NO runtime dependency on react-router — the router is
33
// consumed structurally (see ./vendor/reactRouterTypes).
4-
import { registerNavigationSource, type RouteName } from '@flareapp/js/browser';
4+
import { insulate, registerNavigationSource, safeInvoke, type RouteName } from '@flareapp/js/browser';
55

66
import type { RRDataRouter, RRLocation, RRMatch, RRRouterState } from './vendor/reactRouterTypes';
77

@@ -123,24 +123,10 @@ export function traceReactRouter(router: RRDataRouter): () => void {
123123
// else (inFlight && non-idle): a redirect / superseding hop -> keep the single held root.
124124
};
125125

126-
const unsubscribe = router.subscribe((state) => {
127-
try {
128-
onState(state);
129-
} catch {
130-
// a tracing error must never escape into the router's state dispatch
131-
}
132-
});
126+
const unsubscribe = router.subscribe(insulate(onState));
133127

134128
return () => {
135-
try {
136-
unsubscribe();
137-
} catch {
138-
// ignore
139-
}
140-
try {
141-
nav.unregister();
142-
} catch {
143-
// ignore
144-
}
129+
safeInvoke(unsubscribe);
130+
safeInvoke(() => nav.unregister());
145131
};
146132
}

packages/react/src/tanstack-router.ts

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Electron-safe entry: NO @flareapp/js root import. The navigation-source seam
22
// comes from @flareapp/js/browser (side-effect-free). NO runtime dependency on
33
// @tanstack/react-router — the router is consumed structurally (see ./vendor).
4-
import { registerNavigationSource, type RouteName } from '@flareapp/js/browser';
4+
import { insulate, registerNavigationSource, safeInvoke, type RouteName } from '@flareapp/js/browser';
55

66
import type { TsrLocation, TsrNavEvent, TsrRouter } from './vendor/tanstackRouterTypes';
77

@@ -27,17 +27,6 @@ export function traceTanStackRouter(router: TsrRouter): () => void {
2727
return { name: loc.pathname, source: 'url' };
2828
};
2929

30-
// A tracing error must never escape into the router's event dispatch.
31-
const guard =
32-
(fn: (event: TsrNavEvent) => void) =>
33-
(event: TsrNavEvent): void => {
34-
try {
35-
fn(event);
36-
} catch {
37-
// swallow: instrumentation never breaks the host
38-
}
39-
};
40-
4130
// Enrich the pageload root immediately from the current (already-resolved) location.
4231
try {
4332
nav.setActiveRouteName(routeNameFor(router.state.location));
@@ -49,7 +38,7 @@ export function traceTanStackRouter(router: TsrRouter): () => void {
4938

5039
const offBeforeLoad = router.subscribe(
5140
'onBeforeLoad',
52-
guard((e) => {
41+
insulate((e: TsrNavEvent) => {
5342
if (e.fromLocation === undefined) return; // initial pageload (handled via onResolved)
5443
if (e.toLocation.state === e.fromLocation.state) return; // no-op reload (e.g. router.invalidate())
5544
if (!inFlight) {
@@ -62,7 +51,7 @@ export function traceTanStackRouter(router: TsrRouter): () => void {
6251

6352
const offResolved = router.subscribe(
6453
'onResolved',
65-
guard((e) => {
54+
insulate((e: TsrNavEvent) => {
6655
if (e.fromLocation === undefined) {
6756
nav.setActiveRouteName(routeNameFor(e.toLocation)); // one-shot pageload correction
6857
return;
@@ -75,20 +64,8 @@ export function traceTanStackRouter(router: TsrRouter): () => void {
7564
);
7665

7766
return () => {
78-
try {
79-
offBeforeLoad();
80-
} catch {
81-
// ignore
82-
}
83-
try {
84-
offResolved();
85-
} catch {
86-
// ignore
87-
}
88-
try {
89-
nav.unregister();
90-
} catch {
91-
// ignore
92-
}
67+
safeInvoke(offBeforeLoad);
68+
safeInvoke(offResolved);
69+
safeInvoke(() => nav.unregister());
9370
};
9471
}

packages/react/tests/react-router.entry.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@ describe('@flareapp/react/react-router entry', () => {
1010
test('importing the entry does NOT evaluate the @flareapp/js root singleton', async () => {
1111
const rootFactory = vi.fn(() => ({ flare: {} }));
1212
vi.doMock('@flareapp/js', rootFactory);
13-
vi.doMock('@flareapp/js/browser', () => ({ registerNavigationSource: () => ({}) }));
13+
vi.doMock('@flareapp/js/browser', () => ({
14+
registerNavigationSource: () => ({}),
15+
insulate: (fn: (...a: unknown[]) => void) => fn,
16+
safeInvoke: (fn?: () => void) => fn?.(),
17+
}));
1418
await import('../src/react-router');
1519
expect(rootFactory).not.toHaveBeenCalled();
1620
expect((window as unknown as { flare?: unknown }).flare).toBeUndefined();
1721
});
1822

1923
test('exports traceReactRouter', async () => {
20-
vi.doMock('@flareapp/js/browser', () => ({ registerNavigationSource: () => ({}) }));
24+
vi.doMock('@flareapp/js/browser', () => ({
25+
registerNavigationSource: () => ({}),
26+
insulate: (fn: (...a: unknown[]) => void) => fn,
27+
safeInvoke: (fn?: () => void) => fn?.(),
28+
}));
2129
const mod = await import('../src/react-router');
2230
expect(typeof mod.traceReactRouter).toBe('function');
2331
});

packages/react/tests/react-router.integration.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,25 @@ const nav = vi.hoisted(() => ({
88
settleNavigation: vi.fn(),
99
unregister: vi.fn(),
1010
}));
11-
vi.mock('@flareapp/js/browser', () => ({ registerNavigationSource: vi.fn(() => nav) }));
11+
vi.mock('@flareapp/js/browser', () => ({
12+
registerNavigationSource: vi.fn(() => nav),
13+
insulate:
14+
(fn: (...a: unknown[]) => void) =>
15+
(...a: unknown[]) => {
16+
try {
17+
fn(...a);
18+
} catch {
19+
/* swallow */
20+
}
21+
},
22+
safeInvoke: (fn?: (() => void) | null) => {
23+
try {
24+
fn?.();
25+
} catch {
26+
/* swallow */
27+
}
28+
},
29+
}));
1230

1331
import { traceReactRouter } from '../src/react-router';
1432
import type { RRDataRouter } from '../src/vendor/reactRouterTypes';

packages/react/tests/react-router.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,25 @@ const nav = vi.hoisted(() => ({
77
settleNavigation: vi.fn(),
88
unregister: vi.fn(),
99
}));
10-
vi.mock('@flareapp/js/browser', () => ({ registerNavigationSource: vi.fn(() => nav) }));
10+
vi.mock('@flareapp/js/browser', () => ({
11+
registerNavigationSource: vi.fn(() => nav),
12+
insulate:
13+
(fn: (...a: unknown[]) => void) =>
14+
(...a: unknown[]) => {
15+
try {
16+
fn(...a);
17+
} catch {
18+
/* swallow */
19+
}
20+
},
21+
safeInvoke: (fn?: (() => void) | null) => {
22+
try {
23+
fn?.();
24+
} catch {
25+
/* swallow */
26+
}
27+
},
28+
}));
1129

1230
import { routeNameFromMatches, traceReactRouter } from '../src/react-router';
1331
import type { RRMatch, RRRouterState } from '../src/vendor/reactRouterTypes';

0 commit comments

Comments
 (0)