Skip to content

fix: keep headers of thrown and data() responses - #155

Merged
KyleJune merged 1 commit into
mainfrom
fix/loader-response-errors
Sep 23, 2026
Merged

KyleJune merged 1 commit into
mainfrom
fix/loader-response-errors

Conversation

@KyleJune

Copy link
Copy Markdown
Member

Summary

This PR fixes two bugs that showed up while reviewing #152.

Data requests (#153). A data request could reject in server.request. It
happened when a loader or action threw a Response that isn't a redirect, or
threw or returned data(). For all of these, React Router 8.3.1's queryRoute
throws a bare Response: see callDataStrategy and queryImpl in
lib/router/router.js. Hono 4.13.7's compose only passes Error instances to
onError and rethrows anything else. So under Deno.serve the request ended as
a generic 500, and the response's own headers were lost, cookies included. By
the time queryRoute returns, a returned data() and a thrown Response look
the same, so the handler can't tell them apart.

The fix normalizes each result where React Router can still tell them apart: a
dataStrategy that is passed to queryRoute only for data requests. It is
React Router's defaultDataStrategy (filter on shouldLoad, then resolve())
plus one mapping step:

  • A thrown Response or thrown data() becomes an HttpError through
    convertToHttpError, which already converts Hono's HTTPException. The
    existing error handler sends it as the data error envelope with that status
    and those headers. A status outside 400–599 becomes 500, because the
    HttpError constructor throws a RangeError for any other status.
  • A returned data() goes out as data in a 200 envelope, with its own
    headers and its own Cache-Control. The client's fetchServerData reads any
    non-2xx X-Juniper: data response as an error envelope. So the status that
    data() sets applies to document requests only. Keeping it on data requests
    would also break a null-body status like 204.
  • Redirects stay with React Router, which handles only 301, 302, 303, 307
    and 308.

Both envelopes now go through one helper, newEnvelopeResponse, which then
calls newDataResponse and commitResponse. The error handler's own copy of
the header-merging code is removed.

Document requests (#154). When a loader threw an HttpError, the error
document now carries that error's headers. Before, renderDocument applied
error headers only from presetError, which is set only for errors thrown by
middleware. Now it applies the headers of whichever error sets the status:
presetError, or else the first HttpError-like value in context.errors.
Cookies are copied with getSetCookie() and append. Body-framing headers
(Content-Length, Content-Encoding, Transfer-Encoding, Content-Type,
X-Juniper) are not copied onto the streamed HTML. The data envelope skips the
same set.

Changes

  • src/_server.tsx:
    • Adds dataRequestStrategy and toDataRequestResult, and the data() check
      isDataWithResponseInit, which mirrors React Router's structural check.
    • Adds responseToHttpError, now shared by thrown Responses and
      HTTPException. In a problem+json body, the response status now wins over
      the body's status, and the response's headers are kept.
    • Adds newEnvelopeResponse and one BODY_HEADERS set, used by both
      envelopes and by the error document.
    • renderDocument now applies the headers of the error that sets the status.
    • newDataResponse now adds no-transform to a route's own policy on a
      deferred stream, as the docs already say it does for middleware policies.
      Before this change, data() was the only route-set policy that could reach
      a stream.
  • src/server.tsx: the createServer JSDoc now lists error and data()
    policies among those that replace the default.
  • docs/routing.md: documents what a returned data() becomes on a data
    request.
  • docs/error-handling.md: documents error headers, plus thrown Response and
    data() on data requests.
  • src/server.test.tsx: new cases in "the headers a loader or action response
    sets itself".

Testing

Every new case runs for GET and POST. Each also runs twice, with cors() in
front of the app's middleware and without it, the same way #152's suite does.
The middleware sets a cookie, a header and a cache policy before next().

  • A thrown Response, and a thrown data(): 410, X-Juniper: data, and an
    HttpError envelope with the right message. The error's Cache-Control is
    kept, and both route cookies come after the app's.
  • A returned data() with status 201, 204, 404, or 305 plus Location: a
    200 data envelope whose value round-trips (a Date included), with its
    headers and cookies.
  • Cache policy: a data() without its own policy gets the app's policy. A
    deferred data() with its own policy gets , no-transform appended.
  • A returned data() with 302 and Location: still sent as the redirect
    envelope.
  • A thrown problem+json Response: keeps its own status over the body's
    status, and keeps its headers.
  • A thrown Response with status 200: a 500 error envelope with its
    headers.
  • Document request with a thrown HttpError: 401, HTML, the error's
    Cache-Control and both cookies. Content-Length and Content-Encoding from
    the error are not copied.

Before the fix, the #153 cases rejected with Response {…}, and the 204 case
returned 500. The #154 case failed at Cache-Control
(public, max-age=60 instead of no-store).

Mutation checks. Each was reverted by editing the line back, with a checksum
check afterwards.

  • Skipping the thrown-Response/data() conversion: 14 cases reject with
    Response {…}.
  • Skipping the returned-data() unwrap: 24 cases fail, with a rejection or 500
    instead of 200.
  • Applying document error headers only from presetError: the four An HttpError thrown by a loader on a document request loses its own headers #154 cases
    fail at Cache-Control or at the cookies.
  • Dropping the problem+json headers, reverting the document filter to
    content-type only, treating all of 300–399 as redirects, and dropping
    no-transform from a route's own policy: each fails its own new case at the
    intended assertion.

deno task check, deno task test --parallel, and deno task test:example
pass on the rebased branch.

An adversarial review ran on the diff. Its four findings are fixed in this PR:
the 3xx redirect set, framing headers on the document, the untested
problem+json headers, and problem+json status precedence. Its no-transform
note is fixed too.

Known gaps, left out of this PR:

  • A thrown Response with a JSON body, and a thrown data(new Error(...)),
    produce a different error message on a data request than on a server render.
  • Data requests on which React Router throws its own error still reject with
    ErrorResponseImpl: an unknown X-Juniper-Route-Id, a POST to a route with
    no action, or a GET to a route with no loader. The dataStrategy never runs
    for these.

Closes

Closes #153
Closes #154

🤖 Generated with Claude Code

On a data request, a loader or action that threw a non-redirect Response,
or threw or returned data(), made server.request reject: React Router's
queryRoute throws a Response for those, and Hono only routes Error
instances to onError. A data-request dataStrategy now turns a thrown
Response or data() into an HttpError (the data-error envelope, with its
status and headers) and a returned data() into a 200 data envelope with
its headers. Both envelopes go through commitResponse.

On a document request, the error document now carries the headers of the
HttpError that sets its status, not only one thrown by middleware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KyleJune
KyleJune merged commit 487a7b3 into main Sep 23, 2026
10 checks passed
@KyleJune
KyleJune deleted the fix/loader-response-errors branch September 23, 2026 09:26
KyleJune pushed a commit that referenced this pull request Sep 23, 2026
## [0.16.4](0.16.3...0.16.4) (2026-09-23)

### Bug Fixes

* keep headers of thrown and data() responses ([#155](#155)) ([487a7b3](487a7b3))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.16.4 🎉

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

1 participant