diff --git a/docs/start/framework/react/guide/middleware.md b/docs/start/framework/react/guide/middleware.md index 50ce64928d5..1b97d7ca052 100644 --- a/docs/start/framework/react/guide/middleware.md +++ b/docs/start/framework/react/guide/middleware.md @@ -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. @@ -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. diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index b5d09dd802e..2c843a46ced 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -936,10 +936,11 @@ export const routeTree = rootRouteImport ._addFileTypes() 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> + config: Awaited> } } diff --git a/e2e/react-start/server-functions/src/routes/custom-fetch.tsx b/e2e/react-start/server-functions/src/routes/custom-fetch.tsx index e2f3ff42ad8..a30c726d68d 100644 --- a/e2e/react-start/server-functions/src/routes/custom-fetch.tsx +++ b/e2e/react-start/server-functions/src/routes/custom-fetch.tsx @@ -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? @@ -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. */ @@ -177,6 +183,14 @@ function CustomFetchComponent() { string, string > | null>(null) + const [globalFetchResult, setGlobalFetchResult] = React.useState | null>(null) + const [middlewareOverridesGlobalResult, setMiddlewareOverridesGlobalResult] = + React.useState | null>(null) + const [directOverridesGlobalResult, setDirectOverridesGlobalResult] = + React.useState | null>(null) /** * Test 1: Direct Custom Fetch @@ -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 (

Custom Fetch Implementation Test

Tests custom fetch override precedence: Direct call > Later - middleware > Earlier middleware > Default fetch + middleware > Earlier middleware > Global serverFnFetch > + Default fetch

@@ -357,9 +428,10 @@ function CustomFetchComponent() {
-

Test 5: No Custom Fetch (Default)

+

Test 5: No Custom Fetch (Uses Global)

- Expected: No custom headers, uses default fetch + Expected: x-global-fetch header present (from createStart + serverFnFetch)

+ +
+

Test 6: Global Fetch from createStart

+

+ Expected: x-global-fetch header present +

+ +
+          {globalFetchResult
+            ? JSON.stringify(globalFetchResult, null, 2)
+            : 'null'}
+        
+
+ +
+

Test 7: Middleware Overrides Global Fetch

+

+ Expected: x-custom-fetch-middleware present, x-global-fetch NOT + present +

+ +
+          {middlewareOverridesGlobalResult
+            ? JSON.stringify(middlewareOverridesGlobalResult, null, 2)
+            : 'null'}
+        
+
+ +
+

Test 8: Direct Fetch Overrides Global Fetch

+

+ Expected: x-direct-override-global present, x-global-fetch NOT present +

+ +
+          {directOverridesGlobalResult
+            ? JSON.stringify(directOverridesGlobalResult, null, 2)
+            : 'null'}
+        
+
) } diff --git a/e2e/react-start/server-functions/src/start.ts b/e2e/react-start/server-functions/src/start.ts new file mode 100644 index 00000000000..1cd840ca961 --- /dev/null +++ b/e2e/react-start/server-functions/src/start.ts @@ -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, + }, +})) diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index a03e02448f1..b260fe95e85 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -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') @@ -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') +}) diff --git a/packages/start-client-core/src/client-rpc/createClientRpc.ts b/packages/start-client-core/src/client-rpc/createClientRpc.ts index 7019f5a38ed..0ab3c73e768 100644 --- a/packages/start-client-core/src/client-rpc/createClientRpc.ts +++ b/packages/start-client-core/src/client-rpc/createClientRpc.ts @@ -1,4 +1,5 @@ import { TSS_SERVER_FUNCTION } from '../constants' +import { getStartOptions } from '../getStartOptions' import { serverFnFetcher } from './serverFnFetcher' import type { ClientFnMeta } from '../constants' @@ -7,7 +8,8 @@ export function createClientRpc(functionId: string) { const serverFnMeta: ClientFnMeta = { id: functionId } const clientFn = (...args: Array) => { - return serverFnFetcher(url, args, fetch) + const startFetch = getStartOptions()?.serverFns?.fetch + return serverFnFetcher(url, args, startFetch ?? fetch) } return Object.assign(clientFn, { diff --git a/packages/start-client-core/src/createStart.ts b/packages/start-client-core/src/createStart.ts index d3f1ddbc075..d49c76d9b92 100644 --- a/packages/start-client-core/src/createStart.ts +++ b/packages/start-client-core/src/createStart.ts @@ -5,6 +5,7 @@ import type { AnyRequestMiddleware, CreateMiddlewareFn, } from './createMiddleware' +import type { CustomFetch } from './createServerFn' import type { AnySerializationAdapter, Register, @@ -27,6 +28,25 @@ export interface StartInstanceOptions< defaultSsr?: TDefaultSsr requestMiddleware?: TRequestMiddlewares functionMiddleware?: TFunctionMiddlewares + /** + * Configuration options for server functions. + */ + serverFns?: { + /** + * A custom fetch implementation to use for all server function calls. + * This can be overridden by middleware or at the call site. + * + * Precedence (highest to lowest): + * 1. 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. Default: Global `fetch` function + * + * @note Only applies on the client side. During SSR, server functions are called directly. + */ + fetch?: CustomFetch + } } export interface StartInstance< diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 28b02b63b54..218c984d606 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -109,6 +109,7 @@ export type { AnyStartInstance, AnyStartInstanceOptions, StartInstance, + StartInstanceOptions, } from './createStart' export type { Register } from '@tanstack/router-core'