Skip to content

fix(solid-router): wrap root Outlet child with CatchBoundary#6847

Open
ljho01 wants to merge 1 commit intoTanStack:mainfrom
ljho01:fix/solid-router-root-error-boundary
Open

fix(solid-router): wrap root Outlet child with CatchBoundary#6847
ljho01 wants to merge 1 commit intoTanStack:mainfrom
ljho01:fix/solid-router-root-error-boundary

Conversation

@ljho01
Copy link

@ljho01 ljho01 commented Mar 7, 2026

When shellComponent renders <Outlet /> directly (the natural pattern) instead of {props.children}, the root's CatchBoundary from Match is never placed in the render tree. Errors re-thrown from a child route's errorComponent have no parent boundary to propagate to — the page goes blank.

Fix

Unconditionally wrap the root Outlet's child <Match> with a CatchBoundary using the root's errorComponent. The extra boundary is harmless when shellComponent does render {props.children} — the inner CatchBoundary (from Match) catches first.

1 file changed, +21 −3packages/solid-router/src/Match.tsx

Verification

Closes #6845

Made with Cursor

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced error handling at the root level with improved error recovery and boundary management

When `shellComponent` renders `<Outlet />` directly instead of
`{props.children}`, the root's CatchBoundary (from Match) is never
placed in the render tree. Errors re-thrown from a child route's
errorComponent have no parent boundary to propagate to.

Fix: unconditionally wrap the root Outlet's child `<Match>` with a
CatchBoundary using the root's errorComponent. The extra boundary is
harmless when shellComponent does render children — the inner
CatchBoundary (from Match) catches first.

Closes TanStack#6845

Made-with: Cursor
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

📝 Walkthrough

Walkthrough

Modifies the Outlet component in Match.tsx to conditionally wrap the root-level content in a CatchBoundary when a shellComponent is present. This ensures that the root's errorComponent properly catches errors re-thrown from child routes, regardless of whether the shell renders <Outlet /> directly.

Changes

Cohort / File(s) Summary
Error Boundary Wrapping in Outlet
packages/solid-router/src/Match.tsx
Introduces rootErrorComponent and rootResetKey accessors, adds conditional CatchBoundary wrapping around child match content when a root error component exists, and ensures not-found errors propagate correctly via an onCatch handler that rethrows them. Replaces fallback rendering logic with explicit childMatch memoization.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A boundary so wise, now guards the nested ways,
When shells render outlets in their own display,
Errors re-thrown find a parent at last,
The root catches all—no escape through the past!
One fix, one file, error handling's embrace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: wrapping the root Outlet child with CatchBoundary to fix error handling in solid-router.
Linked Issues check ✅ Passed The code changes fully implement the fix for issue #6845: wrapping root Outlet child with CatchBoundary ensures root errorComponent catches re-thrown errors from child routes regardless of shellComponent implementation.
Out of Scope Changes check ✅ Passed All changes in Match.tsx are directly scoped to fixing the root error boundary issue described in #6845; no unrelated modifications are present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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 and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 7, 2026

