From 75a7ac8ce75fa63ff9859851795b11dd5d5aefbd Mon Sep 17 00:00:00 2001
From: dvd233 <111864431+dvd233@users.noreply.github.com>
Date: Tue, 15 Sep 2026 09:57:42 -0700
Subject: [PATCH] fix(solid-router): hydrate route error components
consistently
---
.changeset/solid-error-hydration.md | 5 +
.../query-integration/src/routeTree.gen.ts | 27 +++-
.../src/routes/error-component-hydration.tsx | 38 ++++++
.../tests/error-component-hydration.spec.ts | 55 ++++++++
packages/solid-router/src/CatchBoundary.tsx | 121 ++++++++++++++----
packages/solid-router/src/Match.tsx | 39 +-----
.../tests/errorComponent.test.tsx | 31 +++++
7 files changed, 256 insertions(+), 60 deletions(-)
create mode 100644 .changeset/solid-error-hydration.md
create mode 100644 e2e/solid-start/query-integration/src/routes/error-component-hydration.tsx
create mode 100644 e2e/solid-start/query-integration/tests/error-component-hydration.spec.ts
diff --git a/.changeset/solid-error-hydration.md b/.changeset/solid-error-hydration.md
new file mode 100644
index 00000000000..d94334d37fb
--- /dev/null
+++ b/.changeset/solid-error-hydration.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/solid-router': patch
+---
+
+Render route match errors at a consistent boundary position during SSR and hydration so error components remain interactive.
diff --git a/e2e/solid-start/query-integration/src/routeTree.gen.ts b/e2e/solid-start/query-integration/src/routeTree.gen.ts
index 840e3e79018..ed889b94895 100644
--- a/e2e/solid-start/query-integration/src/routeTree.gen.ts
+++ b/e2e/solid-start/query-integration/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as ErrorComponentHydrationRouteImport } from './routes/error-component-hydration'
import { Route as UseQueryRouteImport } from './routes/useQuery'
import { Route as LoaderFetchQueryTypeRouteImport } from './routes/loader-fetchQuery/$type'
import { Route as NotFoundReloadIdRouteImport } from './routes/not-found-reload.$id'
@@ -19,6 +20,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const ErrorComponentHydrationRoute = ErrorComponentHydrationRouteImport.update({
+ id: '/error-component-hydration',
+ path: '/error-component-hydration',
+ getParentRoute: () => rootRouteImport,
+} as any)
const UseQueryRoute = UseQueryRouteImport.update({
id: '/useQuery',
path: '/useQuery',
@@ -37,12 +43,14 @@ const NotFoundReloadIdRoute = NotFoundReloadIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/error-component-hydration': typeof ErrorComponentHydrationRoute
'/useQuery': typeof UseQueryRoute
'/loader-fetchQuery/$type': typeof LoaderFetchQueryTypeRoute
'/not-found-reload/$id': typeof NotFoundReloadIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/error-component-hydration': typeof ErrorComponentHydrationRoute
'/useQuery': typeof UseQueryRoute
'/loader-fetchQuery/$type': typeof LoaderFetchQueryTypeRoute
'/not-found-reload/$id': typeof NotFoundReloadIdRoute
@@ -50,6 +58,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/error-component-hydration': typeof ErrorComponentHydrationRoute
'/useQuery': typeof UseQueryRoute
'/loader-fetchQuery/$type': typeof LoaderFetchQueryTypeRoute
'/not-found-reload/$id': typeof NotFoundReloadIdRoute
@@ -58,14 +67,21 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/error-component-hydration'
| '/useQuery'
| '/loader-fetchQuery/$type'
| '/not-found-reload/$id'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/useQuery' | '/loader-fetchQuery/$type' | '/not-found-reload/$id'
+ to:
+ | '/'
+ | '/error-component-hydration'
+ | '/useQuery'
+ | '/loader-fetchQuery/$type'
+ | '/not-found-reload/$id'
id:
| '__root__'
| '/'
+ | '/error-component-hydration'
| '/useQuery'
| '/loader-fetchQuery/$type'
| '/not-found-reload/$id'
@@ -73,6 +89,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ ErrorComponentHydrationRoute: typeof ErrorComponentHydrationRoute
UseQueryRoute: typeof UseQueryRoute
LoaderFetchQueryTypeRoute: typeof LoaderFetchQueryTypeRoute
NotFoundReloadIdRoute: typeof NotFoundReloadIdRoute
@@ -87,6 +104,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/error-component-hydration': {
+ id: '/error-component-hydration'
+ path: '/error-component-hydration'
+ fullPath: '/error-component-hydration'
+ preLoaderRoute: typeof ErrorComponentHydrationRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/useQuery': {
id: '/useQuery'
path: '/useQuery'
@@ -113,6 +137,7 @@ declare module '@tanstack/solid-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ ErrorComponentHydrationRoute: ErrorComponentHydrationRoute,
UseQueryRoute: UseQueryRoute,
LoaderFetchQueryTypeRoute: LoaderFetchQueryTypeRoute,
NotFoundReloadIdRoute: NotFoundReloadIdRoute,
diff --git a/e2e/solid-start/query-integration/src/routes/error-component-hydration.tsx b/e2e/solid-start/query-integration/src/routes/error-component-hydration.tsx
new file mode 100644
index 00000000000..4c58982d308
--- /dev/null
+++ b/e2e/solid-start/query-integration/src/routes/error-component-hydration.tsx
@@ -0,0 +1,38 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { createSignal } from 'solid-js'
+import type { ErrorComponentProps } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/error-component-hydration')({
+ validateSearch: (search) => ({
+ falsy: search.falsy === true || search.falsy === 'true',
+ }),
+ loaderDeps: ({ search }) => ({ falsy: search.falsy }),
+ loader: ({ deps }) => {
+ const loaderError = deps.falsy ? undefined : new Error('loader failed')
+ throw loaderError
+ },
+ component: () =>
Route content
,
+ errorComponent: InteractiveErrorComponent,
+})
+
+function InteractiveErrorComponent(props: ErrorComponentProps) {
+ const [clicked, setClicked] = createSignal(false)
+
+ return (
+
+
+ {props.error instanceof Error
+ ? props.error.message
+ : String(props.error)}
+
+
+
+ )
+}
diff --git a/e2e/solid-start/query-integration/tests/error-component-hydration.spec.ts b/e2e/solid-start/query-integration/tests/error-component-hydration.spec.ts
new file mode 100644
index 00000000000..a681a09a1d6
--- /dev/null
+++ b/e2e/solid-start/query-integration/tests/error-component-hydration.spec.ts
@@ -0,0 +1,55 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import type { Page } from '@playwright/test'
+
+test.use({
+ whitelistErrors: [/Failed to load resource.*500/],
+})
+
+async function expectHydratedErrorComponent(
+ page: Page,
+ path: string,
+ message: string,
+) {
+ const hydrationWarnings: Array = []
+ page.on('console', (consoleMessage) => {
+ if (
+ consoleMessage.text().includes('Hydration key miss') ||
+ consoleMessage.text().includes('unclaimed server-rendered')
+ ) {
+ hydrationWarnings.push(consoleMessage.text())
+ }
+ })
+
+ const response = await page.goto(path)
+
+ expect(response?.status()).toBe(500)
+ await expect(page.getByTestId('route-content')).toHaveCount(0)
+ await expect(page.getByTestId('error-message')).toHaveText(message)
+
+ const button = page.getByTestId('error-component-button')
+ await expect(button).toHaveAttribute('data-clicked', 'false')
+ await button.click()
+ await expect(button).toHaveAttribute('data-clicked', 'true')
+ expect(hydrationWarnings).toEqual([])
+}
+
+test('SSR error component is claimed during hydration and remains interactive', async ({
+ page,
+}) => {
+ await expectHydratedErrorComponent(
+ page,
+ '/error-component-hydration',
+ 'loader failed',
+ )
+})
+
+test('falsy loader errors still render an interactive hydrated error component', async ({
+ page,
+}) => {
+ await expectHydratedErrorComponent(
+ page,
+ '/error-component-hydration?falsy=true',
+ 'undefined',
+ )
+})
diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx
index f35e451e299..d2d6c6afe5b 100644
--- a/packages/solid-router/src/CatchBoundary.tsx
+++ b/packages/solid-router/src/CatchBoundary.tsx
@@ -4,15 +4,15 @@ import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type { ErrorRouteComponent } from './route'
import type { JSX } from '@solidjs/web'
-export function CatchBoundary(
- props: {
- getResetKey: () => unknown
- children?: JSX.Element
- render?: () => JSX.Element
- errorComponent?: ErrorRouteComponent
- onCatch?: (error: Error) => void
- } & Solid.ParentProps,
-) {
+type CatchBoundaryProps = {
+ getResetKey: () => unknown
+ children?: JSX.Element
+ render?: () => JSX.Element
+ errorComponent?: ErrorRouteComponent
+ onCatch?: (error: Error) => void
+} & Solid.ParentProps
+
+export function CatchBoundary(props: CatchBoundaryProps) {
const [retryKey, setRetryKey] = Solid.createSignal