diff --git a/bun.lock b/bun.lock
index 9b1e1a67b..dff6c2859 100644
--- a/bun.lock
+++ b/bun.lock
@@ -657,12 +657,22 @@
},
"devDependencies": {
"@maple/browser-session": "workspace:*",
+ "@types/react": "catalog:react",
+ "@types/react-dom": "catalog:react",
"@vitest/browser-playwright": "catalog:",
"playwright": "catalog:",
+ "react": "catalog:react",
+ "react-dom": "catalog:react",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:",
},
+ "peerDependencies": {
+ "react": ">=18",
+ },
+ "optionalPeers": [
+ "react",
+ ],
},
"packages/browser-session": {
"name": "@maple/browser-session",
diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md
index 8ebc37937..dddf6b381 100644
--- a/docs/browser-sdk.md
+++ b/docs/browser-sdk.md
@@ -514,6 +514,43 @@ createRoot(document.getElementById("root")!).render()
In Next.js, run the import from a client component mounted high in the tree (e.g. the root layout), since the SDK is browser-only.
+### React integration
+
+`@maple-dev/browser/react` adds an error boundary, a React 19 root error handler, and adapters that
+span navigations straight from your router. `react` is an optional peer dependency; routers are
+typed structurally, so no router package is required.
+
+```tsx
+import { createBrowserRouter, RouterProvider } from "react-router"
+import { instrumentReactRouter, mapleReactErrorHandler, MapleErrorBoundary } from "@maple-dev/browser/react"
+
+const router = createBrowserRouter(routes)
+instrumentReactRouter(router)
+
+createRoot(document.getElementById("root")!, {
+ onCaughtError: mapleReactErrorHandler(),
+ onUncaughtError: mapleReactErrorHandler(),
+}).render(
+ }>
+
+ ,
+)
+```
+
+- `MapleErrorBoundary` reports a render error once (as a `react.render_error` span with the
+ component stack in `maple.react.component_stack`) and renders `fallback`, which may be a node or a
+ function of `{ error, reset }`.
+- `mapleReactErrorHandler()` fits React 19's `onCaughtError`, `onUncaughtError` and
+ `onRecoverableError` root options. An error already reported by a boundary is not reported again.
+- `instrumentReactRouter(router)` takes a data router (`createBrowserRouter` and friends). A
+ navigation starts when the router starts loading and ends when its loaders settle, named by the
+ route template (`navigate /projects/:id`). The first is the `pageload`.
+- `instrumentTanStackRouter(router)` does the same from `onBeforeNavigate` to `onResolved`, named by
+ the leaf route's full path (`navigate /projects/$projectId`). Search-only changes are not
+ navigations.
+
+Both adapters return an unsubscribe. Don't also call `startNavigation`/`endNavigation` yourself.
+
## Notes
- Replay event blobs live in object storage. Only small, queryable metadata is indexed, and playback streams blobs directly via signed URLs.
diff --git a/packages/browser/README.md b/packages/browser/README.md
index e1ba2eca0..0d896558f 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -199,6 +199,18 @@ MapleBrowser.endNavigation("/projects/:id") // the route is ready: its template
- All three are no-ops on the server, before `init()`, with tracing disabled or
without consent (`traced` then only runs `fn`).
+## React
+
+`@maple-dev/browser/react` has `MapleErrorBoundary`, `mapleReactErrorHandler()` for React 19's
+`createRoot` error options, and router adapters that call `startNavigation`/`endNavigation` for you:
+
+```ts
+import { instrumentReactRouter, instrumentTanStackRouter } from "@maple-dev/browser/react"
+
+instrumentReactRouter(createBrowserRouter(routes)) // navigate /projects/:id
+instrumentTanStackRouter(router) // navigate /projects/$projectId
+```
+
## Linking a marketing site to your app
The visitor id lives in localStorage **and** a cookie scoped to your registered
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 9c2b600b0..22470bc73 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -30,6 +30,10 @@
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
+ },
+ "./react": {
+ "types": "./dist/react.d.mts",
+ "import": "./dist/react.mjs"
}
},
"publishConfig": {
@@ -58,10 +62,22 @@
},
"devDependencies": {
"@maple/browser-session": "workspace:*",
+ "@types/react": "catalog:react",
+ "@types/react-dom": "catalog:react",
"@vitest/browser-playwright": "catalog:",
"playwright": "catalog:",
+ "react": "catalog:react",
+ "react-dom": "catalog:react",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
}
}
diff --git a/packages/browser/src/react.browser.test.ts b/packages/browser/src/react.browser.test.ts
new file mode 100644
index 000000000..e280de15c
--- /dev/null
+++ b/packages/browser/src/react.browser.test.ts
@@ -0,0 +1,218 @@
+// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
+import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
+import { createElement, type ReactNode } from "react"
+import { flushSync } from "react-dom"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const exported: ReadableSpan[] = []
+vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
+ OTLPTraceExporter: class {
+ export(spans: ReadableSpan[], callback: (result: { code: number }) => void): void {
+ exported.push(...spans)
+ callback({ code: 0 })
+ }
+ forceFlush(): Promise {
+ return Promise.resolve()
+ }
+ shutdown(): Promise {
+ return Promise.resolve()
+ }
+ },
+}))
+
+const { MapleBrowser } = await import("./index")
+const { resetNavigationForTests } = await import("./navigation")
+const { resetReportedErrorsForTests } = await import("./errors")
+const { MapleErrorBoundary, instrumentReactRouter, instrumentTanStackRouter, mapleReactErrorHandler } =
+ await import("./react")
+type ReactRouterState = import("./react").ReactRouterState
+
+let handle: ReturnType | undefined
+const stop = async (): Promise => {
+ await handle?.shutdown()
+ handle = undefined
+}
+const TIMING_SPANS = new Set([
+ "documentFetch",
+ "dns",
+ "connect",
+ "request",
+ "response",
+ "domProcessing",
+ "loadEvent",
+])
+const spanNames = () => exported.filter((span) => !TIMING_SPANS.has(span.name)).map((span) => span.name)
+
+beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("{}")),
+ )
+ handle = MapleBrowser.init({
+ ingestKey: "k",
+ serviceName: "web",
+ endpoint: "https://ingest.test",
+ replay: { enabled: false },
+ tracing: { instrumentFetch: false, instrumentXhr: false },
+ webVitals: false,
+ breadcrumbs: false,
+ })
+})
+
+afterEach(async () => {
+ await stop()
+ exported.length = 0
+ resetNavigationForTests()
+ resetReportedErrorsForTests()
+ vi.unstubAllGlobals()
+})
+
+describe("MapleErrorBoundary", () => {
+ it("reports a render error once with its component stack, renders the fallback, and resets", async () => {
+ let shouldThrow = true
+ const Broken = (): ReactNode => {
+ if (shouldThrow) throw new Error("render failed")
+ return createElement("p", null, "recovered")
+ }
+ let resetBoundary = (): void => {}
+ const container = document.createElement("div")
+ const root = createRoot(container)
+ // React logs caught errors; keep the test output quiet.
+ vi.spyOn(console, "error").mockImplementation(() => {})
+ flushSync(() =>
+ root.render(
+ createElement(MapleErrorBoundary, {
+ fallback: ({ reset }: { reset: () => void }) => {
+ resetBoundary = reset
+ return createElement("p", null, "fallback")
+ },
+ children: createElement(Broken),
+ }),
+ ),
+ )
+ expect(container.textContent).toBe("fallback")
+
+ shouldThrow = false
+ flushSync(() => resetBoundary())
+ expect(container.textContent).toBe("recovered")
+ root.unmount()
+ await stop()
+
+ const reported = exported.filter((span) => span.name === "react.render_error")
+ expect(reported).toHaveLength(1)
+ expect(reported[0]?.attributes["maple.exception.source"]).toBe("react.error_boundary")
+ expect(String(reported[0]?.attributes["maple.react.component_stack"])).toContain("Broken")
+ })
+})
+
+describe("mapleReactErrorHandler", () => {
+ it("reports what a React 19 root caught, once", async () => {
+ const onCaughtError = mapleReactErrorHandler()
+ const error = new Error("caught by root")
+ onCaughtError(error, { componentStack: "\n at Widget" })
+ onCaughtError(error, { componentStack: "\n at Widget" })
+ await stop()
+ const reported = exported.filter((span) => span.name === "react.render_error")
+ expect(reported).toHaveLength(1)
+ expect(reported[0]?.attributes["maple.exception.source"]).toBe("react.root")
+ expect(reported[0]?.attributes["maple.react.component_stack"]).toBe("at Widget")
+ })
+})
+
+/** A React Router data router, reduced to what the adapter reads. */
+function fakeReactRouter(initial: ReactRouterState) {
+ const listeners = new Set<(state: ReactRouterState) => void>()
+ const router = {
+ state: initial,
+ subscribe(listener: (state: ReactRouterState) => void) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ set(next: Partial) {
+ router.state = { ...router.state, ...next }
+ for (const listener of listeners) listener(router.state)
+ },
+ }
+ return router
+}
+
+const idle = { state: "idle" }
+const projectMatches = [{ route: { path: "/" } }, { route: { path: "projects/:id" } }]
+
+describe("instrumentReactRouter", () => {
+ it("spans the page load until the router initializes, then each loading navigation by template", async () => {
+ const router = fakeReactRouter({
+ initialized: false,
+ location: { pathname: "/" },
+ navigation: idle,
+ matches: [{ route: { path: "/" } }],
+ })
+ const unsubscribe = instrumentReactRouter(router)
+ router.set({ initialized: true })
+ router.set({ navigation: { state: "loading", location: { pathname: "/projects/42" } } })
+ router.set({ navigation: idle, location: { pathname: "/projects/42" }, matches: projectMatches })
+ unsubscribe()
+ await stop()
+ expect(spanNames()).toEqual(["pageload /", "navigate /projects/:id"])
+ })
+
+ it("spans a navigation to a route without loaders, which never enters loading", async () => {
+ const router = fakeReactRouter({
+ initialized: true,
+ location: { pathname: "/" },
+ navigation: idle,
+ matches: [{ route: { path: "/" } }],
+ })
+ const unsubscribe = instrumentReactRouter(router)
+ router.set({ location: { pathname: "/projects/7" }, matches: projectMatches })
+ unsubscribe()
+ await stop()
+ expect(spanNames()).toEqual(["pageload /", "navigate /projects/:id"])
+ })
+})
+
+type TanStackEvent = { toLocation: { pathname: string }; pathChanged: boolean }
+
+function fakeTanStackRouter() {
+ const listeners = new Map void>>()
+ const router = {
+ state: {
+ status: "pending",
+ location: { pathname: "/" },
+ matches: [] as Array<{ fullPath?: string; routeId: string }>,
+ },
+ subscribe(eventType: "onBeforeNavigate" | "onResolved", listener: (event: TanStackEvent) => void) {
+ const set = listeners.get(eventType) ?? new Set()
+ set.add(listener)
+ listeners.set(eventType, set)
+ return () => set.delete(listener)
+ },
+ emit(eventType: string, event: TanStackEvent) {
+ for (const listener of listeners.get(eventType) ?? []) listener(event)
+ },
+ }
+ return router
+}
+
+describe("instrumentTanStackRouter", () => {
+ it("keeps the initial load as the page load and ignores search-only changes", async () => {
+ const router = fakeTanStackRouter()
+ const unsubscribe = instrumentTanStackRouter(router)
+ // The router's own initial load re-announces the page load.
+ router.emit("onBeforeNavigate", { toLocation: { pathname: "/" }, pathChanged: true })
+ router.state.matches = [{ routeId: "__root__" }, { fullPath: "/", routeId: "/" }]
+ router.emit("onResolved", { toLocation: { pathname: "/" }, pathChanged: true })
+
+ router.emit("onBeforeNavigate", { toLocation: { pathname: "/" }, pathChanged: false })
+ router.emit("onBeforeNavigate", { toLocation: { pathname: "/projects/42" }, pathChanged: true })
+ router.state.matches = [
+ { routeId: "__root__" },
+ { fullPath: "/projects/$projectId", routeId: "/projects/$projectId" },
+ ]
+ router.emit("onResolved", { toLocation: { pathname: "/projects/42" }, pathChanged: true })
+ unsubscribe()
+ await stop()
+ expect(spanNames()).toEqual(["pageload /", "navigate /projects/$projectId"])
+ })
+})
diff --git a/packages/browser/src/react.ts b/packages/browser/src/react.ts
new file mode 100644
index 000000000..dc0ccb413
--- /dev/null
+++ b/packages/browser/src/react.ts
@@ -0,0 +1,190 @@
+// `@maple-dev/browser/react`: an error boundary, a React 19 root error
+// handler, and router adapters that drive `startNavigation`/`endNavigation`
+// from the router itself. Routers are typed structurally, so this entry does
+// not depend on any router package.
+import { Component, type ErrorInfo, type ReactNode } from "react"
+import { MapleBrowser } from "./index"
+
+/** Component stacks can run to hundreds of lines; the top of it names the failing tree. */
+const MAX_COMPONENT_STACK = 2_000
+
+function reportReactError(error: unknown, componentStack: string | null | undefined, source: string): void {
+ MapleBrowser.captureException(error, {
+ name: "react.render_error",
+ attributes: {
+ "maple.exception.source": source,
+ ...(componentStack
+ ? { "maple.react.component_stack": componentStack.trim().slice(0, MAX_COMPONENT_STACK) }
+ : undefined),
+ },
+ })
+}
+
+export interface MapleErrorBoundaryFallbackProps {
+ // BOUNDARY: a thrown value is unparsed by definition.
+ readonly error: unknown
+ /** Clear the error and render the children again. */
+ readonly reset: () => void
+}
+
+export interface MapleErrorBoundaryProps {
+ readonly children?: ReactNode
+ /** Rendered instead of the children after an error. Default: nothing. */
+ readonly fallback?: ReactNode | ((props: MapleErrorBoundaryFallbackProps) => ReactNode)
+ /** Called after the error is reported. */
+ readonly onError?: (error: unknown, info: ErrorInfo) => void
+}
+
+interface MapleErrorBoundaryState {
+ readonly failed: boolean
+ readonly error: unknown
+}
+
+/** Reports render errors below it to Maple once, then renders `fallback`. */
+export class MapleErrorBoundary extends Component {
+ override state: MapleErrorBoundaryState = { failed: false, error: undefined }
+
+ static getDerivedStateFromError(error: unknown): MapleErrorBoundaryState {
+ return { failed: true, error }
+ }
+
+ override componentDidCatch(error: unknown, info: ErrorInfo): void {
+ reportReactError(error, info.componentStack, "react.error_boundary")
+ this.props.onError?.(error, info)
+ }
+
+ private readonly reset = (): void => {
+ this.setState({ failed: false, error: undefined })
+ }
+
+ override render(): ReactNode {
+ if (!this.state.failed) return this.props.children ?? null
+ const { fallback } = this.props
+ return typeof fallback === "function"
+ ? fallback({ error: this.state.error, reset: this.reset })
+ : (fallback ?? null)
+ }
+}
+
+/**
+ * For React 19's `createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError })`:
+ * reports what React caught, with its component stack. Errors already reported
+ * (by a `MapleErrorBoundary`, say) are not reported twice.
+ */
+export function mapleReactErrorHandler(
+ source = "react.root",
+): (error: unknown, info: { readonly componentStack?: string | null | undefined }) => void {
+ return (error, info) => reportReactError(error, info.componentStack, source)
+}
+
+/** A React Router data router (`createBrowserRouter` and friends), as far as Maple reads it. */
+export interface ReactRouterLike {
+ readonly state: ReactRouterState
+ subscribe(listener: (state: ReactRouterState) => void): () => void
+}
+
+export interface ReactRouterState {
+ readonly initialized: boolean
+ readonly location: { readonly pathname: string }
+ readonly navigation: {
+ readonly state: string
+ readonly location?: { readonly pathname: string } | undefined
+ }
+ readonly matches: ReadonlyArray<{ readonly route: { readonly path?: string | undefined } }>
+}
+
+/** `/projects/:id/settings`, from the matched routes' own path segments. */
+function reactRouterTemplate(state: ReactRouterState): string {
+ let template = ""
+ for (const match of state.matches) {
+ const path = match.route.path
+ if (!path) continue
+ template = path.startsWith("/") ? path : `${template.replace(/\/$/, "")}/${path}`
+ }
+ return template || "/"
+}
+
+/**
+ * Span each navigation of a React Router data router, from the moment it starts
+ * loading until its loaders settle, named by route template. The first is the
+ * page load. Returns an unsubscribe.
+ */
+export function instrumentReactRouter(router: ReactRouterLike): () => void {
+ let open = false
+ let pathname = router.state.location.pathname
+ const start = (path: string): void => {
+ MapleBrowser.startNavigation(path)
+ open = true
+ }
+ const end = (state: ReactRouterState): void => {
+ if (!open) return
+ MapleBrowser.endNavigation(reactRouterTemplate(state))
+ open = false
+ }
+ start(pathname)
+ if (router.state.initialized && router.state.navigation.state === "idle") end(router.state)
+ return router.subscribe((state) => {
+ const target = state.navigation.location?.pathname
+ if (state.navigation.state !== "idle" && target !== undefined) {
+ if (!open || target !== pathname) start(target)
+ pathname = target
+ return
+ }
+ if (!state.initialized) return
+ // A route without loaders never enters `loading`: the location just changes.
+ if (!open && state.location.pathname !== pathname) start(state.location.pathname)
+ pathname = state.location.pathname
+ end(state)
+ })
+}
+
+/** A TanStack Router instance, as far as Maple reads it. */
+export interface TanStackRouterLike {
+ readonly state: {
+ readonly status: string
+ readonly location: { readonly pathname: string }
+ readonly matches: ReadonlyArray<{ readonly fullPath?: string | undefined; readonly routeId: string }>
+ }
+ subscribe(
+ eventType: "onBeforeNavigate" | "onResolved",
+ listener: (event: {
+ readonly toLocation: { readonly pathname: string }
+ readonly pathChanged: boolean
+ }) => void,
+ ): () => void
+}
+
+/**
+ * Span each TanStack Router navigation from `onBeforeNavigate` to `onResolved`,
+ * named by the leaf route's full path (`/projects/$projectId`). Search-only
+ * changes are not navigations. The first is the page load. Returns an unsubscribe.
+ */
+export function instrumentTanStackRouter(router: TanStackRouterLike): () => void {
+ /** The path of the navigation in flight, if any. */
+ let open: string | undefined
+ const template = (): string => {
+ const leaf = router.state.matches.at(-1)
+ return leaf?.fullPath || leaf?.routeId || "/"
+ }
+ open = router.state.location.pathname
+ MapleBrowser.startNavigation(open)
+ if (router.state.status === "idle" && router.state.matches.length > 0) {
+ MapleBrowser.endNavigation(template())
+ open = undefined
+ }
+ const stopBefore = router.subscribe("onBeforeNavigate", (event) => {
+ // The router's own initial load re-announces the page load already open.
+ if (!event.pathChanged || event.toLocation.pathname === open) return
+ open = event.toLocation.pathname
+ MapleBrowser.startNavigation(open)
+ })
+ const stopResolved = router.subscribe("onResolved", () => {
+ if (open === undefined) return
+ MapleBrowser.endNavigation(template())
+ open = undefined
+ })
+ return () => {
+ stopBefore()
+ stopResolved()
+ }
+}
diff --git a/packages/browser/tsdown.config.ts b/packages/browser/tsdown.config.ts
index 8938a40e3..2202c38f6 100644
--- a/packages/browser/tsdown.config.ts
+++ b/packages/browser/tsdown.config.ts
@@ -3,6 +3,7 @@ import { defineConfig } from "tsdown"
export default defineConfig({
entry: {
index: "./src/index.ts",
+ react: "./src/react.ts",
},
format: "esm",
// Types are emitted by tsgo in one pass rooted at the tsconfig's directory,