Skip to content

fix: answer router-rejected data requests - #159

Merged
KyleJune merged 1 commit into
mainfrom
fix/data-request-router-errors
Sep 24, 2026
Merged

KyleJune merged 1 commit into
mainfrom
fix/data-request-router-errors

Conversation

@KyleJune

Copy link
Copy Markdown
Member

Summary

React Router 8.3.1's queryRoute can reject a data request (one that carries
X-Juniper-Route-Id) before any loader or action runs. It throws an
ErrorResponseImpl from getInternalRouterError in lib/router/router.js.
That object is not an Error. Hono's compose passes only Error instances
to onError, so these requests escaped Juniper's error handling and
server.request rejected, which is a 500 under Deno.serve. #155's
dataStrategy never ran for them, because React Router throws before it calls
the strategy.

The fix uses one mechanism. handleDataRequest catches anything queryRoute
throws and passes it through convertToHttpError. It then rethrows the result
as an HttpError, so Hono's onError runs the existing errorHandler, which
sends the envelope through newEnvelopeResponse → newDataResponse →
commitResponse. convertToHttpError is idempotent for Error and
HttpError, so errors that already reached onError produce the same
response as before. convertToHttpError also gains a branch for React
Router's ErrorResponse.

What React Router throws in each case, and what the response is now:

