Skip to content

If you pass createServerOnlyFn(...) directly as an argument to createServerFn().handler(), it will generate a broken server function. #8446

Description

@imaimai17468

Which project does this relate to?

Start

Describe the bug

If you pass a function call that the compiler is supposed to transform directly into the argument of createServerFn().handler(), the build will succeed, but it will generate a broken server function.

  1. createServerOnlyFn
TypeError: Cannot create property 'method' on string 'da38dc3f0228fb3e…'
    at fun.handler (dist/server/server.js:237:23)
    at loadEntries (dist/server/server.js:1419:52)

As a result, it will return 500.
Since it is thrown during module evaluation inside loadEntries, it fails before routing, causing not only that route but the entire application to crash.

  1. createIsomorphicFn
// Broken
.handler(createServerOnlyFn(impl))
.handler(createIsomorphicFn().server(impl).client(other))

// Works (same JavaScript)
const wrapped = createServerOnlyFn(impl)
.handler(wrapped)

No exception is thrown, but the server function returns undefined.
If you bind the call to a variable first and pass the identifier, both will be generated correctly.

Generated Code

Caller chunk (dist/server/assets/router-*.js):

// hoisted            → Correct
createServerFn({ method: "GET" }).handler(createSsrRpc("da38dc3f0228fb3e…"))
// inline-server-only → ID string remains
createServerFn({ method: "GET" }).handler("da38dc3f0228fb3e…")
// inline-isomorphic  → Implementation overwrites the stub
createServerFn({ method: "GET" }).handler(readOnServer)

The client bundle has the same broken slot, and in both cases, a local function is included instead of an RPC.

// inline-server-only
.handler(() => { throw Error("createServerOnlyFn() functions can only be called on the server!") })
// inline-isomorphic
.handler(() => ({ ranOn: "client" }))

Cause

Candidates are collected in a single pass, and each holds a Babel path. The handlers then run in a fixed order defined by BuiltInKindHandlerOrder in packages/start-plugin-core/src/start-compiler/compiler.ts:

['ServerFn', 'Middleware', 'IsomorphicFn', 'ServerOnlyFn', 'ClientOnlyFn']

ServerFn runs first, and handleCreateServerFn.ts:445 replaces the first argument of .handler().

handlerFnPath.replaceWith(rpcStub) // rpcStub is createSsrRpc("<id>")

Subsequent handlers operate through the paths captured before this replacement. From there, they diverge into two cases:

  • handleEnvOnly.ts reads path.node.arguments[0]. Since the node pointed to by the path is now createSsrRpc("<id>"), what it retrieves is the string literal of the ID. This leaves .handler("<id>"), and the handler of createServerFn attempts to assign extractedFn.method to that string.
  • handleCreateIsomorphicFn.ts retains envCallInfo.firstArgPath?.node captured from the original tree, so path.replaceWith(innerFn) overwrites the stub with the implementation itself. The RPC wiring disappears, and no exception is thrown.

The root cause is the same in both cases: subsequent handlers are touching the node replaced by ServerFn through stale paths.

Binding it to a variable avoids this because the node for createServerOnlyFn(...) moves to the initializer of the variable declaration, and none of the handlers replace it there.

Complete minimal reproducer

https://github.com/imaimai17468/tanstack-start-handler-call-expression-repro

Steps to Reproduce the Bug

git clone https://github.com/imaimai17468/tanstack-start-handler-call-expression-repro
cd tanstack-start-handler-call-expression-repro
npm install
npm run repro

This builds three apps, displays the generated .handler(...) in dist/server, and starts vite preview so you can make a request to /.
The results are as follows:

app Shape of .handler() GET /
apps/hoisted Passes const wrapped = createServerOnlyFn(impl) 200, {"ranOn":"server"}
apps/inline-server-only Writes createServerOnlyFn(impl) directly 500, TypeError as shown above
apps/inline-isomorphic Writes createIsomorphicFn()... directly 200, loader data is undefined

They are split into three apps because inline-server-only throws during module evaluation and crashes the entire app, making it impossible to showcase 200 and 500 within the same app.

vite dev fails in the same way (the only difference is that the IDs become base64 strings instead of hashes).

Expected behavior

Since .handler(createServerOnlyFn(impl)) and const f = createServerOnlyFn(impl); .handler(f) represent the same JavaScript, they should compile to the same server function.

If the policy is to not support one of these patterns, the build should fail instead. Currently, it generates either a server function that throws on every request or one that silently returns undefined.

Screenshots or Videos

No response

Platform

  • Router / Start Version: @tanstack/react-start 1.168.54, @tanstack/start-plugin-core 1.171.44, @tanstack/react-router 1.170.36
  • OS: macOS 26.6.2 arm64, Node 26.7.0, npm 11.19.0
  • Browser: Not used
  • Browser Version: N/A
  • Bundler: vite
  • Bundler Version: 8.3.0

Additional context

No response

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions