fix: keep headers of thrown and data() responses - #155
Merged
Merged
Conversation
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
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))
|
🎉 This PR is included in version 0.16.4 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This was referenced Sep 23, 2026
Error documents should not take a shared-cache policy from the error while carrying loader data
#157
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR fixes two bugs that showed up while reviewing #152.
Data requests (#153). A data request could reject in
server.request. Ithappened when a loader or action threw a
Responsethat isn't a redirect, orthrew or returned
data(). For all of these, React Router 8.3.1'squeryRoutethrows a bare
Response: seecallDataStrategyandqueryImplinlib/router/router.js. Hono 4.13.7'scomposeonly passesErrorinstances toonErrorand rethrows anything else. So underDeno.servethe request ended asa generic 500, and the response's own headers were lost, cookies included. By
the time
queryRoutereturns, a returneddata()and a thrownResponselookthe same, so the handler can't tell them apart.
The fix normalizes each result where React Router can still tell them apart: a
dataStrategythat is passed toqueryRouteonly for data requests. It isReact Router's
defaultDataStrategy(filter onshouldLoad, thenresolve())plus one mapping step:
Responseor throwndata()becomes anHttpErrorthroughconvertToHttpError, which already converts Hono'sHTTPException. Theexisting error handler sends it as the data error envelope with that status
and those headers. A status outside 400–599 becomes 500, because the
HttpErrorconstructor throws aRangeErrorfor any other status.data()goes out as data in a 200 envelope, with its ownheaders and its own
Cache-Control. The client'sfetchServerDatareads anynon-2xx
X-Juniper: dataresponse as an error envelope. So the status thatdata()sets applies to document requests only. Keeping it on data requestswould also break a null-body status like 204.
and 308.
Both envelopes now go through one helper,
newEnvelopeResponse, which thencalls
newDataResponseandcommitResponse. The error handler's own copy ofthe header-merging code is removed.
Document requests (#154). When a loader threw an
HttpError, the errordocument now carries that error's headers. Before,
renderDocumentappliederror headers only from
presetError, which is set only for errors thrown bymiddleware. Now it applies the headers of whichever error sets the status:
presetError, or else the firstHttpError-like value incontext.errors.Cookies are copied with
getSetCookie()andappend. Body-framing headers(
Content-Length,Content-Encoding,Transfer-Encoding,Content-Type,X-Juniper) are not copied onto the streamed HTML. The data envelope skips thesame set.
Changes
src/_server.tsx:dataRequestStrategyandtoDataRequestResult, and thedata()checkisDataWithResponseInit, which mirrors React Router's structural check.responseToHttpError, now shared by thrown Responses andHTTPException. In a problem+json body, the response status now wins overthe body's
status, and the response's headers are kept.newEnvelopeResponseand oneBODY_HEADERSset, used by bothenvelopes and by the error document.
renderDocumentnow applies the headers of the error that sets the status.newDataResponsenow addsno-transformto a route's own policy on adeferred stream, as the docs already say it does for middleware policies.
Before this change,
data()was the only route-set policy that could reacha stream.
src/server.tsx: thecreateServerJSDoc now lists error anddata()policies among those that replace the default.
docs/routing.md: documents what a returneddata()becomes on a datarequest.
docs/error-handling.md: documents error headers, plus thrownResponseanddata()on data requests.src/server.test.tsx: new cases in "the headers a loader or action responsesets itself".
Testing
Every new case runs for GET and POST. Each also runs twice, with
cors()infront 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().Response, and a throwndata(): 410,X-Juniper: data, and anHttpErrorenvelope with the right message. The error'sCache-Controliskept, and both route cookies come after the app's.
data()with status 201, 204, 404, or 305 plusLocation: a200 data envelope whose value round-trips (a
Dateincluded), with itsheaders and cookies.
data()without its own policy gets the app's policy. Adeferred
data()with its own policy gets, no-transformappended.data()with 302 andLocation: still sent as the redirectenvelope.
Response: keeps its own status over the body'sstatus, and keeps its headers.Responsewith status 200: a 500 error envelope with itsheaders.
HttpError: 401, HTML, the error'sCache-Controland both cookies.Content-LengthandContent-Encodingfromthe error are not copied.
Before the fix, the #153 cases rejected with
Response {…}, and the 204 casereturned 500. The #154 case failed at
Cache-Control(
public, max-age=60instead ofno-store).Mutation checks. Each was reverted by editing the line back, with a checksum
check afterwards.
Response/data()conversion: 14 cases reject withResponse {…}.data()unwrap: 24 cases fail, with a rejection or 500instead of 200.
presetError: the four An HttpError thrown by a loader on a document request loses its own headers #154 casesfail at
Cache-Controlor at the cookies.content-typeonly, treating all of 300–399 as redirects, and droppingno-transformfrom a route's own policy: each fails its own new case at theintended assertion.
deno task check,deno task test --parallel, anddeno task test:examplepass 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-transformnote is fixed too.
Known gaps, left out of this PR:
Responsewith a JSON body, and a throwndata(new Error(...)),produce a different error message on a data request than on a server render.
ErrorResponseImpl: an unknownX-Juniper-Route-Id, a POST to a route withno action, or a GET to a route with no loader. The
dataStrategynever runsfor these.
Closes
Closes #153
Closes #154
🤖 Generated with Claude Code