@maple-dev/browser 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,8 +28,8 @@ MapleBrowser.init({
28
28
 
29
29
  That single call:
30
30
 
31
- - starts OTel browser tracing, auto-instrumenting `fetch`, exporting to Maple's
32
- ingest (`POST /v1/traces`);
31
+ - starts OTel browser tracing, auto-instrumenting `fetch` and `XMLHttpRequest`,
32
+ exporting to Maple's ingest (`POST /v1/traces`);
33
33
  - records the session with rrweb, chunking events (~5s / 100KB windows),
34
34
  gzipping them with the native `CompressionStream`, and uploading to
35
35
  `POST /v1/sessionReplays/blob`. rrweb ships in a lazy code-split chunk loaded
@@ -57,6 +57,11 @@ try {
57
57
  }
58
58
  ```
59
59
 
60
+ `captureException` is safe before `init()`: calls are held (up to 30) and recorded when it runs.
61
+ A thrown value that is not an `Error` (`throw "boom"`, `throw { code: 1 }`) is recorded with its
62
+ fields as the message, and an uncaught one gets a single frame at the throw site rather than a
63
+ stack from inside the SDK.
64
+
60
65
  Opt out of the global handlers with `tracing: { captureErrors: false }` — worth
61
66
  doing only when another tracker already owns them, or the same crash is recorded
62
67
  twice.
@@ -70,7 +75,10 @@ fingerprint to one contentless issue that buries the real ones. Add
70
75
 
71
76
  `fetch`/XHR responses with a 4xx or 5xx status are errors, typed by status, as the HTTP semantic
72
77
  conventions say for client spans. Narrow that with `errors: { captureHttpStatus: [[500, 599]] }`.
73
- A network failure is always an error.
78
+ A request that never got a response is an error too, unless the connection went away while it was
79
+ in flight: the page unloaded or was hidden, or the browser went offline (or was offline when it
80
+ failed). Those stay `Unset` with `maple.connection_lost` set to `page_unload`, `page_hidden` or
81
+ `offline`, since the browser cancelled them, not your API.
74
82
 
75
83
  ### Breadcrumbs
76
84
 
@@ -99,9 +107,10 @@ in production.
99
107
 
100
108
  ## Identifying users
101
109
 
102
- Pass `userId` to `init()` when you already know the signed-in user, or call
103
- `MapleBrowser.identify(user.id)` later. The id is attached to future session
104
- metadata rows and stamped as `user.id` on future browser-created spans.
110
+ Pass `user` to `init()` when you already know the signed-in user
111
+ (`user: { id: user.id, email: user.email }`), or call `MapleBrowser.identify(user.id)` later.
112
+ The id is attached to future session metadata rows and stamped as `user.id` on future
113
+ browser-created spans.
105
114
 
106
115
  ```ts
107
116
  MapleBrowser.identify(user.id)
@@ -182,7 +191,7 @@ MapleBrowser.init({
182
191
 
183
192
  Call these from your router's hooks to make one click one trace: a navigation
184
193
  span, the data-loading spans under it, and the `fetch` spans they start.
185
- [maple.dev/docs/frontend](https://maple.dev/docs/frontend) shows where for each
194
+ [maple.dev/docs/session-replay/browser-sdk](https://maple.dev/docs/session-replay/browser-sdk#framework-examples) shows where for each
186
195
  framework.
187
196
 
188
197
  ```ts
@@ -196,12 +205,12 @@ MapleBrowser.endNavigation("/projects/:id") // the route is ready: its template
196
205
  - The first `startNavigation` opens a `pageload` span, joined to the server
197
206
  render's trace from a `Server-Timing: traceparent;desc="…"` entry or a
198
207
  `<meta name="traceparent">` tag; later calls open `navigate` spans, and end
199
- one still open as `app.navigation.interrupted`.
208
+ one still open, marked `maple.navigation.interrupted`.
200
209
  - The document's `pageload` starts at navigation start, and gets child spans
201
210
  from the Navigation Timing entry once the page has loaded: `documentFetch`
202
211
  (with `dns`, `connect`, `request` and `response` under it), `domProcessing`
203
212
  and `loadEvent`.
204
- - `traced` returns `fn`'s result and rethrows its error unchanged. Only requests
213
+ - `traced` takes a sync or async `fn`, returns its result and rethrows its error unchanged. Only requests
205
214
  started before `fn`'s first `await` nest under its span. An error it recorded
206
215
  isn't reported again by `captureException` or the global handlers.
207
216
  - All three are no-ops on the server, before `init()`, with tracing disabled or
@@ -220,6 +229,11 @@ instrumentTanStackRouter(router) // navigate /projects/$projectId
220
229
  ```
221
230
 
222
231
  Attach them after `MapleBrowser.init`: a navigation reported earlier uses up the page load.
232
+ Attaching the same router twice (React StrictMode, HMR) keeps one instrumentation, and the
233
+ unsubscribe they return ends a navigation still open as interrupted.
234
+
235
+ `mapleReactErrorHandler(source, next)` replaces React's own console logging for that callback, so
236
+ it hands the error on to `next` afterwards, `console.error` by default.
223
237
 
224
238
  ## Linking a marketing site to your app
225
239
 
@@ -0,0 +1,183 @@
1
+ import { I as safeEmit, L as truncateText, Y as isJsonObject, b as patchMethod } from "./sink-Ji0J42ul.mjs";
2
+ //#region ../browser-session/src/replay/capture/console.ts
3
+ const LEVELS = [
4
+ "log",
5
+ "info",
6
+ "warn",
7
+ "error",
8
+ "debug"
9
+ ];
10
+ const MAX_MESSAGE = 2e3;
11
+ /** No array logged into a 2,000-character message needs more elements than this. */
12
+ const MAX_ARRAY_ELEMENTS = 100;
13
+ /** Nor more keys of one object, or more levels of nesting. */
14
+ const MAX_OBJECT_KEYS = 50;
15
+ const MAX_DEPTH = 6;
16
+ /**
17
+ * Everyone capturing this module's console wrappers: breadcrumbs and replay can
18
+ * both listen. One wrapper per level per page, toggled by whether anyone
19
+ * listens, so starting and stopping capture across visibility changes does not
20
+ * stack wrappers.
21
+ */
22
+ const listeners = /* @__PURE__ */ new Set();
23
+ let unpatch = [];
24
+ /** Set while listeners run: a listener that logs must not be captured again. */
25
+ let dispatching = false;
26
+ function wrapLevel(level) {
27
+ return (original, hooks) => (...args) => {
28
+ const subscribers = hooks();
29
+ if (subscribers && !dispatching) {
30
+ dispatching = true;
31
+ try {
32
+ for (const listener of subscribers) listener(level, args);
33
+ } catch {} finally {
34
+ dispatching = false;
35
+ }
36
+ }
37
+ original.apply(console, args);
38
+ };
39
+ }
40
+ /**
41
+ * Capture `console.*` calls as session events, then forward to the original so
42
+ * the host app's console behaves normally. Never throws into the call site.
43
+ * Teardown leaves a wrapper that something else wrapped in place, inert.
44
+ */
45
+ function installConsoleCapture(emit, options = {}) {
46
+ const listener = (level, args) => {
47
+ safeEmit(emit, {
48
+ type: "console",
49
+ level,
50
+ ...options.maskText ? void 0 : { message: formatArgs(args) }
51
+ });
52
+ };
53
+ if (listeners.size === 0) unpatch = LEVELS.map((level) => patchMethod(console, level, listeners, wrapLevel(level)));
54
+ listeners.add(listener);
55
+ let removed = false;
56
+ return () => {
57
+ if (removed) return;
58
+ removed = true;
59
+ listeners.delete(listener);
60
+ if (listeners.size > 0) return;
61
+ for (const off of unpatch) off();
62
+ unpatch = [];
63
+ };
64
+ }
65
+ /** One console line as text, at most `MAX_MESSAGE` characters. Never throws. */
66
+ function formatArgs(args) {
67
+ let text = "";
68
+ for (const arg of args) {
69
+ if (text.length > MAX_MESSAGE) break;
70
+ const room = MAX_MESSAGE - text.length + 1;
71
+ text += (text ? " " : "") + formatArg(arg, room);
72
+ }
73
+ return truncateText(text, MAX_MESSAGE, "…");
74
+ }
75
+ function formatArg(arg, budget) {
76
+ if (typeof arg === "string") return truncateText(arg, budget);
77
+ try {
78
+ if (arg instanceof Error) return truncateText(`${arg.name}: ${arg.message}`, budget);
79
+ return boundedStringify(arg, budget);
80
+ } catch {
81
+ return "[Unserializable]";
82
+ }
83
+ }
84
+ /**
85
+ * JSON-like text of `value`, stopping once roughly `budget` characters are
86
+ * written. Reads own data properties only, never getters or `toJSON`, and caps
87
+ * keys, elements and depth, so logging a store object costs bounded time and
88
+ * runs none of the page's code. A hostile object (a throwing Proxy) ends the
89
+ * walk with what was written so far.
90
+ */
91
+ function boundedStringify(value, budget) {
92
+ let out = "";
93
+ const ancestors = [];
94
+ const full = () => out.length > budget;
95
+ const room = () => Math.max(0, budget - out.length) + 2;
96
+ /** A quoted string cut to the room left: escaping can grow it sixfold. */
97
+ const quote = (text) => truncateText(JSON.stringify(truncateText(text, room())), room());
98
+ const visit = (node, depth) => {
99
+ if (full()) return;
100
+ switch (typeof node) {
101
+ case "string":
102
+ out += quote(node);
103
+ return;
104
+ case "number":
105
+ case "boolean":
106
+ case "bigint":
107
+ case "symbol":
108
+ case "undefined":
109
+ out += truncateText(String(node), room());
110
+ return;
111
+ case "function":
112
+ out += "[Function]";
113
+ return;
114
+ }
115
+ if (typeof node !== "object" || node === null) {
116
+ out += "null";
117
+ return;
118
+ }
119
+ if (ancestors.includes(node)) {
120
+ out += "\"[Circular]\"";
121
+ return;
122
+ }
123
+ if (node instanceof Date) {
124
+ out += Number.isNaN(node.getTime()) ? "null" : JSON.stringify(node.toISOString());
125
+ return;
126
+ }
127
+ if (depth >= MAX_DEPTH) {
128
+ out += Array.isArray(node) ? "[…]" : "{…}";
129
+ return;
130
+ }
131
+ ancestors.push(node);
132
+ try {
133
+ if (Array.isArray(node)) visitArray(node, depth);
134
+ else if (isJsonObject(node)) visitObject(node, depth);
135
+ } catch {} finally {
136
+ ancestors.pop();
137
+ }
138
+ };
139
+ const visitArray = (node, depth) => {
140
+ out += "[";
141
+ try {
142
+ const count = Math.min(node.length, MAX_ARRAY_ELEMENTS);
143
+ for (let index = 0; index < count && !full(); index++) {
144
+ if (index > 0) out += ",";
145
+ visitProperty(node, String(index), depth + 1);
146
+ }
147
+ if (node.length > count && !full()) out += ",…";
148
+ } finally {
149
+ out += "]";
150
+ }
151
+ };
152
+ /** An own property's value, without running a getter: one could have side effects. */
153
+ const visitProperty = (node, key, depth) => {
154
+ const descriptor = Object.getOwnPropertyDescriptor(node, key);
155
+ if (!descriptor) out += "undefined";
156
+ else visit("value" in descriptor ? descriptor.value : "[Getter]", depth);
157
+ };
158
+ const visitObject = (node, depth) => {
159
+ out += "{";
160
+ try {
161
+ let written = 0;
162
+ for (const key in node) {
163
+ if (full()) break;
164
+ if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
165
+ if (written === MAX_OBJECT_KEYS) {
166
+ out += ",…";
167
+ break;
168
+ }
169
+ out += `${written > 0 ? "," : ""}${quote(key)}:`;
170
+ visitProperty(node, key, depth + 1);
171
+ written++;
172
+ }
173
+ } finally {
174
+ out += "}";
175
+ }
176
+ };
177
+ try {
178
+ visit(value, 0);
179
+ } catch {}
180
+ return out;
181
+ }
182
+ //#endregion
183
+ export { installConsoleCapture as t };
@@ -1,6 +1,6 @@
1
- import { A as selectorOf, G as scrubUrl, K as mayUseStorage, L as consentAllowedSince, O as sdkHint, R as consentRevokedAt, S as ingestHeaders, V as onConsentChange, l as onSessionEvent, z as hasConsent } from "./sink-DbEuphC-.mjs";
2
- import { _ as severityOf, a as detachLogSink, c as resourceAttributes, d as OtlpExporter, f as newestFirstOnExit, g as Severity, h as onErrorRecorded, i as attachLogSink, l as SDK_NAME, m as claimPageSignal, n as navigationSpanAt, o as emitLog, p as attachSpanStash, r as onDocumentPageload, s as liveMapleTracer, u as SDK_VERSION } from "./src-DfU4Cnb1.mjs";
3
- import { t as installConsoleCapture } from "./console-Bkd3hBGp.mjs";
1
+ import { E as ingestHeaders, H as consentAllowedSince, K as onConsentChange, M as sdkHint, P as selectorOf, U as consentRevokedAt, W as hasConsent, X as scrubUrl, Z as mayUseStorage, l as onSessionEvent } from "./sink-Ji0J42ul.mjs";
2
+ import { _ as Severity, a as attachLogSink, c as liveMapleTracer, d as SDK_VERSION, f as OtlpExporter, g as onErrorRecorded, h as claimPageSignal, i as onDocumentPageload, l as resourceAttributes, m as attachSpanStash, o as detachLogSink, p as newestFirstOnExit, r as navigationSpanAt, s as emitLog, u as SDK_NAME, v as severityOf } from "./src-JUynR8Jc.mjs";
3
+ import { t as installConsoleCapture } from "./console-iNhlufZV.mjs";
4
4
  import { ROOT_CONTEXT, TraceFlags, context, trace } from "@opentelemetry/api";
5
5
  import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
6
6
  import { resourceFromAttributes } from "@opentelemetry/resources";
@@ -505,6 +505,19 @@ function startReports(emitLog, options) {
505
505
  //#endregion
506
506
  //#region ../sdk-core/src/browser/web-vitals.ts
507
507
  let registered = false;
508
+ /**
509
+ * The path of the page view the metrics measure. web-vitals reports CLS, INP and
510
+ * LCP for the whole document (often when it is hidden, long after an SPA moved
511
+ * on), so the path is the document's, and a bfcache restore starts a new one.
512
+ */
513
+ let pagePath = "";
514
+ function documentPath() {
515
+ const [entry] = performance.getEntriesByType?.("navigation") ?? [];
516
+ try {
517
+ if (entry?.name) return new URL(entry.name).pathname;
518
+ } catch {}
519
+ return location.pathname;
520
+ }
508
521
  let emit;
509
522
  let pageload;
510
523
  function report(metric) {
@@ -519,7 +532,7 @@ function report(metric) {
519
532
  "browser.web_vital.id": metric.id,
520
533
  "browser.web_vital.rating": metric.rating,
521
534
  "browser.web_vital.navigation_type": metric.navigationType,
522
- "url.path": scrubUrl(location.pathname)
535
+ "url.path": pagePath
523
536
  },
524
537
  link: pageload?.()
525
538
  });
@@ -530,6 +543,10 @@ function startWebVitals(sink, getPageload) {
530
543
  pageload = getPageload;
531
544
  if (!registered) {
532
545
  registered = true;
546
+ pagePath = scrubUrl(documentPath());
547
+ window.addEventListener("pageshow", (event) => {
548
+ if (event.persisted) pagePath = scrubUrl(location.pathname);
549
+ });
533
550
  for (const on of [
534
551
  onCLS,
535
552
  onFCP,
@@ -595,7 +612,16 @@ function startLogs(config, stashOffline) {
595
612
  ingestKey: config.ingestKey,
596
613
  sdk: sdkHint(SDK_NAME, SDK_VERSION)
597
614
  }), JsonLogsSerializer);
598
- const exporter = new ConsentLogExporter(newestFirstOnExit(stashOffline ? new OfflineLogExporter(otlp, stashOffline) : otlp, JsonLogsSerializer));
615
+ let leaving = false;
616
+ const exporter = new ConsentLogExporter(newestFirstOnExit(stashOffline ? new OfflineLogExporter(otlp, stashOffline) : otlp, JsonLogsSerializer, 0, () => leaving));
617
+ const onPageHide = () => {
618
+ leaving = true;
619
+ provider.forceFlush().catch(() => {}).finally(() => {
620
+ leaving = false;
621
+ });
622
+ };
623
+ const canListen = typeof window !== "undefined" && typeof window.addEventListener === "function";
624
+ if (canListen) window.addEventListener("pagehide", onPageHide);
599
625
  const provider = new LoggerProvider({
600
626
  resource: resourceFromAttributes(resourceAttributes(config)),
601
627
  processors: [new BatchLogRecordProcessor({
@@ -614,6 +640,7 @@ function startLogs(config, stashOffline) {
614
640
  context: record.link ? trace.setSpanContext(ROOT_CONTEXT, record.link) : ROOT_CONTEXT
615
641
  }));
616
642
  return async () => {
643
+ if (canListen) window.removeEventListener("pagehide", onPageHide);
617
644
  detachLogSink();
618
645
  await provider.shutdown();
619
646
  };
package/dist/index.d.mts CHANGED
@@ -283,9 +283,15 @@ interface CaptureExceptionOptions {
283
283
  //#endregion
284
284
  //#region src/init.d.ts
285
285
  interface MapleBrowserHandle {
286
- /** Empty until consent is granted when `requireConsent` is enabled. */
286
+ /**
287
+ * Empty until consent is granted when `requireConsent` is enabled, and while
288
+ * a previous `shutdown()` is still flushing.
289
+ */
287
290
  readonly sessionId: string;
288
- /** Tear down tracing + session capture, flushing the final buffers. */
291
+ /**
292
+ * Tear down tracing + session capture, flushing the final buffers. The SDK
293
+ * is released at once: an `init()` right after it gets a new, live handle.
294
+ */
289
295
  readonly shutdown: () => Promise<void>;
290
296
  }
291
297
  //#endregion
@@ -321,10 +327,11 @@ export interface MapleBrowserApi {
321
327
  */
322
328
  track: (name: string, props?: TrackProps) => void;
323
329
  /**
324
- * Report an error your app already caught — the case the global handlers
330
+ * Report an error your app already caught: the case the global handlers
325
331
  * cannot see, because catching it is what stops it reaching them. A
326
332
  * framework error boundary is the canonical caller. The same error object
327
- * is recorded once, even if it is rethrown afterwards.
333
+ * is recorded once, even if it is rethrown afterwards. Safe before `init`:
334
+ * calls are held (capped) and recorded when it runs.
328
335
  *
329
336
  * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw
330
337
  * anything. `captureException` narrows it before it reaches a span.
@@ -340,7 +347,7 @@ export interface MapleBrowserApi {
340
347
  * Run data loading, like a route loader, in a span under the current navigation.
341
348
  * Errors are recorded once and rethrown. Only requests started before `fn`'s first `await` nest under the span.
342
349
  */
343
- traced: <T>(name: string, fn: () => Promise<T>, options?: TracedOptions) => Promise<T>;
350
+ traced: <T>(name: string, fn: () => T | Promise<T>, options?: TracedOptions) => Promise<T>;
344
351
  /**
345
352
  * Structured logs, exported as OpenTelemetry log records linked to the active
346
353
  * span and the session. Safe before `init`: records queue until it runs.
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as MapleBrowser } from "./src-DfU4Cnb1.mjs";
1
+ import { t as MapleBrowser } from "./src-JUynR8Jc.mjs";
2
2
  export { MapleBrowser };
package/dist/react.d.mts CHANGED
@@ -24,14 +24,17 @@ export declare class MapleErrorBoundary extends Component<MapleErrorBoundaryProp
24
24
  private readonly reset;
25
25
  render(): ReactNode;
26
26
  }
27
+ type ReactRootErrorHandler = (error: unknown, info: {
28
+ readonly componentStack?: string | null | undefined;
29
+ }) => void;
27
30
  /**
28
31
  * For React 19's `createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError })`:
29
32
  * reports what React caught, with its component stack. Errors already reported
30
- * (by a `MapleErrorBoundary`, say) are not reported twice.
33
+ * (by a `MapleErrorBoundary`, say) are not reported twice. Passing a handler
34
+ * replaces React's own console logging, so the error is then handed to `next`,
35
+ * which defaults to `console.error`.
31
36
  */
32
- export declare function mapleReactErrorHandler(source?: string): (error: unknown, info: {
33
- readonly componentStack?: string | null | undefined;
34
- }) => void;
37
+ export declare function mapleReactErrorHandler(source?: string, next?: ReactRootErrorHandler): ReactRootErrorHandler;
35
38
  /** A React Router data router (`createBrowserRouter` and friends), as far as Maple reads it. */
36
39
  export interface ReactRouterLike {
37
40
  readonly state: ReactRouterState;
package/dist/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as MapleBrowser } from "./src-DfU4Cnb1.mjs";
1
+ import { n as interruptNavigation, t as MapleBrowser } from "./src-JUynR8Jc.mjs";
2
2
  import { Component } from "react";
3
3
  //#region src/react.ts
4
4
  /** Component stacks can run to hundreds of lines; the top of it names the failing tree. */
@@ -46,10 +46,35 @@ var MapleErrorBoundary = class extends Component {
46
46
  /**
47
47
  * For React 19's `createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError })`:
48
48
  * reports what React caught, with its component stack. Errors already reported
49
- * (by a `MapleErrorBoundary`, say) are not reported twice.
49
+ * (by a `MapleErrorBoundary`, say) are not reported twice. Passing a handler
50
+ * replaces React's own console logging, so the error is then handed to `next`,
51
+ * which defaults to `console.error`.
50
52
  */
51
- function mapleReactErrorHandler(source = "react.root") {
52
- return (error, info) => reportReactError(error, info.componentStack, source);
53
+ function mapleReactErrorHandler(source = "react.root", next = (error) => console.error(error)) {
54
+ return (error, info) => {
55
+ reportReactError(error, info.componentStack, source);
56
+ next(error, info);
57
+ };
58
+ }
59
+ const attachedRouters = /* @__PURE__ */ new WeakMap();
60
+ function attachOnce(router, attach) {
61
+ const entry = attachedRouters.get(router) ?? {
62
+ count: 0,
63
+ stop: attach()
64
+ };
65
+ entry.count += 1;
66
+ attachedRouters.set(router, entry);
67
+ let released = false;
68
+ return () => {
69
+ if (released) return;
70
+ released = true;
71
+ entry.count -= 1;
72
+ queueMicrotask(() => {
73
+ if (entry.count > 0 || attachedRouters.get(router) !== entry) return;
74
+ attachedRouters.delete(router);
75
+ entry.stop();
76
+ });
77
+ };
53
78
  }
54
79
  /** `/projects/:id/settings`, from the matched routes' own path segments. */
55
80
  function reactRouterTemplate(state) {
@@ -67,6 +92,9 @@ function reactRouterTemplate(state) {
67
92
  * page load. Returns an unsubscribe.
68
93
  */
69
94
  function instrumentReactRouter(router) {
95
+ return attachOnce(router, () => attachReactRouter(router));
96
+ }
97
+ function attachReactRouter(router) {
70
98
  let open = false;
71
99
  let pathname = router.state.location.pathname;
72
100
  const start = (path) => {
@@ -80,7 +108,7 @@ function instrumentReactRouter(router) {
80
108
  };
81
109
  start(pathname);
82
110
  if (router.state.initialized && router.state.navigation.state === "idle") end(router.state);
83
- return router.subscribe((state) => {
111
+ const unsubscribe = router.subscribe((state) => {
84
112
  const target = state.navigation.location?.pathname;
85
113
  if (state.navigation.state !== "idle" && target !== void 0) {
86
114
  if (target !== pathname) start(target);
@@ -92,6 +120,11 @@ function instrumentReactRouter(router) {
92
120
  pathname = state.location.pathname;
93
121
  end(state);
94
122
  });
123
+ return () => {
124
+ unsubscribe();
125
+ if (open) interruptNavigation();
126
+ open = false;
127
+ };
95
128
  }
96
129
  /**
97
130
  * Span each TanStack Router navigation from `onBeforeNavigate` to `onResolved`,
@@ -99,6 +132,9 @@ function instrumentReactRouter(router) {
99
132
  * changes are not navigations. The first is the page load. Returns an unsubscribe.
100
133
  */
101
134
  function instrumentTanStackRouter(router) {
135
+ return attachOnce(router, () => attachTanStackRouter(router));
136
+ }
137
+ function attachTanStackRouter(router) {
102
138
  /** The path of the navigation in flight, if any. */
103
139
  let open;
104
140
  const template = () => {
@@ -124,6 +160,8 @@ function instrumentTanStackRouter(router) {
124
160
  return () => {
125
161
  stopBefore();
126
162
  stopResolved();
163
+ if (open !== void 0) interruptNavigation();
164
+ open = void 0;
127
165
  };
128
166
  }
129
167
  //#endregion