Skip to content

feat(browser): React Router integration - #1130

Open
JeremyFunk wants to merge 2 commits into
feat/browser-server-nextjsfrom
feat/browser-react-router
Open

JeremyFunk wants to merge 2 commits into
feat/browser-server-nextjsfrom
feat/browser-react-router

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A React Router integration for @maple-dev/browser, replacing the tracing.ts / router-tracing.ts / entry.server.tsx glue the React Router frontend guide had customers copy. Stacked on #1128 (/server, serverTiming(), the Next.js entries).

Export Purpose
@maple-dev/browser/react-router → dataRouterInstrumentation: ClientInstrumentation Data mode: pass to createBrowserRouter's instrumentations. Starts the page load before the first loaders run; a span per loader and action
@maple-dev/browser/react-router → traceNavigations(router): () => void Data mode: call right after createBrowserRouter. Ends each navigation, named after the matched route pattern
@maple-dev/browser/react-router → frameworkInstrumentation: ClientInstrumentation Framework mode: pass to HydratedRouter. Page load start, the navigate hook (latest-click guard, pathname-only url.path, hash links skipped), loader and action spans
@maple-dev/browser/react-router → useMaplePageload(): void Framework mode: call in the root Layout. Ends the page load once the page hydrates, named from useMatches()
@maple-dev/browser/react-router → reportRouteError: ClientOnErrorFunction onError for RouterProvider / HydratedRouter: skips thrown responses and errors a loader span recorded
@maple-dev/browser/react-router/server → serverInstrumentation: ServerInstrumentation entry.server.tsx instrumentations: a request span renamed to GET /projects/:id, with the status code, and server loader/action spans
@maple-dev/browser/react-router/server → handleError: HandleErrorFunction Logs like React Router's default; records render errors on the request span; skips aborted requests, route error responses and errors already recorded

handleRequest joins the page load with serverTiming() from @maple-dev/browser/server (two lines, in the docs). /react-router/server depends on @opentelemetry/api and react-router only. react-router is an optional peer (*); react-router@^8.4.0 is a dev dependency. The main entry is unchanged: eager 37.42 kB, first-party 14.30 kB, same as #1128.

Behavior

Reproduces the guide's verified behavior:

  • Data mode: redirects and clicks during loading are one span named after where the user landed (url.path keeps the first path); routes without loaders get zero-length spans; back/forward and query changes are navigations; hash changes, revalidations and fetchers aren't; a URL no route matches is navigate /.
  • Framework mode: the next click ends the previous one as app.navigation.interrupted; redirected navigations are named after the clicked route; back/forward aren't traced; every route gets a loader span (module loading).
  • Only thrown Errors fail a loader span (React Router reports redirect(), data() and Responses as success). One error, one issue: loader errors are on their span and reportRouteError / handleError skip them.

Differences from the guide code, each found in review and covered by a test:

  • Data mode no longer ends the page load early when router state changes before the first load finishes (e.g. a fetcher during HydrateFallback): it waits for state.initialized.
  • Framework mode: back/forward during a click ends the pending span as interrupted (a popstate listener). Before, the aborted navigate() ended it as a completed navigate /slow, and the back navigation's loaders ran under it.
  • useMaplePageload ends the page load once: React Router mounts a separate Layout around the page, the ErrorBoundary and the HydrateFallback, so a remount could end an in-flight navigation.
  • A server loader's error reaches the browser in production as Error("Unexpected Server Error") with no stack. The browser loader span and reportRouteError skip it, so it's recorded once, on the server's loader span (the Next.js entry skips digest errors for the same reason).
  • handleError logs what React Router's default logs (error.error for a no-match 404) and records with the shared dedupe, without changing the request span's status (that follows the response code).

