Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions docs/start/framework/react/guide/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,12 +641,13 @@ await myServerFn({

When custom fetch implementations are provided at multiple levels, the following precedence applies (highest to lowest priority):

| Priority | Source | Description |
| ----------- | ------------------ | ----------------------------------------------- |
| 1 (highest) | Call site | `serverFn({ fetch: customFetch })` |
| 2 | Later middleware | Last middleware in chain that provides `fetch` |
| 3 | Earlier middleware | First middleware in chain that provides `fetch` |
| 4 (lowest) | Default | Global `fetch` function |
| Priority | Source | Description |
| ----------- | ------------------ | ---------------------------------------------------- |
| 1 (highest) | Call site | `serverFn({ fetch: customFetch })` |
| 2 | Later middleware | Last middleware in chain that provides `fetch` |
| 3 | Earlier middleware | First middleware in chain that provides `fetch` |
| 4 | createStart | `createStart({ serverFns: { fetch: customFetch } })` |
| 5 (lowest) | Default | Global `fetch` function |

**Key principle:** The call site always wins. This allows you to override middleware behavior for specific calls when needed.

Expand Down Expand Up @@ -715,6 +716,32 @@ const myServerFn = createServerFn()
})
```

**Global Fetch via createStart:**

You can set a default custom fetch for all server functions in your application by providing `serverFns.fetch` in `createStart`. This is useful for adding global request interceptors, retry logic, or telemetry:

```tsx
// src/start.ts
import { createStart } from '@tanstack/react-start'
import type { CustomFetch } from '@tanstack/react-start'

const globalFetch: CustomFetch = async (url, init) => {
console.log('Global fetch:', url)
// Add retry logic, telemetry, etc.
return fetch(url, init)
}

