Skip to content

fix: type boundary loaderData as optional and nest route stubs - #160

Merged
KyleJune merged 1 commit into
mainfrom
fix/error-boundary-loader-data
Sep 24, 2026
Merged

KyleJune merged 1 commit into
mainfrom
fix/error-boundary-loader-data

Conversation

@KyleJune

Copy link
Copy Markdown
Member

Summary

Two testing and type defects around route error boundaries. Both were found while fixing a udibo layout boundary (udibo/udibo#973) and are tracked as udibo/udibo#979 and udibo/udibo#975.

ErrorBoundaryProps typed loaderData as always present (udibo/udibo#979). It is undefined when the boundary's own loader threw. React Router 8.3.1 handles this in processRouteLoaderData, which writes ResetLoaderDataSymbol for that route, and in mergeLoaderData, which filters the symbol out and doesn't carry the previous navigation's value forward. The static handler used for SSR never sets it. It is also undefined when the route has no loader, when middleware refused the request, and during SSR of a no-JS form submission that failed at or below the route. loaderData and actionData are now LoaderData | undefined and ActionData | undefined on ErrorBoundaryProps, so the compiler makes a boundary check them before reading.

createRoutesStub could only build a flat router (udibo/udibo#975). A layout ErrorBoundary could only be reached by failing the layout's own loader, never a child's. RouteStub now takes children and index, and each nested route goes through the same createRoute boundary shim production uses, so the stub doesn't need a copy of it. This also covers the second item in udibo/udibo#979, the request for an exported error-normalization helper. Tests no longer hand-build a nested router that has to copy the shim's isRouteErrorResponse → HttpError step; the stub runs the real one.

Type change: this can break existing code

This is titled fix: because the old type was wrong, but it can make existing code fail to typecheck. A boundary that read loaderData.someField (or actionData.x) without a check now gets a type error. Every such error points at a real crash in the case the boundary exists for. ErrorBoundaryProps no longer extends RouteProps directly. It extends Omit<RouteProps, "loaderData" | "actionData"> and widens those two fields. Nothing in this repo's example, templates or tutorial needed a change. udibo also typechecks cleanly against this branch, because its two boundaries that read loaderData already declared | undefined themselves.

The flat createRoutesStub API is unchanged: top-level routes still default to path / and remain siblings.

Changes

  • src/mod.ts: ErrorBoundaryProps.loaderData / actionData are now | undefined. The JSDoc says when each is absent.
  • src/utils/testing.ts: RouteStub.children and RouteStub.index, built recursively through createRoute.
    • A child's path is relative to its parent. A child with neither path nor index is a pathless layout.
    • An index route with children throws a TypeError when the stub is created.
    • The JSDoc gives the nested hydrationData ids ("0-0").
  • docs/error-handling.md: the ErrorBoundaryProps block now matches the type. A new layout-boundary example keeps its navigation when only a child failed. The nested-boundaries section links to testing.
  • docs/testing.md: covers nested stubs and adds a "Testing Error Boundaries" example. It also notes that the stub has no production catch-all 404 route.

Testing

  • src/mod.test.tsx (new):
    • Type level: assertType<IsExact<…>> on both fields, plus a @ts-expect-error on an unguarded loaderData.name.
    • Browser, through the nested stub:
      • the boundary's own loader threw: undefined
      • a descendant's loader threw: present
      • a client navigation from a resolved layout to one whose loader throws: undefined, not carried forward
    • SSR, through Client + createServer: own loader threw, descendant loader threw, and a no-JS POST whose child action threw.
  • src/utils/testing.test.tsx: a new "createRoutesStub with nested routes" block covers:
    • an index child
    • a relative child path
    • a pathless layout
    • a layout boundary catching a child loader failure
    • a layout boundary catching a child render failure
    • a thrown data() response reaching the boundary as HttpError
    • a child's own boundary taking precedence
    • nested hydrationData ids
    • a child's routeId reaching its server loader request
    • index-with-children refusal
  • Fail-first: every nested-stub and browser loaderData test failed against the unfixed stub (--no-check, 13 steps). The type tests failed to typecheck against the unfixed type.
  • Mutations, each undone by editing the line back:
    • loaderData: LoaderData | undefined → LoaderData: mod.test.tsx fails typecheck with TS2345 at the IsExact check and TS2578 for the unused @ts-expect-error.
    • Children dropped (children: undefined, i.e. a flat stub): every nested test fails. The layout-loader case fails at the intended assertion, rendering 404: Error: No route matches URL "/tenants/acme/users" and No layout data instead of 404: User not found.
  • Gates, run after rebasing onto fix: answer router-rejected data requests #159: deno task check, deno task test (58 files, 700 steps) and deno task test:example (33 files, 329 steps) all pass.
  • An independent review found nothing blocking. Its three wording findings were fixed: the no-JS submission case, actionData scoped to "this route", and the missing catch-all 404 route. The no-JS case is now pinned by the SSR POST test.

Related, not fixed here: when a route has HydrateFallback and an async loader, its ErrorBoundary gets the internal { promise } wrapper as loaderData. That is a change to the src/_client.tsx boundary shim, filed as #158.

Closes

Nothing in this repo. The two udibo issues this fixes, udibo/udibo#979 and udibo/udibo#975, stay open until udibo adopts the release.

🤖 Generated with Claude Code

ErrorBoundaryProps now types loaderData and actionData as possibly
undefined, matching React Router, and createRoutesStub accepts children
and index so a layout boundary can be tested against a child failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KyleJune
KyleJune merged commit 80c7320 into main Sep 24, 2026
10 checks passed
@KyleJune
KyleJune deleted the fix/error-boundary-loader-data branch September 24, 2026 06:49
KyleJune pushed a commit that referenced this pull request Sep 24, 2026
## [0.16.6](0.16.5...0.16.6) (2026-09-24)

### Bug Fixes

* type boundary loaderData as optional and nest route stubs ([#160](#160)) ([80c7320](80c7320))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.16.6 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant