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
6 changes: 6 additions & 0 deletions .changeset/tame-islands-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/start-client-core': patch
'@tanstack/start-server-core': patch
---

Ensure request middleware context wins over colliding client-provided context in server function execution paths, including SSR, GET, and FormData requests.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SimpleRouteImport } from './routes/simple'
import { Route as PathnameMiddlewareRouteImport } from './routes/pathname-middleware'
import { Route as MultipleServerFunctionsRouteImport } from './routes/multiple-server-functions'
import { Route as ContextCollisionRouteImport } from './routes/context-collision'
import { Route as IndexRouteImport } from './routes/index'

const SimpleRoute = SimpleRouteImport.update({
Expand All @@ -29,6 +30,11 @@ const MultipleServerFunctionsRoute = MultipleServerFunctionsRouteImport.update({
path: '/multiple-server-functions',
getParentRoute: () => rootRouteImport,
} as any)
const ContextCollisionRoute = ContextCollisionRouteImport.update({
id: '/context-collision',
path: '/context-collision',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
Expand All @@ -37,19 +43,22 @@ const IndexRoute = IndexRouteImport.update({

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/context-collision': typeof ContextCollisionRoute
'/multiple-server-functions': typeof MultipleServerFunctionsRoute
'/pathname-middleware': typeof PathnameMiddlewareRoute
'/simple': typeof SimpleRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/context-collision': typeof ContextCollisionRoute
'/multiple-server-functions': typeof MultipleServerFunctionsRoute
'/pathname-middleware': typeof PathnameMiddlewareRoute
'/simple': typeof SimpleRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/context-collision': typeof ContextCollisionRoute
'/multiple-server-functions': typeof MultipleServerFunctionsRoute
'/pathname-middleware': typeof PathnameMiddlewareRoute
'/simple': typeof SimpleRoute
Expand All @@ -58,21 +67,29 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/context-collision'
| '/multiple-server-functions'
| '/pathname-middleware'
| '/simple'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/multiple-server-functions' | '/pathname-middleware' | '/simple'
to:
| '/'
| '/context-collision'
| '/multiple-server-functions'
| '/pathname-middleware'
| '/simple'
id:
| '__root__'
| '/'
| '/context-collision'
| '/multiple-server-functions'
| '/pathname-middleware'
| '/simple'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ContextCollisionRoute: typeof ContextCollisionRoute
MultipleServerFunctionsRoute: typeof MultipleServerFunctionsRoute
PathnameMiddlewareRoute: typeof PathnameMiddlewareRoute
SimpleRoute: typeof SimpleRoute
Expand Down Expand Up @@ -101,6 +118,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MultipleServerFunctionsRouteImport
parentRoute: typeof rootRouteImport
}
'/context-collision': {
id: '/context-collision'
path: '/context-collision'
fullPath: '/context-collision'
preLoaderRoute: typeof ContextCollisionRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
Expand All @@ -113,6 +137,7 @@ declare module '@tanstack/react-router' {

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ContextCollisionRoute: ContextCollisionRoute,
MultipleServerFunctionsRoute: MultipleServerFunctionsRoute,
PathnameMiddlewareRoute: PathnameMiddlewareRoute,
SimpleRoute: SimpleRoute,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createFileRoute } from '@tanstack/react-router'
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { useState } from 'react'

const collisionMiddleware = createMiddleware({ type: 'function' }).client(
async ({ next }) => {
return next({
sendContext: {
trustedUser: 'client-user',
clientNonce: 'client-nonce',
},
})
},
)

const getCollisionContext = createServerFn()
.middleware([collisionMiddleware])
.handler(({ context }) => {
return {
trustedUser: context.trustedUser,
clientNonce: context.clientNonce,
}
})

const postCollisionContext = createServerFn({ method: 'POST' })
.middleware([collisionMiddleware])
.inputValidator((data: unknown) => {
if (!(data instanceof FormData)) {
throw new Error('Expected FormData')
}

return {
attempt: String(data.get('attempt') ?? ''),
}
})
.handler(({ context, data }) => {
return {
trustedUser: context.trustedUser,
clientNonce: context.clientNonce,
attempt: data.attempt,
}
})

type CollisionResult = {
trustedUser: string | undefined
clientNonce: string | undefined
}

export const Route = createFileRoute('/context-collision')({
loader: async () => {
return {
loaderResult: await getCollisionContext(),
}
},
component: ContextCollisionComponent,
})

function ContextCollisionResult({
label,
result,
testIdPrefix,
}: {
label: string
result: CollisionResult | null
testIdPrefix: string
}) {
const status =
result &&
result.trustedUser === 'server-user' &&
result.clientNonce === 'client-nonce'
? 'PASS'
: 'FAIL'

return (
<div className="mb-4">
<h2 className="font-semibold">{label}</h2>
<div>
trustedUser:{' '}
<span data-testid={`${testIdPrefix}-trusted-user`}>
{result?.trustedUser ?? 'pending'}
</span>
</div>
<div>
clientNonce:{' '}
<span data-testid={`${testIdPrefix}-client-nonce`}>
{result?.clientNonce ?? 'pending'}
</span>
</div>
<div data-testid={`${testIdPrefix}-status`}>{status}</div>
</div>
)
}

function ContextCollisionComponent() {
const { loaderResult } = Route.useLoaderData()
const [getResult, setGetResult] = useState<CollisionResult | null>(null)
const [postResult, setPostResult] = useState<CollisionResult | null>(null)

return (
<div className="p-8">
<h1 className="font-bold text-lg mb-4">
Client Context Collision Regression
</h1>

<p className="mb-4 text-gray-600">
Trusted request middleware context should win over colliding client
sendContext keys while preserving non-colliding client context.
</p>

<ContextCollisionResult
label="SSR loader result"
result={loaderResult}
testIdPrefix="loader"
/>

<div className="mb-4 flex gap-4">
<button
data-testid="invoke-get-collision"
type="button"
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"
onClick={async () => {
setGetResult(await getCollisionContext())
}}
>
Call GET server function
</button>

<button
data-testid="invoke-post-collision"
type="button"
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"
onClick={async () => {
const formData = new FormData()
formData.set('attempt', 'formdata')
setPostResult(await postCollisionContext({ data: formData }))
}}
>
Call POST server function
</button>
</div>

<ContextCollisionResult
label="Client GET result"
result={getResult}
testIdPrefix="get"
/>

<ContextCollisionResult
label="Client FormData POST result"
result={postResult}
testIdPrefix="post"
/>
</div>
)
}
16 changes: 15 additions & 1 deletion e2e/react-start/server-functions-global-middleware/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ export const pathnameMiddleware = createMiddleware().server(
},
)

export const trustedUserMiddleware = createMiddleware().server(
async ({ next }) => {
return next({
context: {
trustedUser: 'server-user',
},
})
},
)

// Global function middleware that should be deduped across server functions
export const globalFunctionMiddleware = createMiddleware({
type: 'function',
Expand Down Expand Up @@ -91,5 +101,9 @@ export const startInstance = createStart(() => ({
functionMiddleware: [globalFunctionMiddleware, globalFunctionMiddleware2],
// Request middleware - includes loggingMiddleware (issue #5239 scenario)
// AND the same loggingMiddleware is also attached to server functions
requestMiddleware: [loggingMiddleware, pathnameMiddleware],
requestMiddleware: [
trustedUserMiddleware,
loggingMiddleware,
pathnameMiddleware,
],
}))
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,36 @@ test.describe('Request middleware pathname (issue #6647)', () => {
expect(pathnameText!.startsWith('/_serverFn/')).toBe(true)
})
})

test.describe('Client sendContext collisions (TSR-003)', () => {
test('trusted server middleware context wins for SSR, GET, and FormData calls', async ({
page,
}) => {
await page.goto('/context-collision')
await page.waitForLoadState('networkidle')

await expect(page.getByTestId('loader-status')).toHaveText('PASS')
await expect(page.getByTestId('loader-trusted-user')).toHaveText(
'server-user',
)
await expect(page.getByTestId('loader-client-nonce')).toHaveText(
'client-nonce',
)

await page.getByTestId('invoke-get-collision').click()
await expect(page.getByTestId('get-status')).toHaveText('PASS')
await expect(page.getByTestId('get-trusted-user')).toHaveText('server-user')
await expect(page.getByTestId('get-client-nonce')).toHaveText(
'client-nonce',
)

await page.getByTestId('invoke-post-collision').click()
await expect(page.getByTestId('post-status')).toHaveText('PASS')
await expect(page.getByTestId('post-trusted-user')).toHaveText(
'server-user',
)
await expect(page.getByTestId('post-client-nonce')).toHaveText(
'client-nonce',
)
})
})
5 changes: 2 additions & 3 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,17 @@ export const createServerFn: CreateServerFn<Register> = (options, __opts) => {
const startContext = getStartContextServerOnly()
const serverContextAfterGlobalMiddlewares =
startContext.contextAfterGlobalMiddlewares
// Use safeObjectMerge for opts.context which comes from client
const ctx = {
...extractedFn,
...opts,
// Ensure we use the full serverFnMeta from the provider file's extractedFn
// (which has id, name, filename) rather than the partial one from SSR/client
// callers (which only has id)
serverFnMeta: extractedFn.serverFnMeta,
// Use safeObjectMerge for opts.context which comes from client
// Merge client context first so trusted server middleware context wins.
context: safeObjectMerge(
serverContextAfterGlobalMiddlewares,
opts.context,
serverContextAfterGlobalMiddlewares,
),
request: startContext.request,
}
Expand Down
4 changes: 2 additions & 2 deletions packages/start-server-core/src/server-functions-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ export const handleServerAction = async ({
deserializedContext
) {
params.context = safeObjectMerge(
context,
deserializedContext as Record<string, unknown>,
context,
)
}
} catch (e) {
Expand All @@ -144,7 +144,7 @@ export const handleServerAction = async ({
const payload: any = payloadParam
? parsePayload(JSON.parse(payloadParam))
: {}
payload.context = safeObjectMerge(context, payload.context)
payload.context = safeObjectMerge(payload.context, context)
payload.method = methodUpper
// Send it through!
return await action(payload)
Expand Down
Loading