export const startInstance = createStart(() => {
return {
serverFns: {
fetch: globalFetch,
},
}
})
```

This global fetch has lower priority than middleware and call-site fetch, so you can still override it for specific server functions or calls when needed.

> [!NOTE]
> Custom fetch only applies on the client side. During SSR, server functions are called directly without going through fetch.

Expand Down
3 changes: 2 additions & 1 deletion e2e/react-start/server-functions/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,10 +936,11 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
147 changes: 140 additions & 7 deletions e2e/react-start/server-functions/src/routes/custom-fetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import type { CustomFetch } from '@tanstack/react-start'
* 3. **Earlier middleware fetch** - Middlewares earlier in the chain have lower priority.
* Their fetch will be used only if no later middleware or direct call provides one.
*
* 4. **Default global fetch** - If no custom fetch is provided anywhere, the global
* 4. **Global serverFnFetch** - When `createStart({ serverFnFetch })` is configured,
* it's used if no middleware or call-site fetch is provided.
*
* 5. **Default global fetch** - If no custom fetch is provided anywhere, the global
* `fetch` function is used.
*
* ## Why This Design?
Expand All @@ -34,6 +37,9 @@ import type { CustomFetch } from '@tanstack/react-start'
* middleware can see and override what previous middlewares set, similar to how
* middleware can modify context or headers.
*
* - **Global serverFnFetch**: Provides a default for all server functions that can
* still be overridden per-function or per-call.
*
* - **Fallback to default**: Ensures backward compatibility. Existing code without
* custom fetch continues to work as expected.
*/
Expand Down Expand Up @@ -177,6 +183,14 @@ function CustomFetchComponent() {
string,
string
> | null>(null)
const [globalFetchResult, setGlobalFetchResult] = React.useState<Record<
string,
string
> | null>(null)
const [middlewareOverridesGlobalResult, setMiddlewareOverridesGlobalResult] =
React.useState<Record<string, string> | null>(null)
const [directOverridesGlobalResult, setDirectOverridesGlobalResult] =
React.useState<Record<string, string> | null>(null)

/**
* Test 1: Direct Custom Fetch
Expand Down Expand Up @@ -260,25 +274,82 @@ function CustomFetchComponent() {
* Test 5: No Custom Fetch (Default Behavior)
*
* Calls a server function with NO middleware and NO direct fetch.
* This tests the fallback to the default global fetch.
* This tests the fallback to the global serverFnFetch from createStart.
*
* Expected:
* - 'x-global-fetch: true' SHOULD be present (from createStart serverFnFetch)
* - Neither 'x-custom-fetch-direct' nor 'x-custom-fetch-middleware' should be present
* - Request should succeed using the default fetch
* - Request should succeed using the global fetch from start.ts
*
* Precedence: No custom fetch anywhere → Default global fetch is used
* Precedence: No call-site or middleware fetch → Global serverFnFetch is used
*/
const handleNoCustomFetch = async () => {
const result = await getHeaders()
setNoCustomFetchResult(result)
}

/**
* Test 6: Global Fetch from createStart
*
* Calls a server function with NO middleware and NO direct fetch.
* Verifies that the global serverFnFetch from createStart is used.
*
* Expected:
* - 'x-global-fetch: true' SHOULD be present (from createStart serverFnFetch)
*
* This explicitly tests the global serverFnFetch feature.
*/
const handleGlobalFetch = async () => {
const result = await getHeaders()
setGlobalFetchResult(result)
}

/**
* Test 7: Middleware Overrides Global Fetch
*
* Calls a server function with middleware that provides custom fetch.
* Verifies that middleware fetch takes precedence over global serverFnFetch.
*
* Expected:
* - 'x-custom-fetch-middleware: true' SHOULD be present (middleware wins)
* - 'x-global-fetch' should NOT be present (overridden by middleware)
*
* Precedence: Middleware > Global serverFnFetch
*/
const handleMiddlewareOverridesGlobal = async () => {
const result = await getHeadersWithMiddleware()
setMiddlewareOverridesGlobalResult(result)
}

/**
* Test 8: Direct Fetch Overrides Global Fetch
*
* Calls a server function with direct fetch at call site.
* Verifies that call-site fetch takes precedence over global serverFnFetch.
*
* Expected:
* - 'x-direct-override-global: true' SHOULD be present (call-site wins)
* - 'x-global-fetch' should NOT be present (overridden by call-site)
*
* Precedence: Call-site > Global serverFnFetch
*/
const handleDirectOverridesGlobal = async () => {
const customFetch: CustomFetch = (input, init) => {
const headers = new Headers(init?.headers)
headers.set('x-direct-override-global', 'true')
return fetch(input, { ...init, headers })
}
const result = await getHeaders({ fetch: customFetch })
setDirectOverridesGlobalResult(result)
}

return (
<div className="p-2 m-2 grid gap-4">
<h3>Custom Fetch Implementation Test</h3>
<p className="text-sm text-gray-600">
Tests custom fetch override precedence: Direct call &gt; Later
middleware &gt; Earlier middleware &gt; Default fetch
middleware &gt; Earlier middleware &gt; Global serverFnFetch &gt;
Default fetch
</p>

<div>
Expand Down Expand Up @@ -357,9 +428,10 @@ function CustomFetchComponent() {
</div>

<div>
<h4>Test 5: No Custom Fetch (Default)</h4>
<h4>Test 5: No Custom Fetch (Uses Global)</h4>
<p className="text-xs text-gray-500">
Expected: No custom headers, uses default fetch
Expected: x-global-fetch header present (from createStart
serverFnFetch)
</p>
<button
type="button"
Expand All @@ -375,6 +447,67 @@ function CustomFetchComponent() {
: 'null'}
</pre>
</div>

<div>
<h4>Test 6: Global Fetch from createStart</h4>
<p className="text-xs text-gray-500">
Expected: x-global-fetch header present
</p>
<button
type="button"
data-testid="test-global-fetch-btn"
onClick={handleGlobalFetch}
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Test Global Fetch
</button>
<pre data-testid="global-fetch-result">
{globalFetchResult
? JSON.stringify(globalFetchResult, null, 2)
: 'null'}
</pre>
</div>

<div>
<h4>Test 7: Middleware Overrides Global Fetch</h4>
<p className="text-xs text-gray-500">
Expected: x-custom-fetch-middleware present, x-global-fetch NOT
present
</p>
<button
type="button"
data-testid="test-middleware-overrides-global-btn"
onClick={handleMiddlewareOverridesGlobal}
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Test Middleware Overrides Global
</button>
<pre data-testid="middleware-overrides-global-result">
{middlewareOverridesGlobalResult
? JSON.stringify(middlewareOverridesGlobalResult, null, 2)
: 'null'}
</pre>
</div>

<div>
<h4>Test 8: Direct Fetch Overrides Global Fetch</h4>
<p className="text-xs text-gray-500">
Expected: x-direct-override-global present, x-global-fetch NOT present
</p>
<button
type="button"
data-testid="test-direct-overrides-global-btn"
onClick={handleDirectOverridesGlobal}
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Test Direct Overrides Global
</button>
<pre data-testid="direct-overrides-global-result">
{directOverridesGlobalResult
? JSON.stringify(directOverridesGlobalResult, null, 2)
: 'null'}
</pre>
</div>
</div>
)
}
22 changes: 22 additions & 0 deletions e2e/react-start/server-functions/src/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createStart } from '@tanstack/react-start'
import type { CustomFetch } from '@tanstack/react-start'

/**
* Global custom fetch implementation for all server functions.
* This adds an 'x-global-fetch' header to all requests, which can be used
* to verify that the global fetch is being used.
*
* This fetch has lower priority than middleware and call-site fetch,
* so it can be overridden when needed.
*/
const globalServerFnFetch: CustomFetch = (input, init) => {
const headers = new Headers(init?.headers)
headers.set('x-global-fetch', 'true')
return fetch(input, { ...init, headers })
}

export const startInstance = createStart(() => ({
serverFns: {
fetch: globalServerFnFetch,
},
}))
62 changes: 60 additions & 2 deletions e2e/react-start/server-functions/tests/server-functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,7 @@ test('server function with direct fetch overrides middleware fetch', async ({
expect(result).not.toContain('x-custom-fetch-middleware')
})

test('server function without custom fetch uses default fetch', async ({
test('server function without custom fetch uses global serverFnFetch from createStart', async ({
page,
}) => {
await page.goto('/custom-fetch')
Expand All @@ -1128,10 +1128,68 @@ test('server function without custom fetch uses default fetch', async ({
)

const result = await page.getByTestId('no-custom-fetch-result').textContent()
// No custom headers should be present
// Global serverFnFetch header should be present
expect(result).toContain('x-global-fetch')
// No other custom headers should be present
expect(result).not.toContain('x-custom-fetch-direct')
expect(result).not.toContain('x-custom-fetch-middleware')
expect(result).not.toContain('x-middleware-first')
expect(result).not.toContain('x-middleware-second')
expect(result).not.toContain('x-direct-override')
})

test('server function uses global serverFnFetch from createStart', async ({
page,
}) => {
await page.goto('/custom-fetch')
await page.waitForLoadState('networkidle')

await page.getByTestId('test-global-fetch-btn').click()
await page.waitForSelector(
'[data-testid="global-fetch-result"]:not(:has-text("null"))',
)

const result = await page.getByTestId('global-fetch-result').textContent()
// Global serverFnFetch header should be present
expect(result).toContain('x-global-fetch')
})

test('middleware fetch overrides global serverFnFetch from createStart', async ({
page,
}) => {
await page.goto('/custom-fetch')
await page.waitForLoadState('networkidle')

await page.getByTestId('test-middleware-overrides-global-btn').click()
await page.waitForSelector(
'[data-testid="middleware-overrides-global-result"]:not(:has-text("null"))',
)

const result = await page
.getByTestId('middleware-overrides-global-result')
.textContent()
// Middleware fetch should override global, so x-custom-fetch-middleware should be present
expect(result).toContain('x-custom-fetch-middleware')
// Global fetch header should NOT be present (overridden by middleware)
expect(result).not.toContain('x-global-fetch')
})

test('direct fetch overrides global serverFnFetch from createStart', async ({
page,
}) => {
await page.goto('/custom-fetch')
await page.waitForLoadState('networkidle')

await page.getByTestId('test-direct-overrides-global-btn').click()
await page.waitForSelector(
'[data-testid="direct-overrides-global-result"]:not(:has-text("null"))',
)

const result = await page
.getByTestId('direct-overrides-global-result')
.textContent()
// Direct fetch should override global, so x-direct-override-global should be present
expect(result).toContain('x-direct-override-global')
// Global fetch header should NOT be present (overridden by direct fetch)
expect(result).not.toContain('x-global-fetch')
})
4 changes: 3 additions & 1 deletion packages/start-client-core/src/client-rpc/createClientRpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TSS_SERVER_FUNCTION } from '../constants'
import { getStartOptions } from '../getStartOptions'
import { serverFnFetcher } from './serverFnFetcher'
import type { ClientFnMeta } from '../constants'

Expand All @@ -7,7 +8,8 @@ export function createClientRpc(functionId: string) {
const serverFnMeta: ClientFnMeta = { id: functionId }

const clientFn = (...args: Array<any>) => {
return serverFnFetcher(url, args, fetch)
const startFetch = getStartOptions()?.serverFns?.fetch
return serverFnFetcher(url, args, startFetch ?? fetch)
}

return Object.assign(clientFn, {
Expand Down
Loading
Loading