Tests

  • packages/browser: typecheck, 173 tests (node + Chromium), build, size.
  • route.test.ts: pattern joining, loader/action instrumentation never throws.
  • index.browser.test.ts: the real createMemoryRouter + RouterProvider in Chromium, for both modes: naming, loader parenting, query/hash/revalidation/fetcher, history, redirect, merged and interrupted clicks, popstate, not-found, loader/render/server errors, page load on hydration and on Layout remount.
  • server.test.ts: the real createRequestHandler with a build whose server entry exports the two server pieces: request span under the HTTP span, loader span under it, Server-Timing equals the request span, .data requests, loader/render errors recorded once, 404 logged and not recorded, aborted requests, no server OTel.

End to end

The React Router 8.4 test apps instrumented with the old guide code (framework mode with SSR via react-router-serve + Node SDK, and a data-mode SPA) were switched to the packed tarball, production builds, and driven with the same Playwright script. Span trees match the old run in every scenario, apart from the order of the two parallel fetch spans and the status message now on the failed loader span.

Scenario Framework mode (old → new) Data mode (old → new)
Page load (H1/H2) GET → GET /projects/:id → pageload /projects/:id, server-timing on HTML, new trace on reload → same pageload /projects/:id + loader + fetches → same
Click (H3), param (H4) navigate /projects/:id > loaders > fetches > API → same same → same
Query / hash (H5) query traced, hash skipped → same same → same
Redirect (H6) navigate /old + both loaders → same navigate /projects/:id, url.path=/old → same
Interrupted (H7) navigate INTERRUPTED + navigate /projects/:id → same one merged span → same
Back/forward (H8) not traced → same traced → same
Not found (H9) navigate /projects/:id (404 data), pageload / + 404 server span → same → same
Loader error (H10) loader span Error, once → same → same
Render error (H11) react_router.error once; SSR: request span 500 + exception → same react_router.error once → same
Handler / backend error (H12/H13) one browser.uncaught_error; API 500 → same → same
Propagation (H14) first-party traceparent, none to third party → same → same
Back during a loading click (new) span ended as navigate /slow → navigate INTERRUPTED at the popstate n/a
Server loader error (new) server loader span Error + browser loader span Error (Unexpected Server Error) → server loader span only n/a

Known limits

  • Framework mode names navigations and server requests after the route since React Router 8.1, which added meta to the instrumentation results. On 7.15 to 8.0 those spans keep plain names (navigate, GET); data mode and the page load are unaffected.
  • Framework mode url.path resolves a relative to (edit, .) against the URL, React Router against the route; the span name is right. With a basename, the page load's url.path includes it and navigations' don't.
  • Data mode starts the page load from window.location.pathname, so a hash router's page load url.path is /.
  • In development, server errors reach the browser with their message, so they are recorded on both sides.
  • Framework mode's remaining gaps are the guide's: back/forward untraced, .data requests in their own trace, clientLoader.hydrate loads after the page load ends.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Devin Review

@maple-dev/browser/react-router: data-mode and framework-mode client
instrumentations, traceNavigations, useMaplePageload and reportRouteError.
@maple-dev/browser/react-router/server: the server instrumentation (request
span named after the route, loader and action spans) and handleError.
Data mode no longer ends the page load at a state change before the first
load finishes. Framework mode ends a click abandoned by back or forward as
interrupted, ends the page load at the first Layout mount only, and leaves
sanitized server loader errors to the server's span.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
quality 100/100 · no findings · tests covered · risk high · 4/4 new units observable

Warning

This review ended early; what follows is what it established.

Adds @maple-dev/browser/react-router and its /server entry: navigation spans for data and framework mode, a span per loader and action, route-error reporting, and a server request span. The logic matches the guide's previously end-to-end-verified glue and is well covered; safe to merge. The three React Router test files were read at the head commit rather than as diffs; no defect found in them.

  • dataRouterInstrumentation and traceNavigations span data-mode navigations and loaders
  • frameworkInstrumentation and useMaplePageload span framework-mode clicks and the page load
  • serverInstrumentation renames the request span to GET /route and sets its status
  • reportRouteError, handleError and recordExceptionOnce record each error once