Bundle Size Benchmarks

  • Commit: 4ea2a62787d0
  • Measured at: 2026-03-07T15:40:41.836Z
  • Baseline source: history:4ea2a62787d0
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 86.84 KiB 0 B (0.00%) 273.33 KiB 75.44 KiB ▁▁▁▁▁██████
react-router.full 89.88 KiB 0 B (0.00%) 283.67 KiB 78.18 KiB ▁▁▁▁▁██████
solid-router.minimal 36.24 KiB +68 B (+0.18%) 108.66 KiB 32.52 KiB ▁▁▁▁▁▇▇▇▇▇▇█
solid-router.full 40.56 KiB +59 B (+0.14%) 121.71 KiB 36.39 KiB ▁▁▁▁▁▇▇▇▇▇▇█
vue-router.minimal 52.03 KiB 0 B (0.00%) 148.42 KiB 46.71 KiB ▁▁▁▁▁██████
vue-router.full 56.85 KiB 0 B (0.00%) 164.01 KiB 51.04 KiB ▁▁▁▁▁██████
react-start.minimal 99.40 KiB 0 B (0.00%) 312.48 KiB 85.98 KiB ▁▁▁▁▁██████
react-start.full 102.78 KiB 0 B (0.00%) 322.29 KiB 88.82 KiB ▁▁▁▁▁██████
solid-start.minimal 48.53 KiB +43 B (+0.09%) 146.25 KiB 42.91 KiB ▁▁▁▁▁▇▇▇▇▇▇█
solid-start.full 54.03 KiB +73 B (+0.13%) 162.19 KiB 47.62 KiB ▁▁▁▁▁▇▇▇▇▇▇█

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/solid-router/src/Match.tsx`:
- Around line 391-394: The root Match boundary changes must mirror the full root
behavior: update the Match component logic that defines rootErrorComponent and
rootResetKey so its onCatch handler forwards to the root/default onCatch (call
or delegate to router.options.onCatch or the root route's onCatch) instead of
swallowing errors, ensure isNotFound(error) results in rethrow only when a
sibling root CatchNotFound is present otherwise forward to the root notFound
handler (router.options.notFoundComponent or route-level notFoundComponent), and
in the direct-Outlet shell path ensure ordinary errors still bubble to the root
onCatch and descendant errorComponents that throw notFound(...) are captured by
a sibling root CatchNotFound equivalent; locate and modify the onCatch/code
paths and the error escalation flow near rootErrorComponent/rootResetKey and the
Outlet-shell branch to delegate to the router's root/default handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 64dc9398-72ac-45b8-8c91-2580580f9124

📥 Commits

Reviewing files that changed from the base of the PR and between 4ea2a62 and dc66bbd.

📒 Files selected for processing (1)
  • packages/solid-router/src/Match.tsx

Comment on lines +391 to +394
const rootErrorComponent = () =>
router.routesById[rootRouteId]?.options.errorComponent ??
router.options.defaultErrorComponent
const rootResetKey = useRouterState({ select: (s) => s.loadedAt })
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Mirror the full root Match boundary behavior here.

This restores the root errorComponent, but it still diverges from the normal root path at Lines 102-143: onCatch no longer forwards to the root/default onCatch, and isNotFound(error) is rethrown without a sibling root CatchNotFound. In the direct-<Outlet /> shell case, ordinary errors will skip root onCatch, and a descendant errorComponent that escalates with throw notFound(...) can still bypass the root notFoundComponent.

Proposed parity fix
   const rootErrorComponent = () =>
     router.routesById[rootRouteId]?.options.errorComponent ??
     router.options.defaultErrorComponent
+  const rootOnCatch = () =>
+    router.routesById[rootRouteId]?.options.onCatch ??
+    router.options.defaultOnCatch
+  const rootNotFoundComponent = () =>
+    router.routesById[rootRouteId]?.options.notFoundComponent ??
+    router.options.notFoundRoute?.options.component
   const rootResetKey = useRouterState({ select: (s) => s.loadedAt })

   return (
@@
             <Solid.Suspense
               fallback={
                 <Dynamic component={router.options.defaultPendingComponent} />
               }
             >
-              {rootErrorComponent() ? (
-                <CatchBoundary
-                  getResetKey={() => rootResetKey()}
-                  errorComponent={rootErrorComponent()!}
-                  onCatch={(error) => {
-                    if (isNotFound(error)) throw error
-                  }}
-                >
-                  {childMatch()}
-                </CatchBoundary>
-              ) : (
-                childMatch()
-              )}
+              <Dynamic
+                component={rootErrorComponent() ? CatchBoundary : SafeFragment}
+                getResetKey={() => rootResetKey()}
+                errorComponent={rootErrorComponent() || ErrorComponent}
+                onCatch={(error: Error) => {
+                  if (isNotFound(error)) throw error
+                  warning(false, `Error in route match: ${rootRouteId}`)
+                  rootOnCatch()?.(error)
+                }}
+              >
+                <Dynamic
+                  component={
+                    rootNotFoundComponent() ? CatchNotFound : SafeFragment
+                  }
+                  fallback={(error: any) => {
+                    if (
+                      !rootNotFoundComponent() ||
+                      (error.routeId && error.routeId !== rootRouteId)
+                    ) {
+                      throw error
+                    }
+
+                    return (
+                      <Dynamic
+                        component={rootNotFoundComponent()}
+                        {...error}
+                      />
+                    )
+                  }}
+                >
+                  {childMatch()}
+                </Dynamic>
+              </Dynamic>
             </Solid.Suspense>

Also applies to: 420-432

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/solid-router/src/Match.tsx` around lines 391 - 394, The root Match
boundary changes must mirror the full root behavior: update the Match component
logic that defines rootErrorComponent and rootResetKey so its onCatch handler
forwards to the root/default onCatch (call or delegate to router.options.onCatch
or the root route's onCatch) instead of swallowing errors, ensure
isNotFound(error) results in rethrow only when a sibling root CatchNotFound is
present otherwise forward to the root notFound handler
(router.options.notFoundComponent or route-level notFoundComponent), and in the
direct-Outlet shell path ensure ordinary errors still bubble to the root onCatch
and descendant errorComponents that throw notFound(...) are captured by a
sibling root CatchNotFound equivalent; locate and modify the onCatch/code paths
and the error escalation flow near rootErrorComponent/rootResetKey and the
Outlet-shell branch to delegate to the router's root/default handlers.

@schiller-manuel
Copy link
Contributor

this needs tests. preferably unit tests, otherwise e2e tests

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Solid Router: root errorComponent never catches re-thrown errors when shellComponent renders <Outlet /> directly

2 participants