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 does Route.useLoaderData().map(...).
node gateway.mjs
- Open the route through
http://localhost:8080. The component throws TypeError: ... .map is not a function; Route.useLoaderData() is a Response instance.
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:
Which project does this relate to?
Start
Describe the bug
When a server-function response carries neither
x-tss-serializednorx-tss-raw, is 2xx, and is notapplication/json,getResponsereturns theResponseobject itself as the server function's return value. The loader/caller then receives aResponsewhere it expected data, and the failure surfaces far from its cause — for us, asTypeError: n.map is not a functioninside a component, with nothing pointing at the network.packages/start-client-core/src/client-rpc/serverFnFetcher.ts(currentmain, L334-340):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):So a handler that returns a
Responsegetsx-tss-raw(and the client returns it early at L246), and everything else getsx-tss-serialized. The only untagged legitimate path is theisRedirectbranch at L173. Every other untagged response reachinggetResponsecame 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-originfetch(), so they can never satisfy an interstitial. The challenge body names the intercepted path directly:Confirming the response never reaches the Worker (no
x-tss-*headers):Depending on what the intermediary returns, one call site produced three different symptoms:
text/htmlErrorwhose message is the entire HTML bodytext/htmlResponsen.map is not a functionin a componentapplication/jsonundefined(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: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 aResponseand the component throws.map is not a function.Steps to Reproduce the Bug
loader: () => someServerFn()where the server function returns an array, and a component that doesRoute.useLoaderData().map(...).node gateway.mjshttp://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-rawalready marks every deliberate rawResponse, throwing here does not affect the "return aResponsefrom a server function" feature. TheisRedirectbranch 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
.mapfailure 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-start1.168.51,@tanstack/react-router1.170.34 (resolvedstart-client-core1.170.29,start-server-core1.169.33,router-core1.171.28), vite 8.2.2, react 19.3.0, node 24.20.0. Deployed on Cloudflare Workers. Code quoted above is from currentmain.Additional context
Related but distinct:
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.x-tsr-serverFnget unhandled 500s. A cause of untagged responses; this issue is about how the client handles them.