Skip to content

fix(start-server-core): fall back to GET handler for HEAD requests (RFC 9110 §9.3.2)#7325

Open
Zelys-DFKH wants to merge 4 commits intoTanStack:mainfrom
Zelys-DFKH:fix/head-request-fallback-to-get-handler
Open

fix(start-server-core): fall back to GET handler for HEAD requests (RFC 9110 §9.3.2)#7325
Zelys-DFKH wants to merge 4 commits intoTanStack:mainfrom
Zelys-DFKH:fix/head-request-fallback-to-get-handler

Conversation

@Zelys-DFKH
Copy link
Copy Markdown

@Zelys-DFKH Zelys-DFKH commented May 2, 2026

Fixes #7270.

RFC 9110 §9.3.2 requires HEAD responses to match GET in headers and status, with no body. When a route has no explicit HEAD handler, the server falls back to GET and strips the body.

What changed

  • Handler priority: HEAD now tries HEAD → GET → ANY. Previously ANY beat GET, so a route with both a GET and an ANY handler would skip the GET for HEAD requests entirely — wrong per RFC 9110.
  • Body stripping now covers the ANY fallback too, not just GET. If HEAD falls back to ANY, the body is stripped.
  • Redirect before strip: the old code stripped the body before resolving redirects, converting a RedirectResponse to a plain Response before handleRedirectResponse could process it. The Location header was lost as a result. The fix resolves the redirect first, then strips.
  • statusText is now copied to the stripped response.

E2E coverage

Three new Playwright tests in e2e/react-start/server-routes:

  • HEAD to a GET-only route: body empty, content-type preserved
  • HEAD to an ANY-only route: body empty, X-HANDLER: ANY and X-METHOD: HEAD preserved
  • HEAD to a route with both GET and ANY: GET wins, body empty

Test plan

  • pnpm test in packages/start-server-core
  • E2E tests pass in e2e/react-start/server-routes

Summary by CodeRabbit

  • New Features

    • Added three API endpoints: /api/head-fallback, /api/get-and-any, and /api/head-redirect-fallback.
  • Bug Fixes

    • HEAD requests now prefer HEAD → GET → ANY (RFC 9110), return empty bodies for HEAD fallbacks, preserve status/headers, and correctly resolve redirects.
  • Tests

    • Added end-to-end tests validating HEAD behavior, handler selection, header preservation, and redirect flow.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR makes HEAD requests to exact-match server file routes fall back HEAD → GET → ANY, strips the response body when the handler is not a native HEAD handler while preserving status/headers (including redirects), and adds route examples, tests, generated route-tree entries, and a changeset.

Changes

HEAD Request Fallback Implementation

Layer / File(s) Summary
Core Handler Logic
packages/start-server-core/src/createStartHandler.ts
HEAD handler selection changed to handlers.HEAD ?? handlers.GET ?? handlers.ANY; tracks isHeadFallback, resolves redirects when used, and returns new Response(null, resolved) to preserve headers/status but strip the body.
Server Routes (examples)
e2e/react-start/server-routes/src/routes/api/head-fallback.ts, e2e/react-start/server-routes/src/routes/api/get-and-any.ts, e2e/react-start/server-routes/src/routes/api/head-redirect-fallback.ts
Adds /api/head-fallback (GET-only, returns XML body/content-type), /api/get-and-any (GET and ANY handlers emitting x-handler/x-method), and /api/head-redirect-fallback (GET → 307 redirect to /api/head-fallback).
Route Registration (generated)
e2e/react-start/server-routes/src/routeTree.gen.ts
Imports, instantiates, and registers the three new routes; extends exported route type mappings and adds entries to RootRouteChildren and rootRouteChildren.
E2E Tests
e2e/react-start/server-routes/tests/server-routes.spec.ts
Adds Playwright HEAD fallback suite asserting HEAD responses preserve handler headers (e.g., content-type, x-handler, x-method), prefer GET over ANY, return expected status, and have empty bodies; includes redirect-follow test.
Changelog / Release Note
.changeset/fix-head-request-server-route-fallback.md
Adds a changeset documenting the HEAD fallback behavior change for @tanstack/start-server-core (patch).

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Start as StartHandler
    participant Router as Router
    participant File as FileRouteHandler

    Client->>Start: HEAD /path
    Start->>Router: match route & select handler (HEAD → GET → ANY)
    Router->>File: invoke chosen handler
    File-->>Router: Response (status, headers, maybe body)
    Router->>Start: middleware returns Response
    Start-->>Client: Response (same headers/status, body null)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I sniff a HEAD and find a GET,
I carry back the headers yet forget the rest.
Redirects hop along their track,
I tuck the body safe—then bounce right back.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main change: fixing HEAD request handling to fall back to GET handlers per RFC 9110, which is the core objective of this PR.
Linked Issues check ✅ Passed All coding objectives from issue #7270 are met: HEAD falls back to GET (with priority HEAD→GET→ANY), body is stripped while preserving headers/status, redirects are resolved before body stripping, statusText is preserved, and E2E tests verify content-type preservation and handler precedence.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue: core handler dispatch logic, E2E tests for HEAD behavior, route fixtures, and a changeset. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: Turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get your free trial and get 200 agent minutes per Slack user (a $50 value).


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 2, 2026

Bundle Size Benchmarks

  • Commit: 35261948496a
  • Measured at: 2026-05-03T08:20:25.876Z
  • Baseline source: history:35261948496a
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.15 KiB 0 B (0.00%) 273.94 KiB 75.70 KiB ▅▅▅▅▅▅▅▅▅▅▅
react-router.full 90.68 KiB 0 B (0.00%) 285.45 KiB 78.71 KiB ▅▅▅▅▅▅▅▅▅▅▅
solid-router.minimal 35.38 KiB 0 B (0.00%) 106.25 KiB 31.81 KiB ▅▅▅▅▅▅▅▅▅▅▅
solid-router.full 40.10 KiB 0 B (0.00%) 120.46 KiB 36.04 KiB ▅▅▅▅▅▅▅▅▅▅▅
vue-router.minimal 53.15 KiB 0 B (0.00%) 151.39 KiB 47.73 KiB ▅▅▅▅▅▅▅▅▅▅▅
vue-router.full 58.28 KiB 0 B (0.00%) 167.56 KiB 52.18 KiB ▅▅▅▅▅▅▅▅▅▅▅
react-start.minimal 101.76 KiB 0 B (0.00%) 322.10 KiB 87.97 KiB ▅▅▅▅▅▅▅▅▅▅▅
react-start.full 105.19 KiB 0 B (0.00%) 332.43 KiB 90.96 KiB ▅▅▅▅▅▅▅▅▅▅▅
react-start.rsbuild.minimal 99.33 KiB 0 B (0.00%) 316.47 KiB 85.46 KiB ▅▅▅▅▅▅▅▅▅▅▅
react-start.rsbuild.full 102.63 KiB 0 B (0.00%) 326.90 KiB 88.28 KiB ▅▅▅▅▅▅▅▅▅▅▅
solid-start.minimal 49.40 KiB 0 B (0.00%) 152.08 KiB 43.60 KiB ▅▅▅▅▅▅▅▅▅▅▅
solid-start.full 55.20 KiB 0 B (0.00%) 168.98 KiB 48.51 KiB ▅▅▅▅▅▅▅▅▅▅▅

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.


// Strip body from HEAD responses that fell back to the GET handler.
// RFC 9110 §9.3.2: HEAD must send the same headers as GET, but with no body.
if (isHeadFallingBackToGet && ctx.response instanceof Response) {
Copy link
Copy Markdown
Contributor

@schiller-manuel schiller-manuel May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the wrapped response does not preserve statusText

better:

return new Response(null, ctx.response)

handlers[requestMethod] ??
handlers['ANY'] ??
(requestMethod === 'HEAD' ? handlers['GET'] : undefined)
isHeadFallingBackToGet =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we strip out the body in case ANY handles the HEAD?

const handler = handlers[requestMethod] ?? handlers['ANY']
// Per RFC 9110 §9.3.2, HEAD should return the same header fields as GET.
// Fall back to the GET handler when no explicit HEAD or ANY handler is defined.
const handler =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if both GET and ANY exist, HEAD uses ANY, not GET.

could be solved via:

const handler =
  requestMethod === 'HEAD'
    ? handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY']
    : handlers[requestMethod] ?? handlers['ANY']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a separate E2E test route for this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Handler selection is now handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY'], so GET always wins over ANY for HEAD requests.

Added /api/get-and-any with separate GET and ANY handlers, and a test that sends HEAD /api/get-and-any and asserts x-handler: GET.

// Strip body from HEAD responses that fell back to the GET handler.
// RFC 9110 §9.3.2: HEAD must send the same headers as GET, but with no body.
if (isHeadFallingBackToGet && ctx.response instanceof Response) {
return new Response(null, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A GET-only handler returning redirect({ to: '/login' }) can become a 307 without Location, because handleRedirectResponse() no longer sees isRedirect(response).

either resolve redirects before stripping the body, or strip the body after handleRedirectResponse().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. handleRedirectResponse now runs before the body-stripping step, so Location survives.

Added /api/head-redirect-fallback (GET handler returns redirect({ to: '/api/head-fallback', statusCode: 307 })), with a test that sends HEAD and checks the browser can follow the redirect to a 200. If Location were dropped, the browser would stall on the 307 and the assertion would fail.

Also adopted your new Response(null, resolved) suggestion from the other thread. Cleaner.

@nx-cloud
Copy link
Copy Markdown
Contributor

nx-cloud Bot commented May 2, 2026

View your CI Pipeline Execution ↗ for commit 0d3549e

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 8m 5s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 43s View ↗

☁️ Nx Cloud last updated this comment at 2026-05-03 09:34:53 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 2, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7325

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7325

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@7325

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7325

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7325

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7325

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7325

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7325

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7325

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7325

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@7325

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7325

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7325

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7325

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7325

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7325

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7325

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7325

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7325

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7325

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7325

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7325

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7325

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7325

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7325

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7325

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7325

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7325

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7325

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7325

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7325

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7325

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7325

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7325

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7325

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7325

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7325

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7325

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7325

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7325

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7325

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7325

commit: 0d3549e

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 2, 2026

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing Zelys-DFKH:fix/head-request-fallback-to-get-handler (0d3549e) with main (3526194)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

- HEAD priority is now HEAD -> GET -> ANY (was HEAD -> ANY -> GET)
- body stripping covers ANY fallback, not just GET fallback
- resolve redirects before stripping so Location header is preserved
- copy statusText to the stripped response
- add E2E tests for all three HEAD fallback scenarios
@Zelys-DFKH Zelys-DFKH force-pushed the fix/head-request-fallback-to-get-handler branch from 0f65fbf to 37da897 Compare May 3, 2026 00:15
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/start-server-core/src/createStartHandler.ts (1)

944-951: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

instanceof Response guard makes handleRedirectResponse a no-op and silently skips body-stripping for TanStack redirect objects.

Two interconnected effects stem from the instanceof Response guard:

  1. Dead call: redirect() returns a new Redirect object — a plain object, never a native Response. Therefore isRedirect(nativeResponse) is always false for values that satisfy instanceof Response, and handleRedirectResponse returns immediately without doing any redirect resolution in every case this branch is entered. resolved === ctx.response unconditionally.

  2. Missing body-strip for TanStack redirects: When the HEAD-fallback GET/ANY handler returns a TanStack redirect({to: '/login'}), executeMiddleware catches it via isSpecialResponse and stores it in ctx.response as a Redirect object. Because Redirect is not instanceof Response, the body-stripping block at line 944 is skipped entirely and ctx.response falls through to return ctx.response at line 953. The outer handleRedirectResponse at line 774 does correctly resolve it and preserves the Location header, but the resolved response's body is not stripped — a technical RFC 9110 §9.3.2 violation ("server MUST NOT send a message body in the response to HEAD"). In practice redirect bodies are empty (e.g. Response.redirect() produces a null body), so the observable impact is low.

The fix is to drop the instanceof Response guard and let handleRedirectResponse see the TanStack redirect directly:

🛠️ Proposed fix
- if (isHeadFallback && ctx.response instanceof Response) {
-   const resolved = await handleRedirectResponse(ctx.response, request, getRouter)
+ if (isHeadFallback) {
+   const resolved = await handleRedirectResponse(
+     ctx.response as Response,
+     request,
+     getRouter,
+   )
    return new Response(null, {
      status: resolved.status,
      statusText: resolved.statusText,
      headers: resolved.headers,
    })
  }

With this change:

  • For native Response values → handleRedirectResponse early-returns unchanged, body stripped as before. ✓
  • For TanStack redirect() objects → handleRedirectResponse resolves to a proper HTTP Response (preserving Location), then the body is stripped. ✓
  • For undefined → impossible; middleware always sets ctx.response or throws.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/start-server-core/src/createStartHandler.ts` around lines 944 - 951,
The branch that checks "instanceof Response" prevents handleRedirectResponse
from processing TanStack Redirect objects and skipping body-stripping; remove
the instanceof guard so that when isHeadFallback is true you always call await
handleRedirectResponse(ctx.response, request, getRouter) and use its resolved
Response to construct the new Response (preserving resolved.status, statusText,
headers) so redirect objects returned by middleware are resolved and their
bodies get stripped; update the block around isHeadFallback to reference
ctx.response and call handleRedirectResponse unconditionally (retain the same
usage of request and getRouter).
🧹 Nitpick comments (1)
e2e/react-start/server-routes/tests/server-routes.spec.ts (1)

20-71: ⚡ Quick win

The three core HEAD fallback scenarios are well-covered; consider adding a redirect + HEAD test.

Tests 1–3 validate GET fallback, ANY fallback, and GET-over-ANY preference. What's absent is a test for a GET handler that returns a TanStack redirect() (the scenario flagged in the past review comment and the code path guarded by handleRedirectResponse at line 945 of createStartHandler.ts). Due to the instanceof Response bug noted above, a TanStack redirect returned by the HEAD-fallback handler currently skips the body-stripping block entirely. A test exercising that path would make the gap immediately visible.

Suggestion: add a test fixture route (e.g., /api/head-fallback-redirect) whose GET handler returns redirect({ to: '/api/head-fallback', statusCode: 307 }), then assert that HEAD receives a 307 with a Location header and an empty body.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/react-start/server-routes/tests/server-routes.spec.ts` around lines 20 -
71, Add a new e2e test and fixture route to exercise the GET-redirect + HEAD
fallback path: create an API route (e.g., handler name or path
/api/head-fallback-redirect) whose GET handler returns TanStack's redirect({ to:
'/api/head-fallback', statusCode: 307 }), then add a Playwright test similar to
the other HEAD-fallback tests that does fetch('/api/head-fallback-redirect', {
method: 'HEAD' }) and asserts status === 307, that the Location header points to
/api/head-fallback and that the response body is an empty string; this will
exercise the handleRedirectResponse path in createStartHandler.ts and reveal the
instanceof Response issue noted in the review.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/start-server-core/src/createStartHandler.ts`:
- Around line 944-951: The branch that checks "instanceof Response" prevents
handleRedirectResponse from processing TanStack Redirect objects and skipping
body-stripping; remove the instanceof guard so that when isHeadFallback is true
you always call await handleRedirectResponse(ctx.response, request, getRouter)
and use its resolved Response to construct the new Response (preserving
resolved.status, statusText, headers) so redirect objects returned by middleware
are resolved and their bodies get stripped; update the block around
isHeadFallback to reference ctx.response and call handleRedirectResponse
unconditionally (retain the same usage of request and getRouter).

---

Nitpick comments:
In `@e2e/react-start/server-routes/tests/server-routes.spec.ts`:
- Around line 20-71: Add a new e2e test and fixture route to exercise the
GET-redirect + HEAD fallback path: create an API route (e.g., handler name or
path /api/head-fallback-redirect) whose GET handler returns TanStack's
redirect({ to: '/api/head-fallback', statusCode: 307 }), then add a Playwright
test similar to the other HEAD-fallback tests that does
fetch('/api/head-fallback-redirect', { method: 'HEAD' }) and asserts status ===
307, that the Location header points to /api/head-fallback and that the response
body is an empty string; this will exercise the handleRedirectResponse path in
createStartHandler.ts and reveal the instanceof Response issue noted in the
review.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba0be7a3-20de-49ae-8923-8f92f12870af

📥 Commits

Reviewing files that changed from the base of the PR and between 0f65fbf and 37da897.

📒 Files selected for processing (6)
  • .changeset/fix-head-request-server-route-fallback.md
  • e2e/react-start/server-routes/src/routeTree.gen.ts
  • e2e/react-start/server-routes/src/routes/api/get-and-any.ts
  • e2e/react-start/server-routes/src/routes/api/head-fallback.ts
  • e2e/react-start/server-routes/tests/server-routes.spec.ts
  • packages/start-server-core/src/createStartHandler.ts
✅ Files skipped from review due to trivial changes (3)
  • e2e/react-start/server-routes/src/routes/api/get-and-any.ts
  • .changeset/fix-head-request-server-route-fallback.md
  • e2e/react-start/server-routes/src/routeTree.gen.ts

Zelys-DFKH and others added 3 commits May 2, 2026 19:48
…irect+HEAD E2E test

The `instanceof Response` check before the HEAD body-stripping block was
redundant — `redirect()` returns `new Response(...)` and IS instanceof
Response, and `ctx.response` is always a Response at that point in the
middleware pipeline. Replacing it with an `as Response` cast eliminates
dead branch analysis noise.

Also adds a fourth E2E test: HEAD on a route whose GET handler returns a
`redirect()` must preserve the Location header so the browser can follow it.
The test verifies this by asserting the final status is 200 (meaning the
307 redirect was followed). If the Location header were stripped before the
redirect resolved, the browser would stall on the 307 and the assertion
would fail.
…iewer suggestion

Adopts the tighter form suggested by @schiller-manuel: passing the resolved
Response directly as the ResponseInit arg copies status, statusText, and
headers in one shot instead of spreading them manually.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/start-server-core/src/createStartHandler.ts`:
- Around line 944-950: The current isHeadFallback branch forces ctx.response
through handleRedirectResponse and always wraps its result into new
Response(...), which converts an undefined/absent response into a synthetic 200
OK; change the logic in the isHeadFallback block (the call to
handleRedirectResponse with ctx.response, request, getRouter) to check the
returned value (from handleRedirectResponse) and if it is undefined/null do not
create a new Response—propagate undefined (or return early) instead; update
types so the variable (e.g., resolved) is typed as ResponseInit | undefined and
the code only calls new Response when resolved is a real ResponseInit object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 25d3f5d5-4beb-4193-b5dd-cd09db55d827

📥 Commits

Reviewing files that changed from the base of the PR and between d9d24f0 and 0d3549e.

📒 Files selected for processing (2)
  • .changeset/fix-head-request-server-route-fallback.md
  • packages/start-server-core/src/createStartHandler.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-head-request-server-route-fallback.md

Comment on lines +944 to +950
if (isHeadFallback) {
const resolved = await handleRedirectResponse(
ctx.response as Response,
request,
getRouter,
)
return new Response(null, resolved)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't synthesize a 200 OK when no response was produced.

Line 946 forces ctx.response to Response, but if a fallback GET/ANY chain exits without returning or calling next, handleRedirectResponse passes undefined through and Line 950 turns that into a fresh 200 OK. That hides the original handler bug and makes a broken HEAD route look successful.

Suggested fix
   if (isHeadFallback) {
+    if (!ctx.response) {
+      throwRouteHandlerError()
+    }
+
     const resolved = await handleRedirectResponse(
-      ctx.response as Response,
+      ctx.response,
       request,
       getRouter,
     )
     return new Response(null, resolved)
   }

As per coding guidelines, **/*.{ts,tsx}: Use TypeScript strict mode with extensive type safety.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/start-server-core/src/createStartHandler.ts` around lines 944 - 950,
The current isHeadFallback branch forces ctx.response through
handleRedirectResponse and always wraps its result into new Response(...), which
converts an undefined/absent response into a synthetic 200 OK; change the logic
in the isHeadFallback block (the call to handleRedirectResponse with
ctx.response, request, getRouter) to check the returned value (from
handleRedirectResponse) and if it is undefined/null do not create a new
Response—propagate undefined (or return early) instead; update types so the
variable (e.g., resolved) is typed as ResponseInit | undefined and the code only
calls new Response when resolved is a real ResponseInit object.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sense. please incorporate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@schiller-manuel, here is the fix incorporated:

  • Guard against ctx.response being undefined/falsy before passing it to handleRedirectResponse
  • Remove the unsafe as Response cast

Why this matters:
Without the guard, if a fallback GET/ANY handler exits without returning a Response (or calling next), ctx.response is undefined. handleRedirectResponse passes undefined straight through, and new Response(null, undefined) quietly synthesizes a 200 OK — masking the bug entirely. Calling throwRouteHandlerError() instead surfaces the developer mistake with the same clear message used everywhere else in the file.


Failed to handle agent chat message. Please try again.

Copy link
Copy Markdown
Contributor

@nx-cloud nx-cloud Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud has identified a possible root cause for your failed CI:

We've identified the failure in tanstack-solid-start-e2e-serialization-adapters:test:e2e as an environment issue unrelated to this PR — the error originates in a pre-built e2e-utils dist artifact (localDummyServer.js) that is missing the users export, and none of our changes touch that package or the solid-start serialization suite. This appears to be a stale CI build artifact that needs to be rebuilt in the environment.

No code changes were suggested for this issue.

You can trigger a rerun by pushing an empty commit:

git commit --allow-empty -m "chore: trigger rerun"
git push

Nx Cloud View detailed reasoning on Nx Cloud ↗


🎓 Learn more about Self-Healing CI on nx.dev

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HEAD requests to server routes return text/html instead of handler's content-type

2 participants