fix: answer router-rejected data requests - #159
Merged
Merged
Conversation
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
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))
|
🎉 This PR is included in version 0.16.5 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This was referenced Sep 24, 2026
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
React Router 8.3.1's
queryRoutecan reject a data request (one that carriesX-Juniper-Route-Id) before any loader or action runs. It throws anErrorResponseImplfromgetInternalRouterErrorinlib/router/router.js.That object is not an
Error. Hono'scomposepasses onlyErrorinstancesto
onError, so these requests escaped Juniper's error handling andserver.requestrejected, which is a 500 underDeno.serve. #155'sdataStrategynever ran for them, because React Router throws before it callsthe strategy.
The fix uses one mechanism.
handleDataRequestcatches anythingqueryRoutethrows and passes it through
convertToHttpError. It then rethrows the resultas an
HttpError, so Hono'sonErrorruns the existingerrorHandler, whichsends the envelope through
newEnvelopeResponse→newDataResponse→commitResponse.convertToHttpErroris idempotent forErrorandHttpError, so errors that already reachedonErrorproduce the sameresponse as before.
convertToHttpErroralso gains a branch for ReactRouter's
ErrorResponse.What React Router throws in each case, and what the response is now:
queryRoute)submit, orcallLoaderOrActionfor a lazy route)AllowPROPFIND(queryRoute)AllowloadRouteData)loader"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 fromexactly one place (
queryRoute's route-id check), so the remap is limited torouter-internal errors (
internal === true). AnErrorResponsethat appcode throws keeps its 403.
Allowon the router's 405s. It's built from the route with the header'sid, looked up among the routes that match the URL.
GET, HEADis listed whenthe route has a loader, and
POST, PUT, PATCH, DELETEwhen 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
Allowis added to it.expose: false, so the envelopecarries the status's generic
exposedMessage. React Router's message, whichnames the route, goes to the server log only. In development, the serialized
stackstill includes that message, as it does for every error indevelopment.
Cache-Control: private, no-cache.Found by the same catch, and now covered by tests:
Error, such as a string, used tomake
server.requestreject in the same way. It now gets a 500 envelope withthe value hidden.
ErrorResponsethat a loader throws now becomes a data error with its ownstatus 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
undefineddata, because React Router treats an unloaded lazy route aspossibly having one. Once the module has loaded, the same request gets the 400.
Changes
src/_server.tsx:handleDataRequestcatches rejections fromqueryRouteand passes themto
toDataRequestError.toDataRequestErrorandallowedDataMethods, which build theAllowheader for router-internal 405s.
isRouteErrorResponsebranch inconvertToHttpError, throughrouteErrorResponseToHttpErrorandisRouterRejection: router-internalerrors are hidden, a 403 becomes a 404, and the original is kept as
causefor the log.
src/server.test.tsx: new suite, "data requests React Router rejects beforea loader or action runs".
docs/error-handling.md: documents the 404, 405 and 400 responses, theAllowheader, and the message policy. These requests had no documentedbehavior before, so no documented behavior changes.
Testing
Every case in the new suite runs twice, with
cors()in front of the app'smiddleware and without it, as in #152's and #155's suites. That makes 38 cases
in all.
X-Juniper: data,Content-Type,Cache-Control: private, no-cache, the app's own header, anHttpErrorenvelope with the generic message, and that React Router's message is absent
from the body. They also check the
Allowheader, or that it's absent.another route, on GET and POST: 404.
neither a loader nor an action (
Allow: ""), one with a loader(
Allow: GET, HEAD), and a lazy route, which takes thecallLoaderOrActionpath.
PROPFINDrequest: 405 withAllow: GET, HEADfor a route with a loader,and
Allow: POST, PUT, PATCH, DELETEfor a route with an action.Allowisleft off for a route id that doesn't match the URL and for a lazy route that
hasn't loaded.
/and to a page without a loader: 400.ErrorResponse: keeps its 403 and its message.Responsethat an action throws keeps its ownAllow: GET. A 405ErrorResponsethat a loader throws gets noAllow.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.requestrejected withErrorResponseImplin 24 of them and with thethrown 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.
ErrorResponsebranch inconvertToHttpErrorfails the 8 route-id tests and the 2
ErrorResponsetests at the status.HttpError.fromtreats anErrorResponseas problem details and keeps its403.
assertion.
ErrorResponses fails the 2 "keeps thestatus and message" tests at the status.
Allowheader fails the 10 tests that expect one, atAllow.Listing only
POSTfor an action fails the 2PROPFINDtests on the actionroute.
Allowon any 405 fails the "keeps the Allow header" and "adds noAllow header" tests. Dropping only the router-internal check fails the
second one.
without matching the URL, fails the matching "leaves Allow off" tests.
deno task check,deno task test --parallel(54 passed, 682 steps) anddeno 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:
Allow;Allowwas computed for a route id that doesn't match the URL;Allowwas sent for a lazy route that hadn't loaded;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