fix(react): Defer React Router span finalization until lazy routes load#18881
fix(react): Defer React Router span finalization until lazy routes load#18881
Conversation
c7953b2 to
c11361b
Compare
size-limit report 📦
|
34ccaca to
c47669c
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical issue where pageload transactions receive incorrect names (URL-based or wildcard-based) when lazy routes load after the span ends due to idle timeout. The fix introduces a deferred promise mechanism to block span finalization until patchRoutesOnNavigation completes, ensuring lazy-loaded routes are available for proper transaction naming.
Changes:
- Added deferred promise mechanism that blocks span finalization until lazy routes finish loading via
patchRoutesOnNavigation - Captured active span before router creation (instead of after) to maintain reference even if span ends before lazy routes load
- Modified
patchSpanEndto use globalallRoutesSet directly instead of a snapshot, allowing visibility of routes added after span starts - Extended
patchRoutesOnNavigationwrapper to handle both pageload and navigation spans (previously only navigation)
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
packages/react/src/reactrouter-compat-utils/instrumentation.tsx |
Core implementation: adds deferred promise mechanism, moves span capture timing, updates patchSpanEnd to use global allRoutes, extends patchRoutesOnNavigation to handle pageload spans |
packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx |
Unit tests verifying allRoutes global set behavior and proper handling of late route additions |
dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts |
E2E regression tests verifying slow lazy routes get parameterized names even with early span end |
dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/WildcardLazyRoutes.tsx |
New test component simulating slow lazy route loading with wildcard routes |
dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx |
Router configuration for wildcard lazy route test scenario |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/react/src/reactrouter-compat-utils/instrumentation.tsx
Outdated
Show resolved
Hide resolved
packages/react/src/reactrouter-compat-utils/instrumentation.tsx
Outdated
Show resolved
Hide resolved
packages/react/src/reactrouter-compat-utils/instrumentation.tsx
Outdated
Show resolved
Hide resolved
...ages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/WildcardLazyRoutes.tsx
Show resolved
Hide resolved
packages/react/src/reactrouter-compat-utils/instrumentation.tsx
Outdated
Show resolved
Hide resolved
b578154 to
a9ed0f4
Compare
chargome
left a comment
There was a problem hiding this comment.
Looks good but the code for this becomes increasingly difficult to read. We should add a state/mermaid diagram at some point for more clarity on how we do parameterization here
Thanks for fixing!
| for (const route of allRoutes) { | ||
| const idMatches = route.id !== undefined && route.id === routeId; | ||
| const referenceMatches = route === leafRoute; | ||
| const pathMatches = | ||
| route.path !== undefined && leafRoute.path !== undefined && route.path === leafRoute.path; | ||
|
|
||
| if (idMatches || referenceMatches || pathMatches) { | ||
| // Attach children to this parent route | ||
| addResolvedRoutesToParent(children, route); | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
You could use .find() here (fewer bytes in bundle size and readable):
const matchingRoute = allRoutes.find(route => {
const idMatches = route.id !== undefined && route.id === routeId;
const referenceMatches = route === leafRoute;
const pathMatches = route.path !== undefined && leafRoute.path !== undefined && route.path === leafRoute.path;
return idMatches || referenceMatches || pathMatches;
});
if (matchingRoute) {
addResolvedRoutesToParent(children, targetRoute);
}
I agree, I'll add a dev-docs with diagrams after we're eventually sure we eliminated all potential edge cases. |
node-overhead report 🧳Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.
|
…es (#19086) This PR will resolve the core reason for the series of fixes / handling for automatic lazy-route resolution for a while. Related: #18898, #18881, #18346, #18155, #18098, #17962, #17867, #17438, #17277 The core issue we have been trying to tackle is not having access to the complete route hierarchy when asynchronously loaded lazy routes are used. React Router provides a route manifest that we can use while matching parameterized transaction names with routes in all cases except this lazy-routes pattern. This problem has been discussed on React Router: - remix-run/react-router#11113 While this has been [addressed](remix-run/react-router#11626) for Remix / React Router (Framework Mode), it's still not available in Library Mode. The manifest contains the lazily-loaded route, only when it's navigated to. While waiting for navigation, our transactions can be dropped for several reasons, such as user behaviour like switching tabs (`document.hidden` guard), hitting timeouts like `idleTimeout`, and potentially other reasons. This results in incomplete transaction naming with leftover wildcards, which caused broken aggregation on the Sentry dashboard. The series of attempts to fix this while keeping automatic route discovery has been prone to race conditions and required special-case handling of each edge case scenario, also requiring a considerable amount of internal logic, affecting our readability and performance. At the end, all failed in giving completely robust and deterministic results on the customers' side. This PR proposes a new option: `lazyRouteManifest` specifically for lazy routes. This will let us have initial information about the route hierarchy. So we can assign correct parameterized transaction names without needing to wait for navigated state. It's a static array of routes in parameterized format (needs to be maintained by the users on route hierarchy updates) like: ```ts Sentry.reactRouterV7BrowserTracingIntegration({ // ... enableAsyncRouteHandlers: true lazyRouteManifest: [ '/', '/pricing', '/features', '/login', '/signup', '/forgot-password', '/reset-password/:token', '/org/:orgSlug', '/org/:orgSlug/dashboard', '/org/:orgSlug/projects', '/org/:orgSlug/projects/:projectId', '/org/:orgSlug/projects/:projectId/settings', '/org/:orgSlug/projects/:projectId/issues', '/org/:orgSlug/projects/:projectId/issues/:issueId', '/org/:orgSlug/team', '/org/:orgSlug/team/:memberId', '/org/:orgSlug/settings', '/org/:orgSlug/billing', '/admin', '/admin/users', '/admin/users/:userId', '/admin/orgs', '/admin/orgs/:orgId', ], }) ``` - This will only be active when `enableAsyncRouteHandlers` is set to `true` - To match URLs with given routes, we mimic React Router's own implementation. - When this is not provided or fails, it falls back to the current behaviour - This manifest is primarily for lazy routes, but the users can also add their non-lazy routes here for convenience or consistency. - Also added E2E tests that will fail when (if at some point) React Router manifests include the lazy routes before navigation, so we'll be aware and plan depending on that manifest instead. - We can do a cleanup for the race-condition / edge-case handling part of the code in a follow-up PR. Closes #19090 (added automatically)
Following up on #18155 and #18346
Fixes an issue where pageload transactions have incorrect names (URL-based or wildcard-based) when lazy routes load after the span ends due to idle timeout.
This occurs when using patchRoutesOnNavigation for lazy route loading. The idle timeout can fire before lazy routes finish loading, causing the span to end with a wildcard like
/slow-fetch/*instead of the parameterized/slow-fetch/:id.The root cause was that the active span was captured after router creation, making it inaccessible when
patchRoutesOnNavigationwas called later (span already ended). Also,patchSpanEndwas taking a snapshot ofallRoutes, so lazy routes added later wouldn't be visible.This fix:
wrapPatchRoutesOnNavigationpatchRoutesOnNavigationcompletesallRoutesSet directly instead of a snapshotpatchRoutesOnNavigation(was only handling navigation before)