Case React Router 8.3.1 throws Response now
Route id doesn't match the URL (queryRoute) internal 403 "Route X does not match URL Y" 404
POST to a route with no action (submit, or callLoaderOrAction for a lazy route) internal 405 405 with Allow
Method React Router rejects outright, e.g. PROPFIND (queryRoute) internal 405 405 with Allow
GET to a route with no loader (loadRouteData) internal 400 "did not provide a loader" 400, React Router's own status
  • 404, not React Router's 403. A route id that doesn't match the URL names
    a resource that doesn't exist here. A 403 would tell the client the request
    was forbidden, which it wasn't. getInternalRouterError(403) is called from
    exactly one place (queryRoute's route-id check), so the remap is limited to
    router-internal errors (internal === true). An ErrorResponse that app
    code throws keeps its 403.
  • Allow on the router's 405s. It's built from the route with the header's
    id, looked up among the routes that match the URL. GET, HEAD is listed when
    the route has a loader, and POST, PUT, PATCH, DELETE when it has an action.
    A probe confirmed that HEAD data requests run the loader, and that PUT,
    PATCH and DELETE data requests run the action. The header is empty when the
    route has neither. It's left off in two cases: when the route id doesn't
    match the URL, and while a lazy route's module hasn't loaded, because its
    handlers aren't known yet. A 405 that app code throws keeps its own headers,
    and no Allow is added to it.
  • Messages. Router-internal errors get expose: false, so the envelope
    carries the status's generic exposedMessage. React Router's message, which
    names the route, goes to the server log only. In development, the serialized
    stack still includes that message, as it does for every error in
    development.
  • Cache policy. The envelope keeps the private default,
    Cache-Control: private, no-cache.

Found by the same catch, and now covered by tests:

  • A loader that throws a value that isn't an Error, such as a string, used to
    make server.request reject in the same way. It now gets a 500 envelope with
    the value hidden.
  • An ErrorResponse that a loader throws now becomes a data error with its own
    status and message.

A pre-existing React Router inconsistency is documented here but not changed.
A lazy route without a loader answers its first GET data request with
undefined data, because React Router treats an unloaded lazy route as
possibly having one. Once the module has loaded, the same request gets the 400.

Changes

  • src/_server.tsx:
    • handleDataRequest catches rejections from queryRoute and passes them
      to toDataRequestError.
    • Adds toDataRequestError and allowedDataMethods, which build the Allow
      header for router-internal 405s.
    • Adds an isRouteErrorResponse branch in convertToHttpError, through
      routeErrorResponseToHttpError and isRouterRejection: router-internal
      errors are hidden, a 403 becomes a 404, and the original is kept as cause
      for the log.
  • src/server.test.tsx: new suite, "data requests React Router rejects before
    a loader or action runs".
  • docs/error-handling.md: documents the 404, 405 and 400 responses, the
    Allow header, and the message policy. These requests had no documented
    behavior before, so no documented behavior changes.

Testing

Every case in the new suite runs twice, with cors() in front of the app's
middleware and without it, as in #152's and #155's suites. That makes 38 cases
in all.

  • The error cases check the status, X-Juniper: data, Content-Type,
    Cache-Control: private, no-cache, the app's own header, an HttpError
    envelope with the generic message, and that React Router's message is absent
    from the body. They also check the Allow header, or that it's absent.
  • A route id that doesn't match the URL, whether unknown or belonging to
    another route, on GET and POST: 404.
  • A POST to a route without an action: 405. The routes covered are one with
    neither a loader nor an action (Allow: ""), one with a loader
    (Allow: GET, HEAD), and a lazy route, which takes the callLoaderOrAction
    path.
  • A PROPFIND request: 405 with Allow: GET, HEAD for a route with a loader,
    and Allow: POST, PUT, PATCH, DELETE for a route with an action. Allow is
    left off for a route id that doesn't match the URL and for a lazy route that
    hasn't loaded.
  • A GET to / and to a page without a loader: 400.
  • A thrown string: 500. A thrown ErrorResponse: keeps its 403 and its message.
  • A 405 Response that an action throws keeps its own Allow: GET. A 405
    ErrorResponse that a loader throws gets no Allow.
  • Unchanged behavior: a loader's data still reaches a data request, a document
    still renders for a route without a loader, and a document POST to a route
    without an action still renders the 405 HTML error page.

Before the fix, all 26 error-case tests for React Router rejections, thrown
strings and thrown ErrorResponses failed at the request itself:
server.request rejected with ErrorResponseImpl in 24 of them and with the
thrown string in 2. The 6 unchanged-behavior tests passed.

Mutation checks. Each mutation was reverted by editing the line back, and a
checksum confirmed the file matched afterwards.

  • Removing the React Router ErrorResponse branch in convertToHttpError
    fails the 8 route-id tests and the 2 ErrorResponse tests at the status.
    HttpError.from treats an ErrorResponse as problem details and keeps its
    403.
  • Removing the 403 → 404 remap fails the 8 route-id tests at the status
    assertion.
  • Applying the remap to app-thrown ErrorResponses fails the 2 "keeps the
    status and message" tests at the status.
  • Exposing router messages fails 22 tests at the message assertion.
  • Dropping the Allow header fails the 10 tests that expect one, at Allow.
    Listing only POST for an action fails the 2 PROPFIND tests on the action
    route.
  • Setting Allow on any 405 fails the "keeps the Allow header" and "adds no
    Allow header" tests. Dropping only the router-internal check fails the
    second one.
  • Ignoring whether the lazy route has loaded, or looking up the route id
    without matching the URL, fails the matching "leaves Allow off" tests.

deno task check, deno task test --parallel (54 passed, 682 steps) and
deno task test:example (33 passed) pass on the final commit.

An adversarial review ran on the diff, and all four of its findings are fixed
in this PR:

  • no test covered the guard that keeps an app's own Allow;
  • Allow was computed for a route id that doesn't match the URL;
  • an empty Allow was sent for a lazy route that hadn't loaded;
  • the docs said the router message is never sent, but it's in the stack in
    development.

The review found no regressions in redirects, thrown Responses, data(),
deferred data, aborts, or responses that middleware commits.

Closes

Closes #156

🤖 Generated with Claude Code

A data request that React Router's queryRoute rejects before any loader
or action runs (an X-Juniper-Route-Id that doesn't match the URL, a POST
to a route with no action, a GET to a route with no loader) threw an
ErrorResponseImpl. Hono only passes Error instances to onError, so the
request escaped Juniper's error handling. It now becomes an HttpError and
goes out as a data-error envelope: 404, 405 with Allow, or 400.

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

### Bug Fixes

* answer router-rejected data requests ([#159](#159)) ([d075d4a](d075d4a))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.16.5 🎉

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.

Data requests React Router rejects before any loader runs end as an unhandled error

1 participant