What was checked
  • Loader/action wrappers match the guide's e2e-verified code (git show 6e086c060:skills/maple-frontend-tracing/frameworks/react-router.md)
  • recordExceptionOnce shares the reported WeakSet with recordFailure (failures.ts:62), so handleError skips an error already on its span
  • Browser tests exercise loader, redirect, interrupt and page-load cases against real React Router; server tests drive createRequestHandler
Observability coverage: 4 of 4 changes observable
Change Kind Observable Evidence
React Router navigations and page loads (data and framework mode) client instrumentation yes startNavigation/endNavigation/interruptNavigation (navigation.ts:58, :75, :52) driven from react-router/index.ts:37, :79
Loader and action runs in-process work yes traced(name, ...) per loader/action (react-router/route.ts:24)
React Router server request inbound request yes trace.getTracer(...).startActiveSpan(request.method, ...) with status and renamed to GET /route (react-router/server.ts:25)
Route, render and loader error reporting error path yes captureException (react-router/index.ts:131) and recordExceptionOnce on the request span (react-router/server.ts:52)
Files not reviewed (3)

The review ended before it read these diffs, so nothing above vouches for them.

  • packages/browser/src/react-router/index.browser.test.ts
  • packages/browser/src/react-router/route.test.ts
  • packages/browser/src/react-router/server.test.ts

3d3fe80 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@coderabbitai

coderabbitai Bot commented Sep 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 3e1ceeef-6d5d-4c88-89b2-61e41aaf2490

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +90 to +91
// `navigate(-1)` is a history navigation, like the back button, and hash links run nothing
if (typeof to === "number" || to.startsWith("#")) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Hash and history navigation silently blocked

For hash links or numeric history targets, frameworkInstrumentation returns without calling navigate(). Those navigations never execute, leaving the page at its current location.

Suggested change
// `navigate(-1)` is a history navigation, like the back button, and hash links run nothing
if (typeof to === "number" || to.startsWith("#")) return
// Skip tracing hash links and history navigation, but still execute them
if (typeof to === "number" || to.startsWith("#")) {
await navigate()
return
}

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +61 to +64
// Fetcher loads, revalidations and hash changes aren't navigations
if (!loading && state.location.pathname === pathname) return
// A route without loaders goes straight to the new location
if (!loading) startNavigation(state.location.pathname)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Search-only navigations lose their spans

On routes without loaders, traceNavigations skips completed query changes because their pathname stays unchanged. The navigation never gets a span.

Learn more

Data-mode navigations without loaders can publish only an idle state at their new location. The current pathname comparison treats a query-only navigation as a fetcher or hash update. The subscriber therefore skips a real navigation, despite tracing query changes when loaders run.

Example: Starting at /about, where no loader exists, navigate to /about?tab=team. The router changes location without entering a loading state; the callback sees the same pathname and records no navigate /about span.

Recommended fix: Track the committed pathname and search string together, and compare both at the idle-state gate. Ignore changes to hash alone; retain the existing loading path for loader-backed navigations.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +15 to +16
export const matchedPattern = (paths: readonly (string | undefined)[]): string =>
routePattern(paths.filter(Boolean).join("/"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Absolute child routes get duplicated trace names

For nested absolute routes, matchedPattern concatenates the parent's path with the complete child path. A /projects/:id child under /projects becomes /projects/projects/:id in navigation names.

Learn more

React Router supports a nested route whose absolute path starts with its parent's path. Both data-mode matching and framework page-load matching pass each matched route path into matchedPattern. Concatenating those paths repeats the parent segment for an absolute child.

Example: A parent with path: "/projects" and child with path: "/projects/:id" matches /projects/42, but the recorded span is named navigate /projects/projects/:id.

Recommended fix: Build the pattern incrementally, replacing the accumulated prefix when a route path starts with /, while retaining the current behavior for relative children and pathless layouts. Add a test with an absolute nested child.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant