Start: untagged 2xx non-JSON server-function response resolves as a raw `Response` instead of rejecting
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
Which project does this relate to?
Start
Describe the bug
When a server-function response carries neither x-tss-serialized nor x-tss-raw, is 2xx, and is not application/json, getResponse returns the Response object itself as the server function's return value. The loader/caller then receives a Response where it expected data, and the failure surfaces far from its cause — for us, as TypeError: n.map is not a function inside a component, with nothing pointing at the network.
packages/start-client-core/src/client-rpc/serverFnFetcher.ts (current main, L334-340):
// Otherwise, if it's not OK, throw the content
if (!response.ok) {
throw new Error(await response.text())
}
// Or return the response itself
return response
The reason this is reachable at all is that the server tags every response it produces, so an untagged response did not come from the server-functions handler. In packages/start-server-core/src/server-functions-handler.ts (L172-180):
if (unwrapped instanceof Response) {
if (isRedirect(unwrapped)) {
return unwrapped
}
unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')
return unwrapped
}
return serializeResult(res) // sets X_TSS_SERIALIZED
So a handler that returns a Response gets x-tss-raw (and the client returns it early at L246), and everything else gets x-tss-serialized. The only untagged legitimate path is the isRedirect branch at L173. Every other untagged response reaching getResponse came from something between the browser and the server-functions handler.
The asymmetry is what makes this hard to defend as intentional: the same untagged non-JSON response throws when it is 500 (L335) but is returned silently when it is 200.
What triggers it in practice
An intermediary that answers /_serverFn/* itself. In our case Cloudflare's bot management started serving managed challenges and 403s to server-function calls on a production site. Server-function calls are same-origin fetch(), so they can never satisfy an interstitial. The challenge body names the intercepted path directly:
cType: 'managed', cZone: '<our domain>',
cUPMDTk: "/_serverFn/c29844191716252ad5d6ebe1cf8a3d5443549155d94a720b2239f3e2d46e76c0?__cf_chl_tk=..."
Confirming the response never reaches the Worker (no x-tss-* headers):
$ curl -D- https://<our domain>/_serverFn/deadbeef -H 'x-tsr-serverFn: true'
HTTP/2 403
content-type: text/plain;charset=UTF-8
server: cloudflare
Depending on what the intermediary returns, one call site produced three different symptoms:
| Intermediary response | Path taken | Symptom |
|---|---|---|
non-2xx, text/html |
L335 throws | Error whose message is the entire HTML body |
2xx, text/html |
L340 returns Response |
n.map is not a function in a component |
non-2xx, application/json |
L322-331 | resolves undefined (this is #8280) |
The 2xx row is the one this issue is about. It is the worst of the three because it corrupts data silently instead of failing.
This is not Cloudflare-specific — any reverse proxy, gateway, captive portal, or ISP interception page does the same thing. #8280 reports hitting it via ISP-level blocking.
Complete minimal reproducer
Any gateway that answers /_serverFn/* with a 2xx non-JSON body reproduces it. Minimal version, in front of a built Start app on 3000:
// gateway.mjs — node gateway.mjs, then open http://localhost:8080
import { createServer } from 'node:http'
createServer(async (req, res) => {
if (req.url.startsWith('/_serverFn/')) {
res.writeHead(200, { 'content-type': 'text/html' })
res.end('<!DOCTYPE html><html><body>interstitial</body></html>')
return
}
const upstream = await fetch(`http://localhost:3000${req.url}`, {
method: req.method,
headers: req.headers,
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
duplex: 'half',
})
res.writeHead(upstream.status, Object.fromEntries(upstream.headers))
res.end(Buffer.from(await upstream.arrayBuffer()))
}).listen(8080)
Give the app a route whose loader calls a server function returning an array, and render data.map(...). Through :3000 it renders; through :8080 the loader data is a Response and the component throws .map is not a function.
Steps to Reproduce the Bug
- Build and start a Start app on port 3000.
- Add a route with
loader: () => someServerFn()where the server function returns an array, and a component that doesRoute.useLoaderData().map(...). node gateway.mjs- Open the route through
http://localhost:8080. The component throwsTypeError: ... .map is not a function;Route.useLoaderData()is aResponseinstance.
Expected behavior
An untagged, non-JSON response is not a server-function response, so it should reject rather than be handed back as data — matching the existing treatment of the untagged non-JSON non-2xx case, and of the other impossible states in the same function (the two invariant() calls at L251-259 and L306-312).
Because x-tss-raw already marks every deliberate raw Response, throwing here does not affect the "return a Response from a server function" feature. The isRedirect branch at server-functions-handler L173 looks like the one path that would need to stay allowed.
An error that names the cause would also save a lot of debugging time versus a .map failure in a component — something like "server function response was not produced by TanStack Start (no x-tss-serialized/x-tss-raw header); got 200 text/html — a proxy or gateway may have intercepted the request".
Platform
@tanstack/react-start 1.168.51, @tanstack/react-router 1.170.34 (resolved start-client-core 1.170.29, start-server-core 1.169.33, router-core 1.171.28), vite 8.2.2, react 19.3.0, node 24.20.0. Deployed on Cloudflare Workers. Code quoted above is from current main.
Additional context
Related but distinct:
- #8280 — untagged non-OK
application/jsonresolvesundefined. Same function, adjacent branch. Its proposed fix (if (!response.ok) throwbefore the JSON return) leaves L340 untouched, so it would not fix this: our failing case is a 200. Conversely, rejecting all untagged responses would cover both. - #8237 — requests missing
x-tsr-serverFnget unhandled 500s. A cause of untagged responses; this issue is about how the client handles them.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/start-client-core/src/client-rpc/serverFnFetcher.ts at getResponse, especially the untagged 2xx path around L334-340, and compare it with the x-tss-raw handling and server tags in packages/start-server-core/src/server-functions-handler.ts. Run the gateway.mjs reproducer and verify that an untagged 2xx non-JSON response rejects, while tagged raw responses remain supported.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend-api-design
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100