+}) {
+ const serverFn = useServerFn(fn)
+ const [result, setResult] = React.useState('idle')
+
+ return (
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/response-reconciliation/src/routes/ssr.tsx b/e2e/react-start/response-reconciliation/src/routes/ssr.tsx
new file mode 100644
index 0000000000..c0cb69c8ec
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/src/routes/ssr.tsx
@@ -0,0 +1,29 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import {
+ setCookie,
+ setResponseHeader,
+ setResponseStatus,
+} from '@tanstack/react-start/server'
+
+const setSsrResponseFn = createServerFn().handler(() => {
+ setResponseStatus(238, 'ssr-loader')
+ setResponseHeader('x-ssr-loader', 'yes')
+ setCookie('ssr-one', '1', { path: '/' })
+ setCookie('ssr-two', '2', { path: '/' })
+ return { message: 'ssr response' }
+})
+
+export const Route = createFileRoute('/ssr')({
+ loader: () => setSsrResponseFn(),
+ component: SsrRoute,
+})
+
+function SsrRoute() {
+ const data = Route.useLoaderData()
+ return (
+
+ {data.message}
+
+ )
+}
diff --git a/e2e/react-start/response-reconciliation/src/server-entry.ts b/e2e/react-start/response-reconciliation/src/server-entry.ts
new file mode 100644
index 0000000000..cde46981d5
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/src/server-entry.ts
@@ -0,0 +1,58 @@
+import {
+ createStartHandler,
+ defaultStreamHandler,
+ handleStartError,
+} from '@tanstack/react-start/server'
+import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
+
+const uncaughtFetch = createStartHandler(defaultStreamHandler)
+
+function markCustomServerEntry(response: Response): Response {
+ try {
+ response.headers.set('x-custom-server-entry', 'yes')
+ return response
+ } catch {
+ const headers = new Headers(response.headers)
+ headers.set('x-custom-server-entry', 'yes')
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ })
+ }
+}
+
+export default createServerEntry({
+ async fetch(request, opts) {
+ if (request.headers.get('x-custom-handle-errors') === 'false') {
+ try {
+ return markCustomServerEntry(await uncaughtFetch(request, opts))
+ } catch (error) {
+ if (request.headers.get('x-custom-handle-start-error') === 'true') {
+ return markCustomServerEntry(handleStartError(error))
+ }
+ return markCustomServerEntry(
+ new Response(error instanceof Error ? error.message : 'error', {
+ status: 555,
+ headers: {
+ 'x-custom-entry-catch': 'yes',
+ },
+ }),
+ )
+ }
+ }
+
+ try {
+ return markCustomServerEntry(await handler.fetch(request, opts))
+ } catch (error) {
+ return markCustomServerEntry(
+ new Response(error instanceof Error ? error.message : 'error', {
+ status: 556,
+ headers: {
+ 'x-custom-entry-catch': 'unexpected',
+ },
+ }),
+ )
+ }
+ },
+})
diff --git a/e2e/react-start/response-reconciliation/src/start.ts b/e2e/react-start/response-reconciliation/src/start.ts
new file mode 100644
index 0000000000..bd3e72c35e
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/src/start.ts
@@ -0,0 +1,53 @@
+import { createMiddleware, createStart } from '@tanstack/react-start'
+import {
+ setCookie,
+ getCookie,
+ setResponseHeader,
+ setResponseHeaders,
+ setResponseStatus,
+} from '@tanstack/react-start/server'
+
+const globalResponseMiddleware = createMiddleware().server(
+ async ({ next, request }) => {
+ const scenario =
+ request.headers.get('x-reconciliation-scenario') ||
+ getCookie('reconciliation-scenario')
+
+ if (scenario === 'global-before') {
+ setResponseHeaders(
+ new Headers({
+ 'x-global-before': 'yes',
+ 'x-global-common': 'before',
+ }),
+ )
+ setResponseStatus(231, 'global-before')
+ return next()
+ }
+
+ if (scenario === 'global-after') {
+ const result = await next()
+ setResponseHeader('x-global-after', 'yes')
+ setResponseHeader('x-global-common', 'after')
+ setResponseStatus(232, 'global-after')
+ return result
+ }
+
+ if (scenario === 'global-multiple-cookies') {
+ setCookie('global-one', '1', { path: '/' })
+ setCookie('global-two', '2', { path: '/' })
+ return next()
+ }
+
+ if (scenario === 'global-throw') {
+ setResponseStatus(401, 'Unauthorized')
+ setResponseHeader('x-global-error', 'yes')
+ throw new Error('Unauthorized global middleware')
+ }
+
+ return next()
+ },
+)
+
+export const startInstance = createStart(() => ({
+ requestMiddleware: [globalResponseMiddleware],
+}))
diff --git a/e2e/react-start/response-reconciliation/src/styles/app.css b/e2e/react-start/response-reconciliation/src/styles/app.css
new file mode 100644
index 0000000000..a38fb439a6
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/src/styles/app.css
@@ -0,0 +1,12 @@
+html {
+ color-scheme: light dark;
+ font-family: system-ui, sans-serif;
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ padding: 16px;
+}
diff --git a/e2e/react-start/response-reconciliation/tests/response-reconciliation.spec.ts b/e2e/react-start/response-reconciliation/tests/response-reconciliation.spec.ts
new file mode 100644
index 0000000000..2d6ea02260
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/tests/response-reconciliation.spec.ts
@@ -0,0 +1,648 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import type { APIResponse, Page, Response } from '@playwright/test'
+
+type TestResponse = APIResponse | Response
+
+function header(response: TestResponse, name: string) {
+ return response.headers()[name.toLowerCase()] ?? null
+}
+
+async function setCookieValues(response: TestResponse) {
+ return (await Promise.resolve(response.headersArray()))
+ .filter((item) => item.name.toLowerCase() === 'set-cookie')
+ .map((item) => item.value)
+}
+
+function expectCookie(cookies: Array, name: string, value: string) {
+ expect(
+ cookies.filter((cookie) => cookie.startsWith(`${name}=${value};`)),
+ ).toHaveLength(1)
+}
+
+async function invokeServerFunction(
+ page: Page,
+ name: string,
+ scenario?: string,
+) {
+ await page.goto(
+ scenario
+ ? `/server-functions?scenario=${encodeURIComponent(scenario)}`
+ : '/server-functions',
+ )
+ await expect(page.getByTestId('server-functions-hydrated')).toBeAttached()
+ await page.evaluate((value) => {
+ document.cookie = value
+ ? `reconciliation-scenario=${value}; Path=/`
+ : 'reconciliation-scenario=; Max-Age=0; Path=/'
+ }, scenario)
+ const responsePromise = page.waitForResponse((response) =>
+ response.url().includes('/_serverFn/'),
+ )
+ await page.getByTestId(`server-function-${name}`).click()
+ const response = await responsePromise
+ await expect(
+ page.getByTestId(`server-function-${name}-result`),
+ ).not.toHaveText('pending')
+ return response
+}
+
+async function invokeJsonServerFunction(
+ page: Page,
+ name: string,
+ scenario?: string,
+) {
+ const response = await invokeServerFunction(page, name, scenario)
+ const request = response.request()
+ expect(request.headers()['x-tsr-serverfn']).toBe('true')
+ const postData = request.postData()
+ if (postData) {
+ expect(postData).not.toContain('x-reconciliation-scenario')
+ }
+ return response
+}
+
+async function expectServerFunctionResult(
+ page: Page,
+ name: string,
+ result: string | RegExp,
+) {
+ await expect(page.getByTestId(`server-function-${name}-result`)).toHaveText(
+ result,
+ )
+}
+
+test.describe('server routes', () => {
+ test('uses expected server entry mode', async ({ request }) => {
+ const response = await request.get('/api/base')
+
+ if (process.env.TSS_E2E_SERVER_ENTRY) {
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ } else {
+ expect(header(response, 'x-custom-server-entry')).toBe(null)
+ }
+ })
+
+ test('global request middleware cookies are preserved on server routes', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/base', {
+ headers: { 'x-reconciliation-scenario': 'global-multiple-cookies' },
+ })
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'global-one', '1')
+ expectCookie(cookies, 'global-two', '2')
+ })
+
+ test('regression #5407: global middleware setResponseHeaders and status apply before next', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/base', {
+ headers: { 'x-reconciliation-scenario': 'global-before' },
+ })
+
+ expect(response.status()).toBe(231)
+ expect(response.statusText()).toBe('global-before')
+ expect(header(response, 'x-global-before')).toBe('yes')
+ expect(header(response, 'x-global-common')).toBe('before')
+ expect(header(response, 'x-base')).toBe('yes')
+ })
+
+ test('global middleware helper changes apply after next', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/base', {
+ headers: { 'x-reconciliation-scenario': 'global-after' },
+ })
+
+ expect(response.status()).toBe(232)
+ expect(response.statusText()).toBe('global-after')
+ expect(header(response, 'x-global-after')).toBe('yes')
+ expect(header(response, 'x-global-common')).toBe('after')
+ expect(header(response, 'x-base')).toBe('yes')
+ })
+
+ test('route middleware helper changes apply before and after next', async ({
+ request,
+ }) => {
+ const before = await request.get('/api/route-before-next')
+ expect(before.status()).toBe(234)
+ expect(before.statusText()).toBe('route-before')
+ expect(header(before, 'x-route-before')).toBe('yes')
+ expect(header(before, 'x-handler')).toBe('route-before-next')
+
+ const after = await request.get('/api/route-after-next')
+ expect(after.status()).toBe(233)
+ expect(after.statusText()).toBe('route-after')
+ expect(header(after, 'x-route-after')).toBe('yes')
+ expect(header(after, 'x-handler')).toBe('route-after-next')
+ })
+
+ test('helper changes overlay same-boundary returned response conflicts', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/same-boundary-conflict')
+
+ expect(response.status()).toBe(235)
+ expect(response.statusText()).toBe('same-boundary')
+ expect(header(response, 'x-conflict')).toBe('helper')
+ await expect(response.text()).resolves.toBe('conflict')
+ })
+
+ test('setResponseHeaders accepts Headers and preserves multiple cookies', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/bulk-headers')
+ const cookies = await setCookieValues(response)
+
+ expect(header(response, 'x-bulk-one')).toBe('1')
+ expect(header(response, 'x-bulk-two')).toBe('2')
+ expect(header(response, 'x-keep')).toBe('yes')
+ expectCookie(cookies, 'bulk-one', '1')
+ expectCookie(cookies, 'bulk-two', '2')
+ })
+
+ test('remove and clear helpers can remove returned response headers', async ({
+ request,
+ }) => {
+ const removed = await request.get('/api/remove-returned-header')
+ expect(header(removed, 'x-remove-me')).toBe(null)
+ expect(header(removed, 'x-keep')).toBe('yes')
+
+ const cleared = await request.get('/api/clear-returned-headers')
+ expect(header(cleared, 'x-clear-one')).toBe(null)
+ expect(header(cleared, 'x-clear-two')).toBe(null)
+ })
+
+ test('getResponseHeader and getResponseHeaders read helper writes immediately', async ({
+ request,
+ }) => {
+ const headerResponse = await request.get('/api/get-response-header-helper')
+ expect(header(headerResponse, 'x-read-after-set')).toBe('yes')
+ await expect(headerResponse.text()).resolves.toBe('yes')
+
+ const headersResponse = await request.get(
+ '/api/get-response-headers-helper',
+ )
+ expect(header(headersResponse, 'x-headers-read')).toBe('yes')
+ expect(header(headersResponse, 'x-headers-snapshot-write')).toBe(null)
+ await expect(headersResponse.text()).resolves.toBe('yes')
+ })
+
+ test('getResponseHeader sees direct current response header mutation', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/direct-mutation-visible')
+
+ expect(header(response, 'x-direct-visible')).toBe('yes')
+ expect(header(response, 'x-direct-read')).toBe('yes')
+ })
+
+ test('response replacement carries helper deltas but not old response mutations', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/replace-after-direct-mutation')
+
+ expect(response.status()).toBe(202)
+ expect(header(response, 'x-direct-a')).toBe(null)
+ expect(header(response, 'x-helper-delta')).toBe('yes')
+ expect(header(response, 'x-response-b')).toBe('yes')
+ await expect(response.text()).resolves.toBe('replacement')
+ })
+
+ test('later returned response replaces earlier response metadata', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/two-returned-responses')
+
+ expect(header(response, 'x-response-a')).toBe(null)
+ expect(header(response, 'x-response-b')).toBe('yes')
+ expect(header(response, 'x-helper-after-a')).toBe('yes')
+ await expect(response.text()).resolves.toBe('response-b')
+ })
+
+ test('readonly responses reconcile through clone fallback', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/readonly-after-next')
+
+ expect(header(response, 'x-readonly-after')).toBe('yes')
+ await expect(response.text()).resolves.toBe('readonly')
+ })
+
+ for (const status of [204, 205, 304]) {
+ test(`null-body status ${status} drops the response body`, async ({
+ request,
+ }) => {
+ const response = await request.get(
+ `/api/null-body-status?status=${status}`,
+ )
+
+ expect(response.status()).toBe(status)
+ expect(header(response, 'x-null-body')).toBe('yes')
+ await expect(response.text()).resolves.toBe('')
+ })
+ }
+
+ test('regression #5107: thrown errors preserve explicit response status and headers', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/throw-after-status')
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-error-helper')).toBe('yes')
+ expect(header(response, 'content-type')).toContain('application/json')
+ if (process.env.TSS_E2E_SERVER_ENTRY) {
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ expect(header(response, 'x-custom-entry-catch')).toBe(null)
+ }
+ await expect(response.json()).resolves.toMatchObject({ status: 401 })
+ })
+
+ test('thrown responses preserve explicit response status and headers', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/throw-after-status?throw=response')
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-error-helper')).toBe('yes')
+ expect(header(response, 'x-thrown-response')).toBe('yes')
+ if (process.env.TSS_E2E_SERVER_ENTRY) {
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ expect(header(response, 'x-custom-entry-catch')).toBe(null)
+ }
+ await expect(response.text()).resolves.toBe('Unauthorized response')
+ })
+
+ test('custom server entry can opt out of automatic error responses', async ({
+ request,
+ }) => {
+ test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only')
+
+ const response = await request.get('/api/throw-after-status', {
+ headers: { 'x-custom-handle-errors': 'false' },
+ })
+
+ expect(response.status()).toBe(555)
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ expect(header(response, 'x-custom-entry-catch')).toBe('yes')
+ await expect(response.text()).resolves.toBe('Unauthorized route')
+ })
+
+ test('custom server entry can recover Start error response state', async ({
+ request,
+ }) => {
+ test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only')
+
+ const response = await request.get('/api/throw-after-status', {
+ headers: {
+ 'x-custom-handle-errors': 'false',
+ 'x-custom-handle-start-error': 'true',
+ },
+ })
+ const cookies = await setCookieValues(response)
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-error-helper')).toBe('yes')
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ expect(header(response, 'x-custom-entry-catch')).toBe(null)
+ expectCookie(cookies, 'throw-after-status', '1')
+ await expect(response.json()).resolves.toMatchObject({ status: 401 })
+ })
+
+ test('custom server entry can recover thrown response state', async ({
+ request,
+ }) => {
+ test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only')
+
+ const response = await request.get(
+ '/api/throw-after-status?throw=response',
+ {
+ headers: {
+ 'x-custom-handle-errors': 'false',
+ 'x-custom-handle-start-error': 'true',
+ },
+ },
+ )
+ const cookies = await setCookieValues(response)
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-error-helper')).toBe('yes')
+ expect(header(response, 'x-thrown-response')).toBe('yes')
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ expect(header(response, 'x-custom-entry-catch')).toBe(null)
+ expectCookie(cookies, 'throw-after-status', '1')
+ await expect(response.text()).resolves.toBe('Unauthorized response')
+ })
+
+ test('custom server entry can opt out of SSR request middleware error responses', async ({
+ request,
+ }) => {
+ test.skip(!process.env.TSS_E2E_SERVER_ENTRY, 'custom server entry only')
+
+ const response = await request.get('/ssr', {
+ headers: {
+ 'x-custom-handle-errors': 'false',
+ 'x-reconciliation-scenario': 'global-throw',
+ },
+ })
+
+ expect(response.status()).toBe(555)
+ expect(header(response, 'x-custom-server-entry')).toBe('yes')
+ expect(header(response, 'x-custom-entry-catch')).toBe('yes')
+ await expect(response.text()).resolves.toBe(
+ 'Unauthorized global middleware',
+ )
+ })
+
+ test('regression #5464: multiple Set-Cookie headers are preserved', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/multiple-cookies')
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'route-one', '1')
+ expectCookie(cookies, 'route-two', '2')
+ })
+
+ test('explicit Set-Cookie header values are preserved and replace response values', async ({
+ request,
+ }) => {
+ const explicit = await request.get('/api/explicit-set-cookie-header')
+ const explicitCookies = await setCookieValues(explicit)
+ expectCookie(explicitCookies, 'explicit-one', '1')
+ expectCookie(explicitCookies, 'explicit-two', '2')
+
+ const replaced = await request.get('/api/replace-explicit-set-cookie')
+ const replacedCookies = await setCookieValues(replaced)
+ expectCookie(replacedCookies, 'explicit-new', '1')
+ expect(
+ replacedCookies.some((cookie) => cookie.startsWith('explicit-old=1;')),
+ ).toBe(false)
+ })
+
+ test('cookies survive redirect responses', async ({ request }) => {
+ const response = await request.get('/api/redirect-with-cookies', {
+ maxRedirects: 0,
+ })
+ const cookies = await setCookieValues(response)
+
+ expect(response.status()).toBe(307)
+ expect(header(response, 'location')).toBe('/api/base')
+ expectCookie(cookies, 'redirect-one', '1')
+ expectCookie(cookies, 'redirect-two', '2')
+ expectCookie(cookies, 'redirect-three', '3')
+ })
+})
+
+test.describe('SSR document responses', () => {
+ test('loader helper headers, status, and cookies reconcile onto document response', async ({
+ request,
+ }) => {
+ const response = await request.get('/ssr')
+ const cookies = await setCookieValues(response)
+
+ expect(response.status()).toBe(238)
+ expect(response.statusText()).toBe('ssr-loader')
+ expect(header(response, 'x-ssr-loader')).toBe('yes')
+ expect(header(response, 'content-type')).toContain('text/html')
+ expectCookie(cookies, 'ssr-one', '1')
+ expectCookie(cookies, 'ssr-two', '2')
+ await expect(response.text()).resolves.toContain('ssr response')
+ })
+
+ test('regression #5464: SSR document responses preserve multiple Set-Cookie headers', async ({
+ request,
+ }) => {
+ const response = await request.get('/ssr', {
+ headers: { 'x-reconciliation-scenario': 'global-multiple-cookies' },
+ })
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'global-one', '1')
+ expectCookie(cookies, 'global-two', '2')
+ expectCookie(cookies, 'ssr-one', '1')
+ expectCookie(cookies, 'ssr-two', '2')
+ })
+
+ test('global middleware after next reconciles onto document response', async ({
+ request,
+ }) => {
+ const response = await request.get('/ssr', {
+ headers: { 'x-reconciliation-scenario': 'global-after' },
+ })
+
+ expect(response.status()).toBe(232)
+ expect(response.statusText()).toBe('global-after')
+ expect(header(response, 'x-global-after')).toBe('yes')
+ expect(header(response, 'x-ssr-loader')).toBe('yes')
+ })
+})
+
+test.describe('server functions', () => {
+ test('global request middleware helper changes reconcile onto serialized responses', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(
+ page,
+ 'globalSerialized',
+ 'global-before',
+ )
+
+ expect(response.status()).toBe(231)
+ expect(response.statusText()).toBe('global-before')
+ expect(header(response, 'x-global-before')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ await expectServerFunctionResult(page, 'globalSerialized', '{"ok":true}')
+ })
+
+ test('global request middleware helper changes after next reconcile onto serialized responses', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(
+ page,
+ 'globalSerialized',
+ 'global-after',
+ )
+
+ expect(response.status()).toBe(232)
+ expect(response.statusText()).toBe('global-after')
+ expect(header(response, 'x-global-after')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ await expectServerFunctionResult(page, 'globalSerialized', '{"ok":true}')
+ })
+
+ test('function middleware helper changes after next reconcile onto serialized responses', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'functionAfterNext')
+
+ expect(response.status()).toBe(236)
+ expect(response.statusText()).toBe('function-after')
+ expect(header(response, 'x-function-after')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ await expectServerFunctionResult(page, 'functionAfterNext', '{"ok":true}')
+ })
+
+ test('raw server function responses reconcile helper changes after next', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(
+ page,
+ 'rawResponseAfterNext',
+ )
+
+ expect(response.status()).toBe(237)
+ expect(response.statusText()).toBe('function-raw-after')
+ expect(header(response, 'x-function-raw')).toBe('yes')
+ expect(header(response, 'x-function-raw-after')).toBe('yes')
+ expect(header(response, 'x-tss-raw')).toBe('true')
+ await expect(response.text()).resolves.toBe('raw function body')
+ await expectServerFunctionResult(
+ page,
+ 'rawResponseAfterNext',
+ '237:raw function body',
+ )
+ })
+
+ test('function response replacement carries helper deltas only', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'replacement')
+
+ expect(header(response, 'x-function-a')).toBe(null)
+ expect(header(response, 'x-function-b')).toBe('yes')
+ expect(header(response, 'x-function-helper-delta')).toBe('yes')
+ await expect(response.text()).resolves.toBe('function-b')
+ await expectServerFunctionResult(page, 'replacement', '200:function-b')
+ })
+
+ test('server function preserves multiple Set-Cookie headers', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'multipleCookies')
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'fn-one', '1')
+ expectCookie(cookies, 'fn-two', '2')
+ await expectServerFunctionResult(page, 'multipleCookies', '{"ok":true}')
+ })
+
+ test('raw server function responses preserve multiple Set-Cookie headers', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'rawMultipleCookies')
+ const cookies = await setCookieValues(response)
+
+ expect(header(response, 'x-tss-raw')).toBe('true')
+ expectCookie(cookies, 'fn-raw-returned-one', '1')
+ expectCookie(cookies, 'fn-raw-returned-two', '2')
+ expectCookie(cookies, 'fn-raw-helper-one', '1')
+ expectCookie(cookies, 'fn-raw-helper-two', '2')
+ await expect(response.text()).resolves.toBe('raw cookie body')
+ await expectServerFunctionResult(
+ page,
+ 'rawMultipleCookies',
+ '200:raw cookie body',
+ )
+ })
+
+ test('server function explicit Set-Cookie header arrays are preserved', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(
+ page,
+ 'explicitCookieHeader',
+ )
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'fn-explicit-one', '1')
+ expectCookie(cookies, 'fn-explicit-two', '2')
+ await expectServerFunctionResult(
+ page,
+ 'explicitCookieHeader',
+ '{"ok":true}',
+ )
+ })
+
+ test.describe('expected 401 responses', () => {
+ test.use({
+ whitelistErrors: [
+ /Failed to load resource: the server responded with a status of 401 \(Unauthorized\)/,
+ ],
+ })
+
+ test('server function thrown errors preserve explicit response status', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'throwAfterStatus')
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-function-error')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ await expectServerFunctionResult(page, 'throwAfterStatus', /Unauthorized/)
+ })
+
+ test('server function request middleware errors are serialized', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(
+ page,
+ 'globalSerialized',
+ 'global-throw',
+ )
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-global-error')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ await expectServerFunctionResult(page, 'globalSerialized', /Unauthorized/)
+ })
+
+ test('regression #5107: function middleware thrown errors preserve explicit response status', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(
+ page,
+ 'throwAfterMiddlewareStatus',
+ )
+
+ expect(response.status()).toBe(401)
+ expect(response.statusText()).toBe('Unauthorized')
+ expect(header(response, 'x-function-error-middleware')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ await expectServerFunctionResult(
+ page,
+ 'throwAfterMiddlewareStatus',
+ /Unauthorized/,
+ )
+ })
+ })
+
+ test('transport headers win over user helpers while user headers persist', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'transportProtected')
+
+ expect(header(response, 'x-user-header')).toBe('yes')
+ expect(header(response, 'x-tss-serialized')).toBe('true')
+ expect(header(response, 'content-type')).toContain('application/json')
+ await expectServerFunctionResult(page, 'transportProtected', '{"ok":true}')
+ })
+
+ test('server function getResponseHeader sees helper writes immediately', async ({
+ page,
+ }) => {
+ const response = await invokeJsonServerFunction(page, 'readAfterSet')
+
+ expect(header(response, 'x-read-after-set')).toBe('yes')
+ expect(header(response, 'x-read-after-set-value')).toBe('yes')
+ await expectServerFunctionResult(page, 'readAfterSet', '{"ok":true}')
+ })
+})
diff --git a/e2e/react-start/response-reconciliation/tsconfig.json b/e2e/react-start/response-reconciliation/tsconfig.json
new file mode 100644
index 0000000000..3824b148e1
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["**/*.ts", "**/*.tsx", "public/script*.js"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/react-start/response-reconciliation/vite.config.ts b/e2e/react-start/response-reconciliation/vite.config.ts
new file mode 100644
index 0000000000..355e12a2f7
--- /dev/null
+++ b/e2e/react-start/response-reconciliation/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteReact from '@vitejs/plugin-react'
+
+const serverEntry = process.env.TSS_E2E_SERVER_ENTRY
+const outDir = process.env.E2E_DIST_DIR ?? 'dist'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ build: {
+ outDir,
+ },
+ plugins: [
+ tanstackStart({
+ server: serverEntry ? { entry: serverEntry } : undefined,
+ }),
+ viteReact(),
+ ],
+})
diff --git a/e2e/react-start/session-handling/.gitignore b/e2e/react-start/session-handling/.gitignore
new file mode 100644
index 0000000000..6ccfab35a6
--- /dev/null
+++ b/e2e/react-start/session-handling/.gitignore
@@ -0,0 +1,22 @@
+node_modules
+package-lock.json
+yarn.lock
+
+.DS_Store
+.cache
+.tanstack
+.env
+.vercel
+.output
+/build/
+/api/
+/server/build
+/public/build# Sentry Config File
+.env.sentry-build-plugin
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+/dist/
+/dist-*/
+/port-*.txt
diff --git a/e2e/react-start/session-handling/.prettierignore b/e2e/react-start/session-handling/.prettierignore
new file mode 100644
index 0000000000..40a96e8819
--- /dev/null
+++ b/e2e/react-start/session-handling/.prettierignore
@@ -0,0 +1,4 @@
+**/build
+**/public
+pnpm-lock.yaml
+routeTree.gen.ts
diff --git a/e2e/react-start/session-handling/package.json b/e2e/react-start/session-handling/package.json
new file mode 100644
index 0000000000..39e0ff0cdf
--- /dev/null
+++ b/e2e/react-start/session-handling/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "tanstack-react-start-e2e-session-handling",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "pnpm dev:vite --port 3000",
+ "dev:e2e": "pnpm dev:vite",
+ "dev:vite": "vite dev",
+ "dev:rsbuild": "rsbuild dev",
+ "build": "pnpm build:vite",
+ "build:vite": "vite build && tsc --noEmit",
+ "build:rsbuild": "rsbuild build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "node server.js",
+ "test:e2e:local": "pnpm build && playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:*",
+ "@tanstack/react-start": "workspace:*",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.57.0",
+ "@rsbuild/core": "^2.0.8",
+ "@rsbuild/plugin-react": "^2.0.0",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/node": "25.0.9",
+ "@types/react": "^19.2.8",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.14"
+ },
+ "nx": {
+ "targets": {
+ "test:e2e": {
+ "parallelism": false
+ },
+ "test:e2e--vite-ssr": {
+ "parallelism": false
+ },
+ "test:e2e--rsbuild-ssr": {
+ "parallelism": false
+ }
+ },
+ "metadata": {
+ "playwrightModes": [
+ {
+ "toolchain": "vite",
+ "mode": "ssr"
+ },
+ {
+ "toolchain": "rsbuild",
+ "mode": "ssr"
+ }
+ ]
+ }
+ }
+}
diff --git a/e2e/react-start/session-handling/playwright.config.ts b/e2e/react-start/session-handling/playwright.config.ts
new file mode 100644
index 0000000000..c291adaabf
--- /dev/null
+++ b/e2e/react-start/session-handling/playwright.config.ts
@@ -0,0 +1,38 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name
+const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
+const distDir = process.env.E2E_DIST_DIR ?? 'dist'
+
+export const PORT = await getTestServerPort(e2ePortKey)
+const baseURL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ use: {
+ baseURL,
+ },
+ webServer: {
+ command: `pnpm start`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ env: {
+ PORT: String(PORT),
+ VITE_SERVER_PORT: String(PORT),
+ E2E_DIST_DIR: distDir,
+ E2E_TOOLCHAIN: toolchain,
+ E2E_PORT_KEY: e2ePortKey,
+ },
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-start/session-handling/rsbuild.config.ts b/e2e/react-start/session-handling/rsbuild.config.ts
new file mode 100644
index 0000000000..af8c3df7d2
--- /dev/null
+++ b/e2e/react-start/session-handling/rsbuild.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from '@rsbuild/core'
+import { pluginReact } from '@rsbuild/plugin-react'
+import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist'
+
+export default defineConfig({
+ plugins: [pluginReact(), tanstackStart()],
+ output: {
+ distPath: {
+ root: outDir,
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/server.js b/e2e/react-start/session-handling/server.js
new file mode 100644
index 0000000000..da874e85c7
--- /dev/null
+++ b/e2e/react-start/session-handling/server.js
@@ -0,0 +1,51 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { spawn } from 'node:child_process'
+import { pathToFileURL } from 'node:url'
+
+const distDir = process.env.E2E_DIST_DIR || 'dist'
+
+function resolveDistClientDir() {
+ return path.resolve(distDir, 'client')
+}
+
+function resolveDistServerEntryPath() {
+ const serverJsPath = path.resolve(distDir, 'server', 'server.js')
+ if (fs.existsSync(serverJsPath)) {
+ return serverJsPath
+ }
+
+ const indexJsPath = path.resolve(distDir, 'server', 'index.js')
+ if (fs.existsSync(indexJsPath)) {
+ return indexJsPath
+ }
+
+ return serverJsPath
+}
+
+export function start() {
+ const child = spawn(
+ 'srvx',
+ ['--prod', '-s', resolveDistClientDir(), resolveDistServerEntryPath()],
+ {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ },
+ )
+
+ child.on('exit', (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal)
+ return
+ }
+
+ process.exit(code ?? 0)
+ })
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ start()
+}
diff --git a/e2e/react-start/session-handling/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/session-handling/src/components/DefaultCatchBoundary.tsx
new file mode 100644
index 0000000000..f3d691814f
--- /dev/null
+++ b/e2e/react-start/session-handling/src/components/DefaultCatchBoundary.tsx
@@ -0,0 +1,20 @@
+import { ErrorComponent, Link, useRouter } from '@tanstack/react-router'
+import type { ErrorComponentProps } from '@tanstack/react-router'
+
+export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
+ const router = useRouter()
+
+ return (
+
+
+
+ Home
+
+ )
+}
diff --git a/e2e/react-start/session-handling/src/components/NotFound.tsx b/e2e/react-start/session-handling/src/components/NotFound.tsx
new file mode 100644
index 0000000000..0b720f0e37
--- /dev/null
+++ b/e2e/react-start/session-handling/src/components/NotFound.tsx
@@ -0,0 +1,10 @@
+import { Link } from '@tanstack/react-router'
+
+export function NotFound({ children }: { children?: React.ReactNode }) {
+ return (
+
+ {children || The page you are looking for does not exist.
}
+ Start Over
+
+ )
+}
diff --git a/e2e/react-start/session-handling/src/routeTree.gen.ts b/e2e/react-start/session-handling/src/routeTree.gen.ts
new file mode 100644
index 0000000000..2c878687f9
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routeTree.gen.ts
@@ -0,0 +1,284 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as SsrRouteImport } from './routes/ssr'
+import { Route as ServerFunctionsRouteImport } from './routes/server-functions'
+import { Route as IndexRouteImport } from './routes/index'
+import { Route as ApiSessionSealRouteImport } from './routes/api/session-seal'
+import { Route as ApiSessionNamedRouteImport } from './routes/api/session-named'
+import { Route as ApiSessionMiddlewareRouteImport } from './routes/api/session-middleware'
+import { Route as ApiSessionHelperCookieRouteImport } from './routes/api/session-helper-cookie'
+import { Route as ApiSessionHeaderRouteImport } from './routes/api/session-header'
+import { Route as ApiSessionCookieDisabledRouteImport } from './routes/api/session-cookie-disabled'
+import { Route as ApiSessionClearRouteImport } from './routes/api/session-clear'
+import { Route as ApiSessionRouteImport } from './routes/api/session'
+
+const SsrRoute = SsrRouteImport.update({
+ id: '/ssr',
+ path: '/ssr',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ServerFunctionsRoute = ServerFunctionsRouteImport.update({
+ id: '/server-functions',
+ path: '/server-functions',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionSealRoute = ApiSessionSealRouteImport.update({
+ id: '/api/session-seal',
+ path: '/api/session-seal',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionNamedRoute = ApiSessionNamedRouteImport.update({
+ id: '/api/session-named',
+ path: '/api/session-named',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionMiddlewareRoute = ApiSessionMiddlewareRouteImport.update({
+ id: '/api/session-middleware',
+ path: '/api/session-middleware',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionHelperCookieRoute = ApiSessionHelperCookieRouteImport.update({
+ id: '/api/session-helper-cookie',
+ path: '/api/session-helper-cookie',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionHeaderRoute = ApiSessionHeaderRouteImport.update({
+ id: '/api/session-header',
+ path: '/api/session-header',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionCookieDisabledRoute =
+ ApiSessionCookieDisabledRouteImport.update({
+ id: '/api/session-cookie-disabled',
+ path: '/api/session-cookie-disabled',
+ getParentRoute: () => rootRouteImport,
+ } as any)
+const ApiSessionClearRoute = ApiSessionClearRouteImport.update({
+ id: '/api/session-clear',
+ path: '/api/session-clear',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiSessionRoute = ApiSessionRouteImport.update({
+ id: '/api/session',
+ path: '/api/session',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/server-functions': typeof ServerFunctionsRoute
+ '/ssr': typeof SsrRoute
+ '/api/session': typeof ApiSessionRoute
+ '/api/session-clear': typeof ApiSessionClearRoute
+ '/api/session-cookie-disabled': typeof ApiSessionCookieDisabledRoute
+ '/api/session-header': typeof ApiSessionHeaderRoute
+ '/api/session-helper-cookie': typeof ApiSessionHelperCookieRoute
+ '/api/session-middleware': typeof ApiSessionMiddlewareRoute
+ '/api/session-named': typeof ApiSessionNamedRoute
+ '/api/session-seal': typeof ApiSessionSealRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/server-functions': typeof ServerFunctionsRoute
+ '/ssr': typeof SsrRoute
+ '/api/session': typeof ApiSessionRoute
+ '/api/session-clear': typeof ApiSessionClearRoute
+ '/api/session-cookie-disabled': typeof ApiSessionCookieDisabledRoute
+ '/api/session-header': typeof ApiSessionHeaderRoute
+ '/api/session-helper-cookie': typeof ApiSessionHelperCookieRoute
+ '/api/session-middleware': typeof ApiSessionMiddlewareRoute
+ '/api/session-named': typeof ApiSessionNamedRoute
+ '/api/session-seal': typeof ApiSessionSealRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/server-functions': typeof ServerFunctionsRoute
+ '/ssr': typeof SsrRoute
+ '/api/session': typeof ApiSessionRoute
+ '/api/session-clear': typeof ApiSessionClearRoute
+ '/api/session-cookie-disabled': typeof ApiSessionCookieDisabledRoute
+ '/api/session-header': typeof ApiSessionHeaderRoute
+ '/api/session-helper-cookie': typeof ApiSessionHelperCookieRoute
+ '/api/session-middleware': typeof ApiSessionMiddlewareRoute
+ '/api/session-named': typeof ApiSessionNamedRoute
+ '/api/session-seal': typeof ApiSessionSealRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/server-functions'
+ | '/ssr'
+ | '/api/session'
+ | '/api/session-clear'
+ | '/api/session-cookie-disabled'
+ | '/api/session-header'
+ | '/api/session-helper-cookie'
+ | '/api/session-middleware'
+ | '/api/session-named'
+ | '/api/session-seal'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/server-functions'
+ | '/ssr'
+ | '/api/session'
+ | '/api/session-clear'
+ | '/api/session-cookie-disabled'
+ | '/api/session-header'
+ | '/api/session-helper-cookie'
+ | '/api/session-middleware'
+ | '/api/session-named'
+ | '/api/session-seal'
+ id:
+ | '__root__'
+ | '/'
+ | '/server-functions'
+ | '/ssr'
+ | '/api/session'
+ | '/api/session-clear'
+ | '/api/session-cookie-disabled'
+ | '/api/session-header'
+ | '/api/session-helper-cookie'
+ | '/api/session-middleware'
+ | '/api/session-named'
+ | '/api/session-seal'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ ServerFunctionsRoute: typeof ServerFunctionsRoute
+ SsrRoute: typeof SsrRoute
+ ApiSessionRoute: typeof ApiSessionRoute
+ ApiSessionClearRoute: typeof ApiSessionClearRoute
+ ApiSessionCookieDisabledRoute: typeof ApiSessionCookieDisabledRoute
+ ApiSessionHeaderRoute: typeof ApiSessionHeaderRoute
+ ApiSessionHelperCookieRoute: typeof ApiSessionHelperCookieRoute
+ ApiSessionMiddlewareRoute: typeof ApiSessionMiddlewareRoute
+ ApiSessionNamedRoute: typeof ApiSessionNamedRoute
+ ApiSessionSealRoute: typeof ApiSessionSealRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/ssr': {
+ id: '/ssr'
+ path: '/ssr'
+ fullPath: '/ssr'
+ preLoaderRoute: typeof SsrRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/server-functions': {
+ id: '/server-functions'
+ path: '/server-functions'
+ fullPath: '/server-functions'
+ preLoaderRoute: typeof ServerFunctionsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-seal': {
+ id: '/api/session-seal'
+ path: '/api/session-seal'
+ fullPath: '/api/session-seal'
+ preLoaderRoute: typeof ApiSessionSealRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-named': {
+ id: '/api/session-named'
+ path: '/api/session-named'
+ fullPath: '/api/session-named'
+ preLoaderRoute: typeof ApiSessionNamedRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-middleware': {
+ id: '/api/session-middleware'
+ path: '/api/session-middleware'
+ fullPath: '/api/session-middleware'
+ preLoaderRoute: typeof ApiSessionMiddlewareRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-helper-cookie': {
+ id: '/api/session-helper-cookie'
+ path: '/api/session-helper-cookie'
+ fullPath: '/api/session-helper-cookie'
+ preLoaderRoute: typeof ApiSessionHelperCookieRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-header': {
+ id: '/api/session-header'
+ path: '/api/session-header'
+ fullPath: '/api/session-header'
+ preLoaderRoute: typeof ApiSessionHeaderRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-cookie-disabled': {
+ id: '/api/session-cookie-disabled'
+ path: '/api/session-cookie-disabled'
+ fullPath: '/api/session-cookie-disabled'
+ preLoaderRoute: typeof ApiSessionCookieDisabledRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session-clear': {
+ id: '/api/session-clear'
+ path: '/api/session-clear'
+ fullPath: '/api/session-clear'
+ preLoaderRoute: typeof ApiSessionClearRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/session': {
+ id: '/api/session'
+ path: '/api/session'
+ fullPath: '/api/session'
+ preLoaderRoute: typeof ApiSessionRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ ServerFunctionsRoute: ServerFunctionsRoute,
+ SsrRoute: SsrRoute,
+ ApiSessionRoute: ApiSessionRoute,
+ ApiSessionClearRoute: ApiSessionClearRoute,
+ ApiSessionCookieDisabledRoute: ApiSessionCookieDisabledRoute,
+ ApiSessionHeaderRoute: ApiSessionHeaderRoute,
+ ApiSessionHelperCookieRoute: ApiSessionHelperCookieRoute,
+ ApiSessionMiddlewareRoute: ApiSessionMiddlewareRoute,
+ ApiSessionNamedRoute: ApiSessionNamedRoute,
+ ApiSessionSealRoute: ApiSessionSealRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { startInstance } from './start.ts'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ config: Awaited>
+ }
+}
diff --git a/e2e/react-start/session-handling/src/router.tsx b/e2e/react-start/session-handling/src/router.tsx
new file mode 100644
index 0000000000..c5c2209e5a
--- /dev/null
+++ b/e2e/react-start/session-handling/src/router.tsx
@@ -0,0 +1,13 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
+import { NotFound } from './components/NotFound'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ defaultErrorComponent: DefaultCatchBoundary,
+ defaultNotFoundComponent: () => ,
+ scrollRestoration: true,
+ })
+}
diff --git a/e2e/react-start/session-handling/src/routes/__root.tsx b/e2e/react-start/session-handling/src/routes/__root.tsx
new file mode 100644
index 0000000000..e5e5f4cd24
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/__root.tsx
@@ -0,0 +1,59 @@
+///
+import * as React from 'react'
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
+import { NotFound } from '~/components/NotFound'
+import '~/styles/app.css'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ ],
+ }),
+ errorComponent: (props) => {
+ return (
+
+
+
+ )
+ },
+ notFoundComponent: () => ,
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+ )
+}
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/e2e/react-start/session-handling/src/routes/api/session-clear.ts b/e2e/react-start/session-handling/src/routes/api/session-clear.ts
new file mode 100644
index 0000000000..54e82270fd
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-clear.ts
@@ -0,0 +1,13 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { clearSession } from '@tanstack/react-start/server'
+
+export const Route = createFileRoute('/api/session-clear')({
+ server: {
+ handlers: {
+ POST: async () => {
+ await clearSession({})
+ return Response.json({ cleared: true })
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session-cookie-disabled.ts b/e2e/react-start/session-handling/src/routes/api/session-cookie-disabled.ts
new file mode 100644
index 0000000000..33b41dd173
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-cookie-disabled.ts
@@ -0,0 +1,19 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { readJson, readSession, updateSessionData } from '~/session'
+
+const cookieDisabled = { cookie: false }
+
+export const Route = createFileRoute('/api/session-cookie-disabled')({
+ server: {
+ handlers: {
+ GET: async () => {
+ return Response.json(await readSession(cookieDisabled))
+ },
+ POST: async ({ request }) => {
+ return Response.json(
+ await updateSessionData(await readJson(request), cookieDisabled),
+ )
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session-header.ts b/e2e/react-start/session-handling/src/routes/api/session-header.ts
new file mode 100644
index 0000000000..210a0f4360
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-header.ts
@@ -0,0 +1,16 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { readSession } from '~/session'
+
+export const Route = createFileRoute('/api/session-header')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ const url = new URL(request.url)
+ const sessionHeader = url.searchParams.get('name') || undefined
+ return Response.json(
+ await readSession({ cookie: false, sessionHeader }),
+ )
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session-helper-cookie.ts b/e2e/react-start/session-handling/src/routes/api/session-helper-cookie.ts
new file mode 100644
index 0000000000..95235d5fea
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-helper-cookie.ts
@@ -0,0 +1,14 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { readJson, updateSessionWithHelperCookie } from '~/session'
+
+export const Route = createFileRoute('/api/session-helper-cookie')({
+ server: {
+ handlers: {
+ POST: async ({ request }) => {
+ return Response.json(
+ await updateSessionWithHelperCookie(await readJson(request)),
+ )
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session-middleware.ts b/e2e/react-start/session-handling/src/routes/api/session-middleware.ts
new file mode 100644
index 0000000000..b9368963c9
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-middleware.ts
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { readSession } from '~/session'
+
+export const Route = createFileRoute('/api/session-middleware')({
+ server: {
+ handlers: {
+ GET: async () => {
+ return Response.json(await readSession())
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session-named.ts b/e2e/react-start/session-handling/src/routes/api/session-named.ts
new file mode 100644
index 0000000000..1a56e7f306
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-named.ts
@@ -0,0 +1,23 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { readJson, readSession, updateSessionData } from '~/session'
+
+function getName(request: Request) {
+ return new URL(request.url).searchParams.get('name') || 'named'
+}
+
+export const Route = createFileRoute('/api/session-named')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ return Response.json(await readSession({ name: getName(request) }))
+ },
+ POST: async ({ request }) => {
+ return Response.json(
+ await updateSessionData(await readJson(request), {
+ name: getName(request),
+ }),
+ )
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session-seal.ts b/e2e/react-start/session-handling/src/routes/api/session-seal.ts
new file mode 100644
index 0000000000..65bb66670e
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session-seal.ts
@@ -0,0 +1,20 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { sealSession, useSession } from '@tanstack/react-start/server'
+import { readJson, sessionConfig } from '~/session'
+
+export const Route = createFileRoute('/api/session-seal')({
+ server: {
+ handlers: {
+ POST: async ({ request }) => {
+ const config = sessionConfig({ cookie: false })
+ const session = await useSession>(config)
+ await session.update(await readJson(request))
+ return Response.json({
+ id: session.id,
+ data: session.data,
+ sealed: await sealSession(config),
+ })
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/api/session.ts b/e2e/react-start/session-handling/src/routes/api/session.ts
new file mode 100644
index 0000000000..d75451858f
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/api/session.ts
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { readJson, readSession, updateSessionData } from '~/session'
+
+export const Route = createFileRoute('/api/session')({
+ server: {
+ handlers: {
+ GET: async () => {
+ return Response.json(await readSession())
+ },
+ POST: async ({ request }) => {
+ return Response.json(await updateSessionData(await readJson(request)))
+ },
+ },
+ },
+})
diff --git a/e2e/react-start/session-handling/src/routes/index.tsx b/e2e/react-start/session-handling/src/routes/index.tsx
new file mode 100644
index 0000000000..9dc929d5bb
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/index.tsx
@@ -0,0 +1,14 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+ Session Handling E2E
+ This app contains HTTP-level session tests.
+
+ )
+}
diff --git a/e2e/react-start/session-handling/src/routes/server-functions.tsx b/e2e/react-start/session-handling/src/routes/server-functions.tsx
new file mode 100644
index 0000000000..4d470366e1
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/server-functions.tsx
@@ -0,0 +1,70 @@
+import { ClientOnly, createFileRoute } from '@tanstack/react-router'
+import { createServerFn, useServerFn } from '@tanstack/react-start'
+import { useSession } from '@tanstack/react-start/server'
+import * as React from 'react'
+import { sessionConfig } from '~/session'
+
+const updateSessionFn = createServerFn().handler(async () => {
+ const session = await useSession>(sessionConfig())
+ await session.update({ serverFn: 'updated' })
+ return {
+ id: session.id,
+ data: session.data,
+ }
+})
+
+const readSessionFn = createServerFn().handler(async () => {
+ const session = await useSession>(sessionConfig())
+ return {
+ id: session.id,
+ data: session.data,
+ }
+})
+
+export const Route = createFileRoute('/server-functions')({
+ component: ServerFunctions,
+})
+
+function ServerFunctions() {
+ return (
+
+ Server Functions
+
+
+
+
+
+
+ )
+}
+
+function ServerFunctionButton({
+ name,
+ fn,
+}: {
+ name: string
+ fn: (...args: Array) => Promise
+}) {
+ const serverFn = useServerFn(fn)
+ const [result, setResult] = React.useState('idle')
+
+ return (
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/session-handling/src/routes/ssr.tsx b/e2e/react-start/session-handling/src/routes/ssr.tsx
new file mode 100644
index 0000000000..fd67d12390
--- /dev/null
+++ b/e2e/react-start/session-handling/src/routes/ssr.tsx
@@ -0,0 +1,30 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { useSession } from '@tanstack/react-start/server'
+import { sessionConfig } from '~/session'
+
+const loadSession = createServerFn().handler(async () => {
+ const session = await useSession>(sessionConfig())
+ const count = Number(session.data.ssrCount ?? 0) + 1
+ await session.update({ ssrCount: count })
+ return {
+ id: session.id,
+ data: session.data,
+ }
+})
+
+export const Route = createFileRoute('/ssr')({
+ loader: () => loadSession(),
+ component: SsrRoute,
+})
+
+function SsrRoute() {
+ const data = Route.useLoaderData()
+ return (
+
+ SSR Session
+ {data.id}
+ {String(data.data.ssrCount)}
+
+ )
+}
diff --git a/e2e/react-start/session-handling/src/session.ts b/e2e/react-start/session-handling/src/session.ts
new file mode 100644
index 0000000000..d8f354aef8
--- /dev/null
+++ b/e2e/react-start/session-handling/src/session.ts
@@ -0,0 +1,53 @@
+import { setCookie, useSession } from '@tanstack/react-start/server'
+
+export const sessionPassword = 'x'.repeat(64)
+
+let sessionId = 0
+
+export function sessionConfig(overrides: Record = {}) {
+ return {
+ password: sessionPassword,
+ generateId: () => `session-${++sessionId}`,
+ ...overrides,
+ }
+}
+
+export async function readJson(request: Request) {
+ try {
+ return (await request.json()) as Record
+ } catch {
+ return {}
+ }
+}
+
+export async function readSession(overrides: Record = {}) {
+ const session = await useSession>(
+ sessionConfig(overrides),
+ )
+ return {
+ id: session.id,
+ data: session.data,
+ }
+}
+
+export async function updateSessionData(
+ update: Record,
+ overrides: Record = {},
+) {
+ const session = await useSession>(
+ sessionConfig(overrides),
+ )
+ await session.update(update)
+ return {
+ id: session.id,
+ data: session.data,
+ }
+}
+
+export async function updateSessionWithHelperCookie(
+ update: Record,
+) {
+ const session = await updateSessionData(update)
+ setCookie('helper-session', '1', { path: '/' })
+ return session
+}
diff --git a/e2e/react-start/session-handling/src/start.ts b/e2e/react-start/session-handling/src/start.ts
new file mode 100644
index 0000000000..8797cc45c6
--- /dev/null
+++ b/e2e/react-start/session-handling/src/start.ts
@@ -0,0 +1,30 @@
+import { createMiddleware, createStart } from '@tanstack/react-start'
+import { updateSession } from '@tanstack/react-start/server'
+import { sessionConfig } from './session'
+
+const sessionMiddleware = createMiddleware().server(
+ async ({ next, request }) => {
+ const scenario = request.headers.get('x-session-middleware')
+
+ if (scenario === 'before') {
+ await updateSession>(sessionConfig(), {
+ middleware: 'before',
+ })
+ return next()
+ }
+
+ if (scenario === 'after') {
+ const result = await next()
+ await updateSession>(sessionConfig(), {
+ middleware: 'after',
+ })
+ return result
+ }
+
+ return next()
+ },
+)
+
+export const startInstance = createStart(() => ({
+ requestMiddleware: [sessionMiddleware],
+}))
diff --git a/e2e/react-start/session-handling/src/styles/app.css b/e2e/react-start/session-handling/src/styles/app.css
new file mode 100644
index 0000000000..a38fb439a6
--- /dev/null
+++ b/e2e/react-start/session-handling/src/styles/app.css
@@ -0,0 +1,12 @@
+html {
+ color-scheme: light dark;
+ font-family: system-ui, sans-serif;
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ padding: 16px;
+}
diff --git a/e2e/react-start/session-handling/tests/session-handling.spec.ts b/e2e/react-start/session-handling/tests/session-handling.spec.ts
new file mode 100644
index 0000000000..74fa31b0e3
--- /dev/null
+++ b/e2e/react-start/session-handling/tests/session-handling.spec.ts
@@ -0,0 +1,314 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import type { APIResponse, Page, Response } from '@playwright/test'
+
+type TestResponse = APIResponse | Response
+
+async function setCookieValues(response: TestResponse) {
+ return (await Promise.resolve(response.headersArray()))
+ .filter((item) => item.name.toLowerCase() === 'set-cookie')
+ .map((item) => item.value)
+}
+
+function cookieName(cookie: string) {
+ return cookie.split('=', 1)[0]!
+}
+
+function cookiePair(cookie: string) {
+ return cookie.split(';', 1)[0]!
+}
+
+function cookieHeader(...cookieSets: Array>) {
+ const cookies = new Map()
+
+ for (const cookie of cookieSets.flat()) {
+ const name = cookieName(cookie)
+ if (/Max-Age=0/i.test(cookie)) {
+ cookies.delete(name)
+ } else {
+ cookies.set(name, cookiePair(cookie))
+ }
+ }
+
+ return Array.from(cookies.values()).join('; ')
+}
+
+function expectCookie(cookies: Array, name: string) {
+ expect(cookies.some((cookie) => cookie.startsWith(`${name}=`))).toBe(true)
+}
+
+function expectNoCookies(cookies: Array) {
+ expect(cookies).toEqual([])
+}
+
+async function expectServerFunctionResult(
+ page: Page,
+ name: string,
+ text: string | RegExp,
+) {
+ await expect(
+ page.getByTestId(`server-function-${name}-result`),
+ ).toContainText(text)
+}
+
+test.describe('server route sessions', () => {
+ test('creates and reuses the default cookie-backed session', async ({
+ request,
+ }) => {
+ const first = await request.get('/api/session')
+ const firstBody = await first.json()
+ const firstCookies = await setCookieValues(first)
+
+ expect(firstBody.id).toMatch(/^session-/)
+ expect(firstBody.data).toEqual({})
+ expectCookie(firstCookies, 'start')
+ expect(firstCookies[0]).toContain('HttpOnly')
+ expect(firstCookies[0]).toContain('Secure')
+ expect(firstCookies[0]).toContain('Path=/')
+
+ const second = await request.get('/api/session', {
+ headers: { cookie: cookieHeader(firstCookies) },
+ })
+ const secondBody = await second.json()
+
+ expect(secondBody.id).toBe(firstBody.id)
+ expect(secondBody.data).toEqual({})
+ expectNoCookies(await setCookieValues(second))
+ })
+
+ test('updates session data and persists it to the next request', async ({
+ request,
+ }) => {
+ const update = await request.post('/api/session', {
+ data: { user: 'tanner' },
+ })
+ const updateBody = await update.json()
+ const updateCookies = await setCookieValues(update)
+
+ expect(updateBody.data).toEqual({ user: 'tanner' })
+ expectCookie(updateCookies, 'start')
+
+ const next = await request.get('/api/session', {
+ headers: { cookie: cookieHeader(updateCookies) },
+ })
+ const nextBody = await next.json()
+
+ expect(nextBody.id).toBe(updateBody.id)
+ expect(nextBody.data).toEqual({ user: 'tanner' })
+ })
+
+ test('clears the default session with a deletion cookie', async ({
+ request,
+ }) => {
+ const update = await request.post('/api/session', {
+ data: { user: 'clear-me' },
+ })
+ const updateCookies = await setCookieValues(update)
+
+ const clear = await request.post('/api/session-clear', {
+ headers: { cookie: cookieHeader(updateCookies) },
+ })
+ const clearCookies = await setCookieValues(clear)
+
+ expect(await clear.json()).toEqual({ cleared: true })
+ expect(
+ clearCookies.some(
+ (cookie) => cookie.startsWith('start=') && /Max-Age=0/i.test(cookie),
+ ),
+ ).toBe(true)
+ })
+
+ test('stores large sessions in chunks and removes stale chunks when shrinking', async ({
+ request,
+ }) => {
+ const token = 'x'.repeat(5000)
+ const large = await request.post('/api/session', {
+ data: { token },
+ })
+ const largeCookies = await setCookieValues(large)
+
+ expect(
+ largeCookies.some((cookie) => cookie.startsWith('start=__chunked__')),
+ ).toBe(true)
+ expectCookie(largeCookies, 'start.1')
+
+ const small = await request.post('/api/session', {
+ headers: { cookie: cookieHeader(largeCookies) },
+ data: { token: 'tiny' },
+ })
+ const smallCookies = await setCookieValues(small)
+
+ expect(
+ smallCookies.some(
+ (cookie) => cookie.startsWith('start.1=') && /Max-Age=0/i.test(cookie),
+ ),
+ ).toBe(true)
+ expect(
+ smallCookies.some(
+ (cookie) =>
+ cookie.startsWith('start=') &&
+ !cookie.startsWith('start=__chunked__'),
+ ),
+ ).toBe(true)
+
+ const next = await request.get('/api/session', {
+ headers: { cookie: cookieHeader(largeCookies, smallCookies) },
+ })
+
+ expect((await next.json()).data).toEqual({ token: 'tiny' })
+ })
+
+ test('keeps named sessions independent from the default session', async ({
+ request,
+ }) => {
+ const defaultResponse = await request.post('/api/session', {
+ data: { scope: 'default' },
+ })
+ const defaultCookies = await setCookieValues(defaultResponse)
+ const namedResponse = await request.post(
+ '/api/session-named?name=account',
+ {
+ data: { role: 'admin' },
+ },
+ )
+ const namedCookies = await setCookieValues(namedResponse)
+ const cookies = cookieHeader(defaultCookies, namedCookies)
+
+ expectCookie(defaultCookies, 'start')
+ expectCookie(namedCookies, 'account')
+
+ const defaultRead = await request.get('/api/session', {
+ headers: { cookie: cookies },
+ })
+ const namedRead = await request.get('/api/session-named?name=account', {
+ headers: { cookie: cookies },
+ })
+
+ expect((await defaultRead.json()).data).toEqual({ scope: 'default' })
+ expect((await namedRead.json()).data).toEqual({ role: 'admin' })
+ })
+
+ test('reads sessions from default and custom headers without Set-Cookie', async ({
+ request,
+ }) => {
+ const sealedResponse = await request.post('/api/session-seal', {
+ data: { source: 'header' },
+ })
+ const sealedBody = await sealedResponse.json()
+
+ expectNoCookies(await setCookieValues(sealedResponse))
+ expect(typeof sealedBody.sealed).toBe('string')
+
+ const defaultHeader = await request.get('/api/session-header', {
+ headers: { 'x-start-session': sealedBody.sealed },
+ })
+ const customHeader = await request.get(
+ '/api/session-header?name=x-custom-session',
+ {
+ headers: { 'x-custom-session': sealedBody.sealed },
+ },
+ )
+
+ expect((await defaultHeader.json()).data).toEqual({ source: 'header' })
+ expect((await customHeader.json()).data).toEqual({ source: 'header' })
+ expectNoCookies(await setCookieValues(defaultHeader))
+ expectNoCookies(await setCookieValues(customHeader))
+ })
+
+ test('supports cookie:false sessions without persistence cookies', async ({
+ request,
+ }) => {
+ const update = await request.post('/api/session-cookie-disabled', {
+ data: { transient: true },
+ })
+ const next = await request.get('/api/session-cookie-disabled')
+
+ expect((await update.json()).data).toEqual({ transient: true })
+ expectNoCookies(await setCookieValues(update))
+ expect((await next.json()).data).toEqual({})
+ expectNoCookies(await setCookieValues(next))
+ })
+
+ test('falls back to a new session for tampered cookies', async ({
+ request,
+ }) => {
+ const response = await request.get('/api/session', {
+ headers: { cookie: 'start=not-a-valid-seal' },
+ })
+ const body = await response.json()
+ const cookies = await setCookieValues(response)
+
+ expect(response.status()).toBe(200)
+ expect(body.id).toMatch(/^session-/)
+ expect(body.data).toEqual({})
+ expectCookie(cookies, 'start')
+ })
+
+ test('merges session cookies with helper cookies', async ({ request }) => {
+ const response = await request.post('/api/session-helper-cookie', {
+ data: { helper: true },
+ })
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'start')
+ expect(
+ cookies.some((cookie) => cookie.startsWith('helper-session=1;')),
+ ).toBe(true)
+ })
+
+ test('request middleware can update sessions before and after next', async ({
+ request,
+ }) => {
+ const before = await request.get('/api/session-middleware', {
+ headers: { 'x-session-middleware': 'before' },
+ })
+ const beforeBody = await before.json()
+
+ expect(beforeBody.data).toEqual({ middleware: 'before' })
+
+ const after = await request.get('/api/session-middleware', {
+ headers: { 'x-session-middleware': 'after' },
+ })
+ const afterCookies = await setCookieValues(after)
+ const afterRead = await request.get('/api/session', {
+ headers: { cookie: cookieHeader(afterCookies) },
+ })
+
+ expect((await after.json()).data).toEqual({ middleware: 'before' })
+ expect((await afterRead.json()).data).toEqual({ middleware: 'after' })
+ })
+})
+
+test.describe('document and server function sessions', () => {
+ test('SSR loader session updates persist across navigations', async ({
+ page,
+ }) => {
+ await page.goto('/ssr')
+ await expect(page.getByTestId('ssr-session-count')).toHaveText('1')
+ const firstId = await page.getByTestId('ssr-session-id').textContent()
+
+ await page.reload()
+ await expect(page.getByTestId('ssr-session-count')).toHaveText('2')
+ await expect(page.getByTestId('ssr-session-id')).toHaveText(firstId ?? '')
+ })
+
+ test('server functions update and read the browser session', async ({
+ page,
+ }) => {
+ await page.goto('/server-functions')
+ await expect(page.getByTestId('server-functions-hydrated')).toBeAttached()
+
+ const responsePromise = page.waitForResponse((response) =>
+ response.url().includes('/_serverFn/'),
+ )
+ await page.getByTestId('server-function-update').click()
+ const response = await responsePromise
+ const cookies = await setCookieValues(response)
+
+ expectCookie(cookies, 'start')
+ await expectServerFunctionResult(page, 'update', '"serverFn":"updated"')
+
+ await page.getByTestId('server-function-read').click()
+ await expectServerFunctionResult(page, 'read', '"serverFn":"updated"')
+ })
+})
diff --git a/e2e/react-start/session-handling/tsconfig.json b/e2e/react-start/session-handling/tsconfig.json
new file mode 100644
index 0000000000..c7a0597e4b
--- /dev/null
+++ b/e2e/react-start/session-handling/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/react-start/session-handling/vite.config.ts b/e2e/react-start/session-handling/vite.config.ts
new file mode 100644
index 0000000000..ef9695579b
--- /dev/null
+++ b/e2e/react-start/session-handling/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteReact from '@vitejs/plugin-react'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ build: {
+ outDir,
+ },
+ plugins: [tanstackStart(), viteReact()],
+})
diff --git a/packages/react-start/src/default-entry/server.ts b/packages/react-start/src/default-entry/server.ts
index 8e734a7e49..26142334ad 100644
--- a/packages/react-start/src/default-entry/server.ts
+++ b/packages/react-start/src/default-entry/server.ts
@@ -1,6 +1,7 @@
import {
createStartHandler,
defaultStreamHandler,
+ handleStartError,
} from '@tanstack/react-start/server'
import type { Register } from '@tanstack/react-router'
import type { RequestHandler } from '@tanstack/react-start/server'
@@ -13,7 +14,11 @@ export type ServerEntry = { fetch: RequestHandler }
export function createServerEntry(entry: ServerEntry): ServerEntry {
return {
async fetch(...args) {
- return await entry.fetch(...args)
+ try {
+ return await entry.fetch(...args)
+ } catch (error) {
+ return handleStartError(error)
+ }
},
}
}
diff --git a/packages/solid-start/src/default-entry/server.ts b/packages/solid-start/src/default-entry/server.ts
index b8f3105c6a..5561c6aa07 100644
--- a/packages/solid-start/src/default-entry/server.ts
+++ b/packages/solid-start/src/default-entry/server.ts
@@ -1,6 +1,7 @@
import {
createStartHandler,
defaultStreamHandler,
+ handleStartError,
} from '@tanstack/solid-start/server'
import type { Register } from '@tanstack/solid-router'
import type { RequestHandler } from '@tanstack/solid-start/server'
@@ -13,7 +14,11 @@ export type ServerEntry = { fetch: RequestHandler }
export function createServerEntry(entry: ServerEntry): ServerEntry {
return {
async fetch(...args) {
- return await entry.fetch(...args)
+ try {
+ return await entry.fetch(...args)
+ } catch (error) {
+ return handleStartError(error)
+ }
},
}
}
diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts
index 5fd9356f1f..c2a02a504e 100644
--- a/packages/start-plugin-core/src/vite/plugin.ts
+++ b/packages/start-plugin-core/src/vite/plugin.ts
@@ -124,10 +124,9 @@ export function tanStackStartVite(
routerBasepath,
serverFnBase: startConfig.serverFns.base,
})
- const resolvedEntryPlan = configContext.resolveEntries()
const entryAliases = createViteResolvedEntryAliases({
- entryPaths: resolvedEntryPlan.entryPaths,
+ entryPaths: configContext.resolveEntries().entryPaths,
})
const startPackageName =
diff --git a/packages/start-plugin-core/tests/entry-planning.test.ts b/packages/start-plugin-core/tests/entry-planning.test.ts
new file mode 100644
index 0000000000..8256ae79ae
--- /dev/null
+++ b/packages/start-plugin-core/tests/entry-planning.test.ts
@@ -0,0 +1,115 @@
+import {
+ mkdtempSync,
+ mkdirSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, test } from 'vitest'
+import { resolveStartEntryPlan } from '../src/planning'
+import { parseStartConfig as parseRsbuildStartConfig } from '../src/rsbuild/schema'
+import { parseStartConfig as parseViteStartConfig } from '../src/vite/schema'
+
+const corePluginOpts = { framework: 'react' as const }
+const defaultEntryPaths = {
+ client: '/virtual/default-client',
+ server: '/virtual/default-server',
+ start: '/virtual/default-start',
+}
+const roots = new Set()
+
+function createRoot() {
+ const root = mkdtempSync(join(tmpdir(), 'tanstack-start-entry-'))
+ roots.add(root)
+ mkdirSync(join(root, 'src'))
+ writeFileSync(join(root, 'src', 'router.ts'), 'export {}\n')
+ return root
+}
+
+afterEach(() => {
+ for (const root of roots) {
+ rmSync(root, { recursive: true, force: true })
+ }
+ roots.clear()
+})
+
+describe('server entry planning', () => {
+ test.each([
+ ['vite', parseViteStartConfig],
+ ['rsbuild', parseRsbuildStartConfig],
+ ])(
+ 'uses the default server entry for %s when none is configured',
+ (_, parse) => {
+ const root = createRoot()
+ const startConfig = parse({}, corePluginOpts, root)
+
+ const plan = resolveStartEntryPlan({
+ root,
+ startConfig,
+ defaultEntryPaths,
+ })
+
+ expect(plan.entryPaths.server).toBe(defaultEntryPaths.server)
+ },
+ )
+
+ test.each([
+ ['vite', parseViteStartConfig],
+ ['rsbuild', parseRsbuildStartConfig],
+ ])('resolves a custom server entry for %s from srcDirectory', (_, parse) => {
+ const root = createRoot()
+ const serverEntry = join(root, 'src', 'server-entry.ts')
+ writeFileSync(serverEntry, 'export default {}\n')
+ const startConfig = parse(
+ {
+ server: {
+ entry: './server-entry.ts',
+ },
+ },
+ corePluginOpts,
+ root,
+ )
+
+ const plan = resolveStartEntryPlan({
+ root,
+ startConfig,
+ defaultEntryPaths,
+ })
+
+ expect(plan.entryPaths.server).toBe(realpathSync(serverEntry))
+ })
+
+ test.each([
+ ['vite', parseViteStartConfig],
+ ['rsbuild', parseRsbuildStartConfig],
+ ])(
+ 'resolves a custom server entry for %s from a custom srcDirectory',
+ (_, parse) => {
+ const root = createRoot()
+ mkdirSync(join(root, 'app'))
+ writeFileSync(join(root, 'app', 'router.ts'), 'export {}\n')
+ const serverEntry = join(root, 'app', 'server-entry.ts')
+ writeFileSync(serverEntry, 'export default {}\n')
+ const startConfig = parse(
+ {
+ srcDirectory: 'app',
+ server: {
+ entry: './server-entry.ts',
+ },
+ },
+ corePluginOpts,
+ root,
+ )
+
+ const plan = resolveStartEntryPlan({
+ root,
+ startConfig,
+ defaultEntryPaths,
+ })
+
+ expect(plan.entryPaths.server).toBe(realpathSync(serverEntry))
+ },
+ )
+})
diff --git a/packages/start-server-core/docs/RECONCILIATION.md b/packages/start-server-core/docs/RECONCILIATION.md
new file mode 100644
index 0000000000..6506d59bbd
--- /dev/null
+++ b/packages/start-server-core/docs/RECONCILIATION.md
@@ -0,0 +1,163 @@
+# Response Reconciliation
+
+This package owns request and response state for TanStack Start server runtime. `h3` is only used by session helpers and is created lazily when a session helper runs.
+
+## Request State
+
+Every Start request runs inside a shared `AsyncLocalStorage` event. The event contains:
+
+- `request`: the original platform `Request`
+- `response`: mutable Start response state
+- `responseMeta`: metadata used to reconcile helper changes
+- `h3Event`: optional lazy session-only `H3Event`
+
+The ALS instance is stored on a global symbol so separately bundled copies of this module share one request context.
+
+Native helpers read directly from this event:
+
+- `getRequest`
+- `getRequestHeaders`
+- `getRequestHeader`
+- `getRequestHost`
+- `getRequestProtocol`
+- `getRequestUrl`
+- `getRequestIP`
+- `getCookies`
+- `getCookie`
+
+These helpers do not construct or read from an `H3Event`.
+
+## Response State
+
+Start response helpers mutate event-owned state, not `h3` state:
+
+- `setResponseStatus`
+- `setResponseHeader`
+- `setResponseHeaders`
+- `removeResponseHeader`
+- `clearResponseHeaders`
+- `setCookie`
+- `deleteCookie`
+
+`getResponseHeader` and `getResponseHeaders` read the current returned response plus helper overlay. Helper writes are visible immediately, even before a final `Response` exists. Treat `getResponseHeaders()` as a read-only snapshot: mutating the returned `Headers` object does not mutate outgoing response state. Use `setResponseHeader`, `setResponseHeaders`, `removeResponseHeader`, or `clearResponseHeaders` for writes.
+
+Internal code can use `getResponse` to access event-owned mutable response state during serialization. This is not public API; user code should use the status/header/cookie helpers above.
+
+## Reconciliation Model
+
+Route handlers, middleware, SSR handlers, and server functions can all return `Response` objects. Start reconciles those responses with helper mutations before the response leaves the request boundary.
+
+Reconciliation rules:
+
+- Helper state overlays returned `Response` state.
+- Status and status text from `setResponseStatus` win over returned response status.
+- Headers from response helpers win over returned response headers.
+- `removeResponseHeader` and `clearResponseHeaders` can remove headers from a returned response.
+- Helper deltas survive when a later middleware replaces the response.
+- Direct mutations to an old `response.headers` object do not survive response replacement.
+- Direct mutations to the current response are visible while that response remains current.
+- If nothing changed, reconciliation returns the original `Response` instance.
+- If only writable headers changed, reconciliation mutates the existing `Response` headers.
+- If status/body shape changed or headers are readonly, reconciliation creates a new `Response` using the same body.
+
+The implementation avoids cloning `Response` objects unless mutation is impossible or the response shape must change.
+
+## Header Tracking
+
+The event response headers are wrapped to track helper intent:
+
+- `set` records a helper write.
+- `append` records a helper write.
+- `delete` records a helper removal.
+- `clearResponseHeaders()` records a full clear.
+
+This metadata matters because a plain `Headers` object cannot distinguish between "not set" and "explicitly removed".
+
+## Cookies
+
+Cookies use native `cookie-es` parsing and serialization.
+
+`setCookie` appends `Set-Cookie` values and dedupes by cookie identity:
+
+- name
+- domain
+- path
+
+If domain is absent, identity uses an empty domain. `setCookie` defaults to `Path=/`, but returned `Set-Cookie` headers without `Path` keep an empty path identity so they do not dedupe against explicit-path cookies.
+
+Cookie behavior has two modes:
+
+- `setCookie`/`deleteCookie`: merge with existing returned response cookies.
+- `setResponseHeader('set-cookie', ...)`: replace returned response cookies.
+
+This preserves multiple `Set-Cookie` headers and lets explicit header APIs replace cookie state when requested.
+
+## Protected Transport Headers
+
+Server function protocol headers are protected after the protocol response is created:
+
+- `content-type`
+- `x-tss-serialized`
+- `x-tss-raw`
+
+Helper headers still reconcile onto server function responses, but protected transport headers win over user helper writes. This prevents user code from corrupting the client/server function protocol.
+
+## Null Body Responses
+
+Reconciliation drops bodies for response shapes that cannot carry one:
+
+- `HEAD` requests
+- status `204`
+- status `205`
+- status `304`
+
+Informational statuses like `101` cannot be used to construct Fetch responses, so they are sanitized before reconciliation.
+
+If a middleware sets one of these statuses after a streamed SSR response is produced, the middleware executor treats that as response replacement and disposes the original SSR stream owner.
+
+## Stream Ownership
+
+SSR streaming responses carry cleanup ownership metadata. Middleware reconciliation preserves or disposes that ownership based on the final body:
+
+- Same response: ownership is preserved.
+- Wrapper response with the same body: ownership moves to the wrapper.
+- Different response or dropped body: original stream owner is disposed.
+- Middleware error after `next()`: original stream owner is disposed.
+
+This prevents cleanup leaks while still allowing middleware to wrap streamed responses without prematurely disposing the stream.
+
+## Error Handling
+
+`requestHandler` rethrows uncaught errors. Object/function errors are associated with the current Start event in a `WeakMap` so server entries can catch outside the ALS continuation and call `handleStartError(error)`.
+
+Generated server entries wrap `entry.fetch(...args)` in `try/catch` and call `handleStartError(error)`. Custom server entries that call `createStartHandler(...)` directly should do the same if they want Start's default conversion.
+
+Server function RPC errors are converted before the top-level server-entry boundary so serialized protocol headers and bodies are preserved.
+
+`handleStartError` behavior:
+
+- If ALS is still active, it uses the current event.
+- If the error was remembered, it uses and deletes the remembered event.
+- If the error is a `Response`, it returns that response when no Start event exists.
+- Otherwise it returns a generic JSON error response.
+
+Primitive throws are rethrown as-is. Without an associated Start event, `handleStartError(error)` can still return a generic error response, but it cannot recover request-specific state.
+
+## h3 Session Bridge
+
+Session helpers lazily create an `H3Event` only when needed:
+
+- `useSession`
+- `getSession`
+- `updateSession`
+- `sealSession`
+- `unsealSession`
+- `clearSession`
+
+Before a session helper runs, Start response state is copied to `h3Event.res`. After the helper returns, `h3Event.res.headers` is merged back into Start response state. `Set-Cookie` values from session helpers merge through the same cookie dedupe path as native `setCookie`.
+
+No request helper or non-session response helper depends on `h3`.
+
+## Malformed URLs
+
+`requestHandler` validates `request.url` before entering ALS. A malformed URL that throws `URIError` or `TypeError` becomes `400 Bad Request`. Other URL construction errors are rethrown.
diff --git a/packages/start-server-core/package.json b/packages/start-server-core/package.json
index 906c25f2fe..4c8f2a9eff 100644
--- a/packages/start-server-core/package.json
+++ b/packages/start-server-core/package.json
@@ -91,13 +91,13 @@
"@tanstack/router-core": "workspace:*",
"@tanstack/start-client-core": "workspace:*",
"@tanstack/start-storage-context": "workspace:*",
+ "cookie-es": "^3.0.0",
"fetchdts": "^0.1.6",
"h3-v2": "npm:h3@2.0.1-rc.20",
"seroval": "^1.5.4"
},
"devDependencies": {
"@standard-schema/spec": "^1.0.0",
- "cookie-es": "^3.0.0",
"vite": "*",
"@types/node": ">=20"
}
diff --git a/packages/start-server-core/skills/start-server-core/SKILL.md b/packages/start-server-core/skills/start-server-core/SKILL.md
index 7dc16d517f..269960a151 100644
--- a/packages/start-server-core/skills/start-server-core/SKILL.md
+++ b/packages/start-server-core/skills/start-server-core/SKILL.md
@@ -102,6 +102,8 @@ const serverFn = createServerFn({ method: 'POST' }).handler(async () => {
})
```
+`getResponseHeader` and `getResponseHeaders` are read helpers. Treat the `Headers` returned by `getResponseHeaders()` as a snapshot; mutating it does not change the outgoing response. Use the setter/removal helpers for writes.
+
## Cookie Management
```ts
@@ -190,29 +192,6 @@ await session.update({ userId: '123' }) // Persist session data
await session.clear() // Clear session data
```
-## Query Validation
-
-Validate query string parameters using a Standard Schema:
-
-```ts
-// Use @tanstack/-start for your framework (react, solid, vue)
-import { getValidatedQuery } from '@tanstack/react-start/server'
-import { z } from 'zod'
-
-const serverFn = createServerFn({ method: 'GET' }).handler(async () => {
- const query = await getValidatedQuery(
- z.object({
- page: z.coerce.number().default(1),
- limit: z.coerce.number().default(20),
- }),
- )
-
- return { page: query.page }
-})
-```
-
-> Note: `getValidatedQuery` accepts a Standard Schema validator, not a callback function.
-
## How Request Handling Works
`createStartHandler` processes requests in three phases:
diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
index 378ed50d2d..be2561e2ee 100644
--- a/packages/start-server-core/src/createStartHandler.ts
+++ b/packages/start-server-core/src/createStartHandler.ts
@@ -25,9 +25,12 @@ import {
getStartContext,
runWithStartContext,
} from '@tanstack/start-storage-context'
-import { requestHandler } from './request-response'
+import { reconcileResponse, requestHandler } from './internal-request-response'
import { getStartManifest } from './router-manifest'
-import { handleServerAction } from './server-functions-handler'
+import {
+ createServerFnErrorResponse,
+ handleServerAction,
+} from './server-functions-handler'
import { createEarlyHintsCollector } from './early-hints'
import {
createCachedBaseManifestLoader,
@@ -47,6 +50,7 @@ import type {
} from '@tanstack/start-client-core'
import type { RequestHandler } from './request-handler'
import type {
+ AnyRedirect,
AnyRoute,
AnyRouter,
AnySerializationAdapter,
@@ -69,16 +73,15 @@ export interface CreateStartHandlerOptions extends FinalManifestOptions {
handler: HandlerCallback
}
-function getStartResponseHeaders(opts: { router: AnyRouter }) {
- const headers = mergeHeaders(
+function getStartResponseHeaders(router: AnyRouter) {
+ return mergeHeaders(
{
'Content-Type': 'text/html; charset=utf-8',
},
- ...opts.router.stores.matches.get().map((match) => {
+ ...router.stores.matches.get().map((match) => {
return match.headers
}),
)
- return headers
}
interface PluginAdaptersEntry {
@@ -141,7 +144,9 @@ function hasCsrfMiddleware(
}
function warnMissingCsrfMiddlewareOnce() {
- if (hasWarnedMissingCsrfMiddleware) return
+ if (hasWarnedMissingCsrfMiddleware) {
+ return
+ }
hasWarnedMissingCsrfMiddleware = true
console.warn(`TanStack Start server functions are not protected by the CSRF middleware.
@@ -198,23 +203,13 @@ function isSpecialResponse(value: unknown): value is Response {
return value instanceof Response || isRedirect(value)
}
-/**
- * Normalize middleware result to context shape
- */
-function handleCtxResult(result: TODO) {
- if (isSsrResponse(result) || isSpecialResponse(result)) {
- return { response: result }
- }
- return result
-}
-
/**
* Execute a middleware chain
*/
async function executeMiddleware(
middlewares: Array,
ctx: TODO,
-): Promise<{ ctx: TODO; response: HandlerCallbackResult }> {
+): Promise {
let index = -1
let streamResponse:
| Extract
@@ -225,11 +220,12 @@ async function executeMiddleware(
if (response.serverSsrCleanup === 'stream') {
streamResponse = response
}
- ctx.response = response.response
+ ctx.response = reconcileResponse(response.response)
return
}
- ctx.response = response
+ ctx.response =
+ response instanceof Response ? reconcileResponse(response) : response
}
const disposeStreamResponse = async (reason: string) => {
@@ -252,11 +248,16 @@ async function executeMiddleware(
}
const getFinalResponse = async (): Promise => {
- const response = ctx.response
+ let response = ctx.response
if (!response) {
throwRouteHandlerError()
}
+ if (response instanceof Response) {
+ response = reconcileResponse(response)
+ ctx.response = response
+ }
+
if (!streamResponse) {
return response
}
@@ -294,7 +295,9 @@ async function executeMiddleware(
index++
const middleware = middlewares[index]
- if (!middleware) return ctx
+ if (!middleware) {
+ return ctx
+ }
let result: TODO
try {
@@ -308,7 +311,10 @@ async function executeMiddleware(
throw err
}
- const normalized = handleCtxResult(result)
+ const normalized =
+ isSsrResponse(result) || isSpecialResponse(result)
+ ? { response: result }
+ : result
if (normalized) {
if (normalized.response !== undefined) {
setResponse(normalized.response)
@@ -321,8 +327,13 @@ async function executeMiddleware(
return ctx
}
- await next()
- return { ctx, response: await getFinalResponse() }
+ try {
+ await next()
+ return await getFinalResponse()
+ } catch (error) {
+ await disposeStreamResponse('middleware error')
+ throw error
+ }
}
/**
@@ -400,8 +411,8 @@ export function createStartHandler(
request,
requestOpts,
) => {
- let router: AnyRouter | null = null as AnyRouter | null
- let responseOwnsCleanup = false as boolean
+ let router: AnyRouter | undefined
+ let responseOwnsCleanup = false
try {
// normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR.
@@ -447,10 +458,15 @@ export function createStartHandler(
const executedRequestMiddlewares = new Set(
flattenedRequestMiddlewares,
)
+ const requestMiddlewares = flattenedRequestMiddlewares.map(
+ (d) => d.options.server,
+ )
// Memoized router getter
const getRouter = async (): Promise => {
- if (router) return router
+ if (router) {
+ return router
+ }
router = await entries.routerEntry.getRouter()
@@ -481,6 +497,24 @@ export function createStartHandler(
return router
}
+ const runStartContext = (
+ handlerType: 'router' | 'serverFn',
+ context: TODO,
+ fn: () => TResult | Promise,
+ ) => {
+ return runWithStartContext(
+ {
+ getRouter,
+ startOptions: requestStartOptions,
+ contextAfterGlobalMiddlewares: context,
+ request,
+ executedRequestMiddlewares,
+ handlerType,
+ },
+ fn,
+ )
+ }
+
// Check for server function requests first (early exit)
if (SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE)) {
if (
@@ -499,37 +533,41 @@ export function createStartHandler(
throw new Error('Invalid server action param for serverFnId')
}
+ const middlewareCtx = {
+ request,
+ pathname: url.pathname,
+ handlerType: 'serverFn',
+ context: createNullProtoObject(requestOpts?.context),
+ }
+
const serverFnHandler = async ({ context }: TODO) => {
- return runWithStartContext(
- {
- getRouter,
- startOptions: requestStartOptions,
- contextAfterGlobalMiddlewares: context,
+ return runStartContext('serverFn', context, () =>
+ handleServerAction({
request,
- executedRequestMiddlewares,
- handlerType: 'serverFn',
- },
- () =>
- handleServerAction({
- request,
- context: requestOpts?.context,
- serverFnId,
- }),
+ context,
+ serverFnId,
+ }),
)
}
- const middlewares = flattenedRequestMiddlewares.map(
- (d) => d.options.server,
- )
- const { response: middlewareResponse } = await executeMiddleware(
- [...middlewares, serverFnHandler],
- {
- request,
- pathname: url.pathname,
- handlerType: 'serverFn',
- context: createNullProtoObject(requestOpts?.context),
- },
- )
+ const handleServerFnMiddlewareError = async (
+ error: unknown,
+ ): Promise => {
+ return runStartContext('serverFn', middlewareCtx.context, () =>
+ createServerFnErrorResponse(error),
+ )
+ }
+
+ let middlewareResponse: HandlerCallbackResult
+ try {
+ middlewareResponse = await executeMiddleware(
+ [...requestMiddlewares, serverFnHandler],
+ middlewareCtx,
+ )
+ } catch (error) {
+ // Request middleware can throw before serverFnHandler runs.
+ middlewareResponse = await handleServerFnMiddlewareError(error)
+ }
const result = await handleRedirectResponse(
middlewareResponse,
@@ -599,9 +637,7 @@ export function createStartHandler(
requestAssets: ctx?.requestAssets,
})
- const responseHeaders = getStartResponseHeaders({
- router: routerInstance,
- })
+ const responseHeaders = getStartResponseHeaders(routerInstance)
earlyHints?.appendResponseHeaders(responseHeaders)
const response = await cb({
request,
@@ -613,40 +649,20 @@ export function createStartHandler(
// Main request handler
const requestHandlerMiddleware = async ({ context }: TODO) => {
- return runWithStartContext(
- {
+ return runStartContext('router', context, () =>
+ handleServerRoutes({
getRouter,
- startOptions: requestStartOptions,
- contextAfterGlobalMiddlewares: context,
request,
+ url,
+ executeRouter,
+ context,
executedRequestMiddlewares,
- handlerType: 'router',
- },
- async () => {
- try {
- return await handleServerRoutes({
- getRouter,
- request,
- url,
- executeRouter,
- context,
- executedRequestMiddlewares,
- })
- } catch (err) {
- if (err instanceof Response) {
- return err
- }
- throw err
- }
- },
+ }),
)
}
- const middlewares = flattenedRequestMiddlewares.map(
- (d) => d.options.server,
- )
- const { response: middlewareResponse } = await executeMiddleware(
- [...middlewares, requestHandlerMiddleware],
+ const middlewareResponse = await executeMiddleware(
+ [...requestMiddlewares, requestHandlerMiddleware],
{
request,
pathname: url.pathname,
@@ -669,7 +685,7 @@ export function createStartHandler(
// Transformed streaming response bodies clean up when consumed/cancelled.
router.serverSsr.cleanup()
}
- router = null
+ router = undefined
}
}
@@ -685,55 +701,51 @@ async function handleRedirectResponse(
if (!isRedirect(ssrResponse.response)) {
return ssrResponse
}
+ const redirectResponse: AnyRedirect = ssrResponse.response
+ const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
+ const serializeServerFnRedirect = () => {
+ return replaceSsrResponse(
+ ssrResponse,
+ Response.json(
+ { ...redirectResponse.options, isSerializedRedirect: true },
+ { headers: redirectResponse.headers },
+ ),
+ 'redirect response replaced',
+ )
+ }
- if (isResolvedRedirect(ssrResponse.response)) {
- if (request.headers.get('x-tsr-serverFn') === 'true') {
- return replaceSsrResponse(
- ssrResponse,
- Response.json(
- { ...ssrResponse.response.options, isSerializedRedirect: true },
- { headers: ssrResponse.response.headers },
- ),
- 'redirect response replaced',
- )
+ if (isResolvedRedirect(redirectResponse)) {
+ if (isServerFn) {
+ return serializeServerFnRedirect()
}
return ssrResponse
}
- const opts = ssrResponse.response.options
+ const opts = redirectResponse.options
if (opts.to && typeof opts.to === 'string' && !opts.to.startsWith('/')) {
throw new Error(
`Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`,
)
}
- if (
- ['params', 'search', 'hash'].some(
- (d) => typeof (opts as TODO)[d] === 'function',
- )
- ) {
+ const functionalRedirectOptionNames = (
+ ['params', 'search', 'hash'] as const
+ ).filter((key) => typeof opts[key] === 'function')
+
+ if (functionalRedirectOptionNames.length) {
throw new Error(
- `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys(
- opts,
- )
- .filter((d) => typeof (opts as TODO)[d] === 'function')
+ `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${functionalRedirectOptionNames
.map((d) => `"${d}"`)
.join(', ')}`,
)
}
const router = await getRouter()
- const redirect = router.resolveRedirect(ssrResponse.response)
+ // resolveRedirect mutates redirectResponse so serverFn serialization includes href/Location.
+ const redirect = router.resolveRedirect(redirectResponse)
- if (request.headers.get('x-tsr-serverFn') === 'true') {
- return replaceSsrResponse(
- ssrResponse,
- Response.json(
- { ...ssrResponse.response.options, isSerializedRedirect: true },
- { headers: ssrResponse.response.headers },
- ),
- 'redirect response replaced',
- )
+ if (isServerFn) {
+ return serializeServerFnRedirect()
}
return replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced')
@@ -766,7 +778,7 @@ async function handleServerRoutes({
const { matchedRoutes, foundRoute, routeParams } =
router.getMatchedRoutes(pathname)
- const isExactMatch = foundRoute && routeParams['**'] === undefined
+ const isExactMatch = !!foundRoute && routeParams['**'] === undefined
// Collect and dedupe route middlewares
const routeMiddlewares: Array = []
@@ -803,8 +815,7 @@ async function handleServerRoutes({
requestMethod === 'HEAD'
? (handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY'])
: (handlers[requestMethod] ?? handlers['ANY'])
- isHeadFallback =
- requestMethod === 'HEAD' && handler !== undefined && !handlers['HEAD']
+ isHeadFallback = requestMethod === 'HEAD' && !!handler && !handlers['HEAD']
if (handler) {
const mayDefer = !!foundRoute.options.component
@@ -829,7 +840,7 @@ async function handleServerRoutes({
routeMiddlewares.push(((ctx: TODO) =>
executeRouter(ctx.context, matchedRoutes)) as TODO)
- const { ctx, response } = await executeMiddleware(routeMiddlewares, {
+ const response = await executeMiddleware(routeMiddlewares, {
request,
context,
params: routeParams,
@@ -839,11 +850,11 @@ async function handleServerRoutes({
// RFC 9110 §9.3.2: HEAD must carry the same header fields as GET but no body.
// Resolve any redirect before stripping so the Location header survives.
+ // Keep this SsrResponse-aware strip here instead of relying only on final
+ // reconciliation. At this point we still have stream ownership metadata, so
+ // stripSsrResponseBody can dispose SSR streams when HEAD falls back to GET/ANY.
+ // Generic reconciliation still handles plain Response body dropping later.
if (isHeadFallback) {
- if (!ctx.response) {
- throwRouteHandlerError()
- }
-
const resolved = await handleRedirectResponse(response, request, getRouter)
return stripSsrResponseBody(resolved, 'HEAD body stripped')
}
diff --git a/packages/start-server-core/src/headers.ts b/packages/start-server-core/src/headers.ts
new file mode 100644
index 0000000000..8632fd5724
--- /dev/null
+++ b/packages/start-server-core/src/headers.ts
@@ -0,0 +1,38 @@
+import { splitSetCookieString } from 'cookie-es'
+
+type HeadersWithGetSetCookie = Headers & {
+ getSetCookie?: () => Array
+}
+
+export function getSetCookieValues(headers: Headers): Array {
+ const headersWithSetCookie = headers as HeadersWithGetSetCookie
+ if (typeof headersWithSetCookie.getSetCookie === 'function') {
+ return headersWithSetCookie.getSetCookie()
+ }
+ const value = headers.get('set-cookie')
+ return value ? splitSetCookieString(value) : []
+}
+
+export function cloneHeaders(headers: Headers): Headers {
+ const cloned = new Headers()
+ applyHeaders(headers, cloned)
+ return cloned
+}
+
+export function applyHeaders(source: Headers, target: Headers): void {
+ for (const [name, value] of source) {
+ if (name !== 'set-cookie') {
+ target.set(name, value)
+ }
+ }
+ for (const cookie of getSetCookieValues(source)) {
+ target.append('set-cookie', cookie)
+ }
+}
+
+export function copyHeaders(source: Headers, target: Headers): void {
+ for (const name of Array.from(target.keys())) {
+ target.delete(name)
+ }
+ applyHeaders(source, target)
+}
diff --git a/packages/start-server-core/src/internal-request-response.ts b/packages/start-server-core/src/internal-request-response.ts
new file mode 100644
index 0000000000..ae8f1a6d41
--- /dev/null
+++ b/packages/start-server-core/src/internal-request-response.ts
@@ -0,0 +1,1233 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+
+import {
+ H3Event,
+ clearSession as h3_clearSession,
+ getSession as h3_getSession,
+ sealSession as h3_sealSession,
+ unsealSession as h3_unsealSession,
+ updateSession as h3_updateSession,
+ useSession as h3_useSession,
+} from 'h3-v2'
+import { parseCookie, parseSetCookie, serializeCookie } from 'cookie-es'
+import { cloneHeaders, copyHeaders, getSetCookieValues } from './headers'
+import type {
+ RequestHeaderMap,
+ RequestHeaderName,
+ ResponseHeaderMap,
+ ResponseHeaderName,
+ TypedHeaders,
+} from 'fetchdts'
+
+import type { CookieSerializeOptions } from 'cookie-es'
+import type {
+ Session,
+ SessionConfig,
+ SessionData,
+ SessionManager,
+ SessionUpdate,
+} from './session'
+import type { RequestHandler } from './request-handler'
+
+interface StartEvent {
+ request: Request
+ requestUrl?: URL
+ h3Event?: H3Event
+ h3SessionQueue?: Promise
+ response: StartResponse
+ responseMeta: ResponseMeta
+}
+
+interface StartResponse {
+ status?: number
+ statusText?: string
+ headers: Headers
+}
+
+// Use a global symbol to ensure the same AsyncLocalStorage instance is shared
+// across different bundles that may each bundle this module.
+const GLOBAL_EVENT_STORAGE_KEY = Symbol.for('tanstack-start:event-storage')
+
+const globalObj = globalThis as typeof globalThis & {
+ [GLOBAL_EVENT_STORAGE_KEY]?: AsyncLocalStorage
+}
+
+if (!globalObj[GLOBAL_EVENT_STORAGE_KEY]) {
+ globalObj[GLOBAL_EVENT_STORAGE_KEY] = new AsyncLocalStorage()
+}
+
+const eventStorage = globalObj[GLOBAL_EVENT_STORAGE_KEY]
+
+export type { ResponseHeaderName, RequestHeaderName }
+
+type ProtectedHeaders = Map
+
+type MaybePromise = T | Promise
+
+interface ResponseMeta {
+ currentResponse?: Response
+ removedHeaders?: Set
+ clearHeaders: boolean
+ settingCookie: boolean
+ setCookieBehavior?: 'merge' | 'replace'
+}
+
+const protectedResponseHeaders = new WeakMap()
+const startErrorEvents = new WeakMap