TanStack / TanStack/router

Start: untagged 2xx non-JSON server-function response resolves as a raw `Response` instead of rejecting

Open Beginner friendly
#8,333 0 comments 0 reactions 0 assignees View on GitHub

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
  1. Build and start a Start app on port 3000.
  2. Add a route with loader: () => someServerFn() where the server function returns an array, and a component that does Route.useLoaderData().map(...).
  3. node gateway.mjs
  4. 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:

  • #8280 — untagged non-OK application/json resolves undefined. Same function, adjacent branch. Its proposed fix (if (!response.ok) throw before 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-serverFn get unhandled 500s. A cause of untagged responses; this issue is about how the client handles them.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.