Skip to content

Commit fe11405

Browse files
response reconciliation
fixes #5107 fixes #5464 fixes #5407
1 parent 6f1daf5 commit fe11405

87 files changed

Lines changed: 6967 additions & 905 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/start-response-state.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/react-start': patch
3+
'@tanstack/solid-start': patch
4+
'@tanstack/vue-start': patch
5+
'@tanstack/start-client-core': patch
6+
'@tanstack/start-plugin-core': patch
7+
'@tanstack/start-server-core': patch
8+
---
9+
10+
Improve Start response reconciliation, session cookie handling, and server-entry error recovery. Server entries now recover Start error responses with `handleStartError`, response helpers reconcile across server routes, SSR, server functions, redirects, and error responses, and serialized server-function transport headers are protected from helper overrides.

‎docs/start/framework/react/guide/server-entry-point.md‎

Lines changed: 143 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,60 @@ TanStack Start exposes a wrapper to make creation type-safe. This is done in the
2727
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
2828

2929
export default createServerEntry({
30-
fetch(request) {
31-
return handler.fetch(request)
30+
fetch(request, opts) {
31+
return handler.fetch(request, opts)
3232
},
3333
})
3434
```
3535

3636
Whether we are statically generating our app or serving it dynamically, the `server.ts` file is the entry point for doing all SSR-related work as well as for handling server routes and server function requests.
3737

38+
## Configuring a Custom Server Entry
39+
40+
By default, Start uses the generated server entry. If you provide your own server entry at a non-standard path, configure it in your bundler plugin. The `server.entry` path is resolved relative to `srcDirectory`, which defaults to `src`.
41+
42+
<!-- ::start:tabs variant="bundler" -->
43+
44+
# Vite
45+
46+
```ts title="vite.config.ts"
47+
import { defineConfig } from 'vite'
48+
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
49+
import viteReact from '@vitejs/plugin-react'
50+
51+
export default defineConfig({
52+
plugins: [
53+
tanstackStart({
54+
server: {
55+
entry: './server-entry.ts',
56+
},
57+
}),
58+
viteReact(),
59+
],
60+
})
61+
```
62+
63+
# Rsbuild
64+
65+
```ts title="rsbuild.config.ts"
66+
import { defineConfig } from '@rsbuild/core'
67+
import { pluginReact } from '@rsbuild/plugin-react'
68+
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'
69+
70+
export default defineConfig({
71+
plugins: [
72+
pluginReact(),
73+
tanstackStart({
74+
server: {
75+
entry: './server-entry.ts',
76+
},
77+
}),
78+
],
79+
})
80+
```
81+
82+
<!-- ::end:tabs -->
83+
3884
## Custom Server Handlers
3985

4086
You can create custom server handlers to modify how your application is rendered:
@@ -53,13 +99,101 @@ const customHandler = defineHandlerCallback((ctx) => {
5399
return defaultStreamHandler(ctx)
54100
})
55101

56-
const fetch = createStartHandler(customHandler)
102+
const startHandler = createStartHandler(customHandler)
57103

58104
export default createServerEntry({
59-
fetch,
105+
fetch(request, opts) {
106+
return startHandler(request, opts)
107+
},
60108
})
61109
```
62110

111+
## Error Handling
112+
113+
The generated server entry catches uncaught errors and calls `handleStartError(error)`. Response helpers such as `setResponseStatus`, `setResponseHeader`, and `setCookie` are reconciled onto the error response.
114+
115+
This default behavior means Start owns the uncaught-error boundary. If a loader, server route, or middleware throws after setting response state, Start still returns a valid `Response` with the intended status, headers, and cookies. Server function RPC errors are handled before this top-level boundary so the client protocol response stays intact.
116+
117+
For example, this should return `401`, not a generic `500`:
118+
119+
```tsx
120+
import { createMiddleware } from '@tanstack/react-start'
121+
import {
122+
setResponseHeader,
123+
setResponseStatus,
124+
} from '@tanstack/react-start/server'
125+
126+
const authMiddleware = createMiddleware().server(() => {
127+
setResponseStatus(401, 'Unauthorized')
128+
setResponseHeader('www-authenticate', 'Bearer')
129+
throw new Error('Unauthorized')
130+
})
131+
```
132+
133+
This conversion does more than create a plain `new Response(...)`. It preserves response state that Start captured during the request:
134+
135+
- Status and status text from `setResponseStatus`
136+
- Headers from `setResponseHeader` and `setResponseHeaders`
137+
- Cookies from `setCookie`, including multiple `Set-Cookie` headers
138+
- HTTP-style error metadata such as `error.status`, `error.statusText`, `error.headers`, and `error.cause.headers`
139+
- Protocol headers used by server function responses
140+
- Null-body response rules for `HEAD`, `204`, `205`, and `304`
141+
142+
`createStartHandler(...)` rethrows uncaught errors after associating them with the current Start request. If you write a custom server entry and need custom top-level error handling, catch around the returned fetch handler and call `handleStartError(error)` yourself:
143+
144+
```tsx
145+
// src/server.ts
146+
import {
147+
createStartHandler,
148+
defaultStreamHandler,
149+
handleStartError,
150+
} from '@tanstack/react-start/server'
151+
import { createServerEntry } from '@tanstack/react-start/server-entry'
152+
153+
const startHandler = createStartHandler(defaultStreamHandler)
154+
155+
export default createServerEntry({
156+
async fetch(request, opts) {
157+
try {
158+
return await startHandler(request, opts)
159+
} catch (error) {
160+
// Add logging, reporting, or custom branching here.
161+
return handleStartError(error)
162+
}
163+
},
164+
})
165+
```
166+
167+
Use `handleStartError(error)` when you want custom top-level logic, such as logging or reporting, but still want Start to produce the same error response as the generated server entry.
168+
169+
`handleStartError(error)` is useful because the error is caught outside the active Start handler. It recovers the Start request state associated with non-primitive thrown errors and reconciles that state onto the error response. Primitive throws cannot carry that association.
170+
171+
If you do not want to preserve response helpers that ran before the error, do not call `handleStartError`. Return your own response instead:
172+
173+
```tsx
174+
// src/server.ts
175+
import {
176+
createStartHandler,
177+
defaultStreamHandler,
178+
} from '@tanstack/react-start/server'
179+
import { createServerEntry } from '@tanstack/react-start/server-entry'
180+
181+
const startHandler = createStartHandler(defaultStreamHandler)
182+
183+
export default createServerEntry({
184+
async fetch(request, opts) {
185+
try {
186+
return await startHandler(request, opts)
187+
} catch (error) {
188+
console.error(error)
189+
return new Response('Internal Server Error', { status: 500 })
190+
}
191+
},
192+
})
193+
```
194+
195+
Use this pattern when your server entry or deployment platform should fully own the error response and should ignore any status, headers, or cookies set earlier in the Start request.
196+
63197
## Request context
64198

65199
When your server needs to pass additional, typed data into request handlers (for example, authenticated user info, a database connection, or per-request flags), register a request context type via TypeScript module augmentation. The registered context is delivered as the second argument to the server `fetch` handler and is available throughout the server-side middleware chain — including global middleware, request/function middleware, server routes, server functions, and the router itself.
@@ -83,8 +217,11 @@ declare module '@tanstack/react-router' {
83217
}
84218

85219
export default createServerEntry({
86-
async fetch(request) {
87-
return handler.fetch(request, { context: { hello: 'world', foo: 123 } })
220+
fetch(request, opts) {
221+
return handler.fetch(request, {
222+
...opts,
223+
context: { hello: 'world', foo: 123 },
224+
})
88225
},
89226
})
90227
```
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
node_modules
2+
package-lock.json
3+
yarn.lock
4+
5+
.DS_Store
6+
.cache
7+
.tanstack
8+
.env
9+
.vercel
10+
.output
11+
/build/
12+
/api/
13+
/server/build
14+
/public/build# Sentry Config File
15+
.env.sentry-build-plugin
16+
/test-results/
17+
/playwright-report/
18+
/blob-report/
19+
/playwright/.cache/
20+
/dist/
21+
/dist-*/
22+
/port-*.txt
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
**/build
2+
**/public
3+
pnpm-lock.yaml
4+
routeTree.gen.ts
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
"name": "tanstack-react-start-e2e-response-reconciliation",
3+
"private": true,
4+
"sideEffects": false,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "pnpm dev:vite --port 3000",
8+
"dev:e2e": "pnpm dev:vite",
9+
"dev:vite": "vite dev",
10+
"dev:rsbuild": "rsbuild dev",
11+
"build": "pnpm build:vite",
12+
"build:vite": "vite build && tsc --noEmit",
13+
"build:rsbuild": "rsbuild build && tsc --noEmit",
14+
"preview": "vite preview",
15+
"start": "node server.js",
16+
"test:e2e:local": "pnpm build && playwright test --project=chromium"
17+
},
18+
"dependencies": {
19+
"@tanstack/react-router": "workspace:*",
20+
"@tanstack/react-start": "workspace:*",
21+
"react": "^19.2.3",
22+
"react-dom": "^19.2.3"
23+
},
24+
"devDependencies": {
25+
"@playwright/test": "^1.57.0",
26+
"@rsbuild/core": "^2.0.8",
27+
"@rsbuild/plugin-react": "^2.0.0",
28+
"@tanstack/router-e2e-utils": "workspace:^",
29+
"@types/node": "25.0.9",
30+
"@types/react": "^19.2.8",
31+
"@types/react-dom": "^19.2.3",
32+
"@vitejs/plugin-react": "^6.0.1",
33+
"srvx": "^0.11.9",
34+
"typescript": "^6.0.2",
35+
"vite": "^8.0.14"
36+
},
37+
"nx": {
38+
"targets": {
39+
"test:e2e": {
40+
"parallelism": false
41+
},
42+
"test:e2e--vite-ssr": {
43+
"parallelism": false
44+
},
45+
"test:e2e--vite-ssr--custom-entry": {
46+
"parallelism": false
47+
},
48+
"test:e2e--rsbuild-ssr": {
49+
"parallelism": false
50+
},
51+
"test:e2e--rsbuild-ssr--custom-entry": {
52+
"parallelism": false
53+
}
54+
},
55+
"metadata": {
56+
"playwrightModes": [
57+
{
58+
"toolchain": "vite",
59+
"mode": "ssr"
60+
},
61+
{
62+
"toolchain": "vite",
63+
"mode": "ssr",
64+
"name": "custom-entry",
65+
"env": {
66+
"TSS_E2E_SERVER_ENTRY": "server-entry"
67+
}
68+
},
69+
{
70+
"toolchain": "rsbuild",
71+
"mode": "ssr"
72+
},
73+
{
74+
"toolchain": "rsbuild",
75+
"mode": "ssr",
76+
"name": "custom-entry",
77+
"env": {
78+
"TSS_E2E_SERVER_ENTRY": "server-entry"
79+
}
80+
}
81+
]
82+
}
83+
}
84+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
import { getTestServerPort } from '@tanstack/router-e2e-utils'
3+
import packageJson from './package.json' with { type: 'json' }
4+
5+
const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name
6+
const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
7+
const distDir = process.env.E2E_DIST_DIR ?? 'dist'
8+
const serverEntry = process.env.TSS_E2E_SERVER_ENTRY ?? ''
9+
const serverEntryMode = serverEntry ? 'custom' : 'default'
10+
11+
export const PORT = await getTestServerPort(`${e2ePortKey}-${serverEntryMode}`)
12+
const baseURL = `http://localhost:${PORT}`
13+
14+
export default defineConfig({
15+
testDir: './tests',
16+
workers: 1,
17+
reporter: [['line']],
18+
use: {
19+
baseURL,
20+
},
21+
webServer: {
22+
command: `pnpm start`,
23+
url: baseURL,
24+
reuseExistingServer: !process.env.CI,
25+
stdout: 'pipe',
26+
env: {
27+
PORT: String(PORT),
28+
VITE_SERVER_PORT: String(PORT),
29+
E2E_DIST_DIR: distDir,
30+
E2E_TOOLCHAIN: toolchain,
31+
E2E_PORT_KEY: e2ePortKey,
32+
TSS_E2E_SERVER_ENTRY: serverEntry,
33+
},
34+
},
35+
projects: [
36+
{
37+
name: 'chromium',
38+
use: { ...devices['Desktop Chrome'] },
39+
},
40+
],
41+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineConfig } from '@rsbuild/core'
2+
import { pluginReact } from '@rsbuild/plugin-react'
3+
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'
4+
5+
const serverEntry = process.env.TSS_E2E_SERVER_ENTRY
6+
const outDir = process.env.E2E_DIST_DIR ?? 'dist'
7+
8+
export default defineConfig({
9+
plugins: [
10+
pluginReact(),
11+
tanstackStart({
12+
server: serverEntry ? { entry: serverEntry } : undefined,
13+
}),
14+
],
15+
output: {
16+
distPath: {
17+
root: outDir,
18+
},
19+
},
20+
})

0 commit comments

Comments
 (0)