Skip to content

[solid-router] errorComponent is SSR'd inside MatchInner but hydrated from CatchBoundary's Errored fallback — hydration key miss, error UI never interactive #8241

Description

@TylerRick

Which project does this relate to? Router

Describe the bug

In @tanstack/solid-router, a route whose loader throws gets its errorComponent instantiated at two different points in the component tree depending on the side. MatchInner's status === 'error' branch (packages/solid-router/src/Match.tsx) renders the error component inline on the server, as a child of its own <Solid.Match>, but throws on the client, so CatchBoundary's <Solid.Errored> catches it and renders the error component from its fallback, several levels higher:

<Solid.Match when={currentMatch().status === 'error'}>
  {(_) => {
    if (isServer ?? router.isServer) {
      const RouteErrorComponent =
        (route.options.errorComponent ?? router.options.defaultErrorComponent) || ErrorComponent
      return <RouteErrorComponent error={currentMatch().error} info={{ componentStack: '' }} />
    }
    throw currentMatch().error
  }}
</Solid.Match>

Solid hydration is id-addressed, and the two sides mint the error component's ids under different namespaces — server: Match → CatchBoundary → Errored → Show → RouteContent → … → MatchInner → Show → Switch → Match; client: Match → CatchBoundary → Errored. The client's root element therefore names a key no server element carries. @solidjs/web logs Hydration key miss and builds the client copy detached; the server's copy stays in the document, unclaimed, with the markup the user sees and no event listeners. Any control inside an error component is dead after hydration. A one-element error component (a bare <h1>) looks fine, because nothing in it needed wiring — which is how this stays invisible until someone puts a button in one.

From the reproducer, the server id, the client's missed claim, and — for comparison — the id the same component gets when rendered from the route's normal component:

server  …0004900010002 1011032370000   ← MatchInner renders <RouteErrorComponent> inline
client  …0004900010002 200100          ← <Errored fallback> instantiates it at the CatchBoundary
control …0004900010002 1032371102      ← MatchInner's success branch

The React router has the same server/client split and gets away with it because React reconciles by tree position and re-renders on a mismatch; Solid does neither.

Complete minimal reproducer

https://github.com/TylerRick/tanstack-solid-router-error-component-hydration-repro

Steps to Reproduce the Bug

  1. pnpm install && pnpm dev, then npx playwright install chromium once and node check.mjs (or open http://localhost:5602 and click the button by hand).
  2. / is the subject: the loader throws synchronously; the route's errorComponent renders a <Card> component — a heading, a #count span, and a #bump button that increments it. /control renders the identical <Card> from the route's component. (/show, /async, and /element vary the error component's root and the loader's timing; they behave the same.)
  3. check.mjs loads each route, collects console output, and clicks the visible #bump:
--- SUBJECT (component-rooted errorComponent)
  hydration messages: 2
    [warning] Hydration key miss for "0000010101149a001000210323711620004900010002200100": no server-rendered
      element carries this key (template: <section id=card …). A detached element was created instead…
    [warning] Hydration completed with 1 unclaimed server-rendered node(s):
      <section _hk="0000010101149a0010002103237116200049000100021011032370000" id="card" …
  #count after clicking the visible #bump: ["0"]
  visible button wired: false
--- CONTROL (same Card via the route component)
  hydration messages: 0
  #count after clicking the visible #bump: ["1"]
  visible button wired: true

Expected behavior

The error component hydrates: no key miss, and its controls work after hydration — the same as the identical component rendered from component. That needs the error component to be instantiated from ONE place on both sides; whether that is the server also throwing into <Errored>, or the client rendering inline as the server does (with reset passed through context rather than the boundary), is your call.

Screenshots or Videos

n/a — check.mjs output above.

Platform

  • @tanstack/solid-router 2.0.0-rc.4, @tanstack/solid-start 2.0.0-rc.4, @tanstack/router-core 1.171.22 — the Match.tsx branch quoted above is unchanged on main today
  • solid-js / @solidjs/web 2.0.0-rc.6
  • OS: Linux
  • Browser: Chromium (Playwright 1.62.1), 151.0.7922.34
  • Bundler: Vite 8.2.2, vite-plugin-solid 3.0.0-next.27

Additional context

Found in a TanStack Start app while giving a route an errorComponent with a "sign out and retry" button for its 403: the button rendered and did nothing. The app's default error component is a lone <h1>, which is why the key miss had been sitting in our logs as harmless. In that app the detached client copy was also inserted into the page next to the server's (the error UI rendered twice); the reproducer shows the key miss and the dead button but holds one copy, so I have not isolated what the duplicate needs.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions