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({}) let resetBoundary: (() => void) | undefined let initialized = false @@ -58,24 +58,7 @@ export function CatchBoundary( props.onCatch?.(resolvedError) resetBoundary = reset - return process.env.NODE_ENV !== 'production' ? ( - renderInNonRouteComponentContext( - () => ( - - ), - 'errorComponent', - ) - ) : ( - - ) + return renderErrorComponent(props, resolvedError, reset) }} > @@ -85,6 +68,90 @@ export function CatchBoundary( ) } +// Route load errors already exist in match state during SSR. Render them at +// the boundary position on both the server and client so Solid hydrates the +// same owner tree instead of entering its error fallback from only one side. +export function RouteCatchBoundary( + props: CatchBoundaryProps & { + hasError: () => boolean + getError: () => unknown + isServer: boolean + }, +) { + const [retryKey, setRetryKey] = Solid.createSignal({}) + let initialized = false + let previousError: unknown + + Solid.createEffect(props.getError, (error) => { + if (!initialized) { + initialized = true + previousError = error + return + } + if (Object.is(error, previousError)) { + return + } + previousError = error + setRetryKey({}) + }) + + const renderRouteError = () => { + const resolvedError = Solid.untrack(props.getError) as Error + const reset = () => setRetryKey({}) + + if (!props.isServer) { + props.onCatch?.(resolvedError) + } + + return renderErrorComponent(props, resolvedError, reset) + } + + return ( + + {props.children} + + } + > + + {(_retryKey) => renderRouteError()} + + + ) +} + +function renderErrorComponent( + props: Pick, + resolvedError: Error, + reset: () => void, +) { + return process.env.NODE_ENV !== 'production' ? ( + renderInNonRouteComponentContext( + () => ( + + ), + 'errorComponent', + ) + ) : ( + + ) +} + export function ErrorComponent({ error }: { error: any }) { const [show, setShow] = Solid.createSignal( process.env.NODE_ENV !== 'production', diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 1f867dac119..3ae43c2360f 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -2,7 +2,7 @@ import * as Solid from 'solid-js' import { rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Dynamic } from '@solidjs/web' -import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { RouteCatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { CatchNotFound, getNotFound } from './not-found' import { nearestMatchContext } from './matchContext' @@ -51,6 +51,7 @@ export const Match = (props: { routeId: string }) => { routeId: match.routeId, ssr: match.ssr, status: match.status, + error: match.error, } }) const nearestMatch = [() => props.routeId, currentMatch] as const @@ -173,7 +174,10 @@ export const Match = (props: { routeId: string }) => { fallback={} > {(errorComponent) => ( - currentMatchState().status === 'error'} + getError={() => currentMatchState().error} + isServer={router.isServer} // Scope the reset key to this match and its // descendants (whose errors bubble here when they // have no errorComponent of their own): resetting on @@ -297,36 +301,7 @@ export const MatchInner = (): any => { {(_) => { - const matchError = Solid.untrack( - () => currentMatch().error, - ) as Error - if (isServer ?? router.isServer) { - const RouteErrorComponent = - (route().options.errorComponent ?? - router.options.defaultErrorComponent) || - ErrorComponent - - return process.env.NODE_ENV !== 'production' ? ( - renderInNonRouteComponentContext( - () => ( - - ), - 'errorComponent', - ) - ) : ( - - ) - } - - throw matchError + throw Solid.untrack(() => currentMatch().error) }} diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index af45fdcceeb..dd353eb091f 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -260,3 +260,34 @@ test('ancestor route errorComponent resets when a background child generation re await screen.findByText('Recovered child revision 2'), ).toBeInTheDocument() }) + +test('route errorComponent updates when a loader fails again after invalidation', async () => { + let loaderCalls = 0 + const onCatch = vi.fn() + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + throw new Error(`loader failed ${++loaderCalls}`) + }, + errorComponent: ({ error }) =>
{error.message}
, + onCatch, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + render(() => ) + expect(await screen.findByText('loader failed 1')).toBeInTheDocument() + expect(onCatch).toHaveBeenCalledTimes(1) + expect(onCatch).toHaveBeenLastCalledWith(expect.any(Error)) + + await router.invalidate() + + expect(await screen.findByText('loader failed 2')).toBeInTheDocument() + expect(onCatch).toHaveBeenCalledTimes(2) + expect(onCatch).toHaveBeenLastCalledWith(expect.any(Error)) +})