diff --git a/e2e/react-start/monorepo/.gitignore b/e2e/react-start/monorepo/.gitignore new file mode 100644 index 00000000000..ea6591949b9 --- /dev/null +++ b/e2e/react-start/monorepo/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.nitro +.output +port*.txt +test-results diff --git a/e2e/react-start/monorepo/package.json b/e2e/react-start/monorepo/package.json new file mode 100644 index 00000000000..e9abef391a1 --- /dev/null +++ b/e2e/react-start/monorepo/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-react-start-e2e-monorepo", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.3.1" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.11.7", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/monorepo/packages/analytics/src/index.ts b/e2e/react-start/monorepo/packages/analytics/src/index.ts new file mode 100644 index 00000000000..cebeac5e2d9 --- /dev/null +++ b/e2e/react-start/monorepo/packages/analytics/src/index.ts @@ -0,0 +1,44 @@ +// Analytics feature package. +// This simulates a separate package in a monorepo that uses +// startInstance.createServerFn and startInstance.createMiddleware +// to create server functions and middleware with fully-typed context +// flowing from the global request middleware — WITHOUT needing +// access to the app's routeTree.gen.ts or Register module augmentation. + +import { startInstance } from '@repo/start-config' + +// --- 1) Direct server function via startInstance.createServerFn --- +// context.locale and context.userId flow automatically from global request middleware +export const getAnalyticsContext = startInstance + .createServerFn({ method: 'GET' }) + .handler(({ context }) => { + return { + locale: context.locale, + userId: context.userId, + } + }) + +// --- 2) Middleware created via startInstance.createMiddleware --- +// The global request context (locale, userId) is visible in .server() callback +const analyticsMiddleware = startInstance + .createMiddleware() + .server(({ next, context }) => { + const sessionId = `session-${context.userId}-${context.locale}` as string + return next({ + context: { + sessionId, + }, + }) + }) + +// --- 3) Server function that uses local middleware (extends global context) --- +export const getAnalyticsSession = startInstance + .createServerFn({ method: 'GET' }) + .middleware([analyticsMiddleware]) + .handler(({ context }) => { + return { + locale: context.locale, + userId: context.userId, + sessionId: context.sessionId, + } + }) diff --git a/e2e/react-start/monorepo/packages/start-config/src/index.ts b/e2e/react-start/monorepo/packages/start-config/src/index.ts new file mode 100644 index 00000000000..bde815b058a --- /dev/null +++ b/e2e/react-start/monorepo/packages/start-config/src/index.ts @@ -0,0 +1,31 @@ +// Shared start configuration for the monorepo. +// This simulates an external package that defines createStart() with +// global request middleware, then exports the startInstance so that +// other packages can use startInstance.createServerFn / startInstance.createMiddleware +// to get fully-typed context WITHOUT needing access to the app's routeTree.gen.ts. + +import { createMiddleware, createStart } from '@tanstack/react-start' + +export const localeMiddleware = createMiddleware({ type: 'request' }).server( + ({ next }) => { + return next({ + context: { + locale: 'en-us' as string, + }, + }) + }, +) + +export const authMiddleware = createMiddleware({ type: 'request' }).server( + ({ next }) => { + return next({ + context: { + userId: 'user-42' as string, + }, + }) + }, +) + +export const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware, authMiddleware], +})) diff --git a/e2e/react-start/monorepo/playwright.config.ts b/e2e/react-start/monorepo/playwright.config.ts new file mode 100644 index 00000000000..8330e023eea --- /dev/null +++ b/e2e/react-start/monorepo/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/monorepo/src/routeTree.gen.ts b/e2e/react-start/monorepo/src/routeTree.gen.ts new file mode 100644 index 00000000000..e168abfb1a4 --- /dev/null +++ b/e2e/react-start/monorepo/src/routeTree.gen.ts @@ -0,0 +1,105 @@ +/* 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 AnalyticsSessionRouteImport } from './routes/analytics-session' +import { Route as AnalyticsContextRouteImport } from './routes/analytics-context' +import { Route as IndexRouteImport } from './routes/index' + +const AnalyticsSessionRoute = AnalyticsSessionRouteImport.update({ + id: '/analytics-session', + path: '/analytics-session', + getParentRoute: () => rootRouteImport, +} as any) +const AnalyticsContextRoute = AnalyticsContextRouteImport.update({ + id: '/analytics-context', + path: '/analytics-context', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/analytics-context': typeof AnalyticsContextRoute + '/analytics-session': typeof AnalyticsSessionRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/analytics-context': typeof AnalyticsContextRoute + '/analytics-session': typeof AnalyticsSessionRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/analytics-context': typeof AnalyticsContextRoute + '/analytics-session': typeof AnalyticsSessionRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/analytics-context' | '/analytics-session' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/analytics-context' | '/analytics-session' + id: '__root__' | '/' | '/analytics-context' | '/analytics-session' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AnalyticsContextRoute: typeof AnalyticsContextRoute + AnalyticsSessionRoute: typeof AnalyticsSessionRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/analytics-session': { + id: '/analytics-session' + path: '/analytics-session' + fullPath: '/analytics-session' + preLoaderRoute: typeof AnalyticsSessionRouteImport + parentRoute: typeof rootRouteImport + } + '/analytics-context': { + id: '/analytics-context' + path: '/analytics-context' + fullPath: '/analytics-context' + preLoaderRoute: typeof AnalyticsContextRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AnalyticsContextRoute: AnalyticsContextRoute, + AnalyticsSessionRoute: AnalyticsSessionRoute, +} +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/monorepo/src/router.tsx b/e2e/react-start/monorepo/src/router.tsx new file mode 100644 index 00000000000..6ee5705ca9b --- /dev/null +++ b/e2e/react-start/monorepo/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/monorepo/src/routes/__root.tsx b/e2e/react-start/monorepo/src/routes/__root.tsx new file mode 100644 index 00000000000..de9dedae745 --- /dev/null +++ b/e2e/react-start/monorepo/src/routes/__root.tsx @@ -0,0 +1,52 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
+ + Home + +
+
+ + + + + ) +} diff --git a/e2e/react-start/monorepo/src/routes/analytics-context.tsx b/e2e/react-start/monorepo/src/routes/analytics-context.tsx new file mode 100644 index 00000000000..627988f8192 --- /dev/null +++ b/e2e/react-start/monorepo/src/routes/analytics-context.tsx @@ -0,0 +1,32 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { getAnalyticsContext } from '@repo/analytics' + +export const Route = createFileRoute('/analytics-context')({ + component: AnalyticsContextComponent, + loader: () => getAnalyticsContext(), +}) + +function AnalyticsContextComponent() { + const data = Route.useLoaderData() + + return ( +
+

Analytics Context

+

+ This route calls getAnalyticsContext() from @repo/analytics. The server + function was created via startInstance.createServerFn() and + automatically receives context from global request middleware (locale, + userId) without needing access to routeTree.gen.ts. +

+
+
+ Locale: {data.locale} +
+
+ User ID: {data.userId} +
+
+
+ ) +} diff --git a/e2e/react-start/monorepo/src/routes/analytics-session.tsx b/e2e/react-start/monorepo/src/routes/analytics-session.tsx new file mode 100644 index 00000000000..8c4fc7b6c66 --- /dev/null +++ b/e2e/react-start/monorepo/src/routes/analytics-session.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { getAnalyticsSession } from '@repo/analytics' + +export const Route = createFileRoute('/analytics-session')({ + component: AnalyticsSessionComponent, + loader: () => getAnalyticsSession(), +}) + +function AnalyticsSessionComponent() { + const data = Route.useLoaderData() + + return ( +
+

Analytics Session

+

+ This route calls getAnalyticsSession() from @repo/analytics. The server + function uses a local middleware created via + startInstance.createMiddleware(), which extends the global request + context with a sessionId — all without needing routeTree.gen.ts. +

+
+
+ Locale: {data.locale} +
+
+ User ID: {data.userId} +
+
+ Session ID: {data.sessionId} +
+
+
+ ) +} diff --git a/e2e/react-start/monorepo/src/routes/index.tsx b/e2e/react-start/monorepo/src/routes/index.tsx new file mode 100644 index 00000000000..9f2ec14b0ae --- /dev/null +++ b/e2e/react-start/monorepo/src/routes/index.tsx @@ -0,0 +1,31 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Monorepo E2E Tests

+

+ Tests for StartInstance.createServerFn / createMiddleware in a monorepo + setup where external packages do not have access to the app's + routeTree.gen.ts Register augmentation. +

+
    +
  • + + Analytics Context - server function using global middleware context + +
  • +
  • + + Analytics Session - server function using local middleware that + extends global context + +
  • +
+
+ ) +} diff --git a/e2e/react-start/monorepo/src/start.ts b/e2e/react-start/monorepo/src/start.ts new file mode 100644 index 00000000000..a25860a1fe2 --- /dev/null +++ b/e2e/react-start/monorepo/src/start.ts @@ -0,0 +1,3 @@ +// Re-export startInstance from the shared config package. +// This is the app's entry point for Start configuration. +export { startInstance } from '@repo/start-config' diff --git a/e2e/react-start/monorepo/src/styles/app.css b/e2e/react-start/monorepo/src/styles/app.css new file mode 100644 index 00000000000..23fa2415ca2 --- /dev/null +++ b/e2e/react-start/monorepo/src/styles/app.css @@ -0,0 +1 @@ +@import 'tailwindcss' source('../'); diff --git a/e2e/react-start/monorepo/tests/monorepo.spec.ts b/e2e/react-start/monorepo/tests/monorepo.spec.ts new file mode 100644 index 00000000000..801ee846e61 --- /dev/null +++ b/e2e/react-start/monorepo/tests/monorepo.spec.ts @@ -0,0 +1,95 @@ +import { expect, Page } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +type NavigationMethod = 'direct' | 'client-side' + +async function navigateToRoute( + page: Page, + route: string, + linkTestId: string, + method: NavigationMethod, +) { + if (method === 'direct') { + await page.goto(route) + } else { + // Client-side navigation: go to home first, then click link + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.getByTestId(linkTestId).click() + } + await page.waitForLoadState('networkidle') +} + +test.describe('Monorepo: startInstance.createServerFn context', () => { + test.describe('direct navigation (SSR)', () => { + test('getAnalyticsContext receives locale and userId from global request middleware', async ({ + page, + }) => { + await navigateToRoute( + page, + '/analytics-context', + 'link-analytics-context', + 'direct', + ) + + await expect(page.getByTestId('locale')).toHaveText('en-us') + await expect(page.getByTestId('userId')).toHaveText('user-42') + }) + }) + + test.describe('client-side navigation', () => { + test('getAnalyticsContext receives locale and userId from global request middleware', async ({ + page, + }) => { + await navigateToRoute( + page, + '/analytics-context', + 'link-analytics-context', + 'client-side', + ) + + await expect(page.getByTestId('locale')).toHaveText('en-us') + await expect(page.getByTestId('userId')).toHaveText('user-42') + }) + }) +}) + +test.describe('Monorepo: startInstance.createMiddleware context', () => { + test.describe('direct navigation (SSR)', () => { + test('getAnalyticsSession receives locale, userId, and sessionId from global + local middleware', async ({ + page, + }) => { + await navigateToRoute( + page, + '/analytics-session', + 'link-analytics-session', + 'direct', + ) + + await expect(page.getByTestId('locale')).toHaveText('en-us') + await expect(page.getByTestId('userId')).toHaveText('user-42') + await expect(page.getByTestId('sessionId')).toHaveText( + 'session-user-42-en-us', + ) + }) + }) + + test.describe('client-side navigation', () => { + test('getAnalyticsSession receives locale, userId, and sessionId from global + local middleware', async ({ + page, + }) => { + await navigateToRoute( + page, + '/analytics-session', + 'link-analytics-session', + 'client-side', + ) + + await expect(page.getByTestId('locale')).toHaveText('en-us') + await expect(page.getByTestId('userId')).toHaveText('user-42') + await expect(page.getByTestId('sessionId')).toHaveText( + 'session-user-42-en-us', + ) + }) + }) +}) diff --git a/e2e/react-start/monorepo/tsconfig.json b/e2e/react-start/monorepo/tsconfig.json new file mode 100644 index 00000000000..82057a848ca --- /dev/null +++ b/e2e/react-start/monorepo/tsconfig.json @@ -0,0 +1,25 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"], + "@repo/start-config": ["./packages/start-config/src/index.ts"], + "@repo/analytics": ["./packages/analytics/src/index.ts"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/monorepo/vite.config.ts b/e2e/react-start/monorepo/vite.config.ts new file mode 100644 index 00000000000..3e946e6645b --- /dev/null +++ b/e2e/react-start/monorepo/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + tailwindcss(), + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/packages/start-client-core/src/createStart.ts b/packages/start-client-core/src/createStart.ts index e59adb452b3..9f82ee1b208 100644 --- a/packages/start-client-core/src/createStart.ts +++ b/packages/start-client-core/src/createStart.ts @@ -1,16 +1,13 @@ import { createMiddleware } from './createMiddleware' +import { createServerFn } from './createServerFn' import type { TSS_SERVER_FUNCTION } from './constants' import type { AnyFunctionMiddleware, AnyRequestMiddleware, CreateMiddlewareFn, } from './createMiddleware' -import type { CustomFetch } from './createServerFn' -import type { - AnySerializationAdapter, - Register, - SSROption, -} from '@tanstack/router-core' +import type { CreateServerFn, CustomFetch } from './createServerFn' +import type { AnySerializationAdapter, SSROption } from '@tanstack/router-core' export interface StartInstanceOptions< in out TSerializationAdapters, @@ -70,7 +67,68 @@ export interface StartInstance< TRequestMiddlewares, TFunctionMiddlewares > - createMiddleware: CreateMiddlewareFn + /** + * A pre-typed `createMiddleware` that carries the global middleware context + * types from this Start instance. Middleware created through this method + * will see the accumulated context from all global request and function + * middleware in their `.server()` callbacks, without requiring the app's + * module augmentation of `Register`. + * + * @example + * ```ts + * // In an external package, import the start instance: + * import { startInstance } from '@myapp/start-config' + * + * // The middleware's server callback sees context.locale from global middleware + * export const authMiddleware = startInstance + * .createMiddleware({ type: 'function' }) + * .server(({ next, context }) => { + * // context.locale is fully typed from global request middleware! + * console.log(context.locale) + * return next({ context: { user: getUser() } }) + * }) + * ``` + */ + createMiddleware: CreateMiddlewareFn<{ + config: StartInstanceOptions< + TSerializationAdapters, + TDefaultSsr, + TRequestMiddlewares, + TFunctionMiddlewares + > + }> + /** + * A pre-typed `createServerFn` that carries the global middleware context + * types from this Start instance. Use this to create server functions in + * external packages that need access to middleware-provided context without + * requiring the app's module augmentation of `Register`. + * + * @example + * ```ts + * // In your app's start.ts, export the start instance: + * export const startInstance = createStart(() => ({ + * requestMiddleware: [localeMiddleware], + * })) + * + * // In an external package, import and use it: + * import { startInstance } from '@myapp/start-config' + * + * export const getLocale = startInstance + * .createServerFn({ method: 'GET' }) + * .handler(({ context }) => { + * // context.locale is fully typed! + * return { locale: context.locale } + * }) + * ``` + */ + createServerFn: CreateServerFn<{ + config: StartInstanceOptions< + TSerializationAdapters, + TDefaultSsr, + TRequestMiddlewares, + TFunctionMiddlewares + > + }> } export interface StartInstanceTypes< @@ -148,7 +206,8 @@ export const createStart = < return options }, createMiddleware: createMiddleware, - } as StartInstance< + createServerFn: createServerFn, + } as unknown as StartInstance< TSerializationAdapters, TDefaultSsr, TRequestMiddlewares, diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 218c984d606..3d9e1017ce6 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -17,6 +17,7 @@ export { export { createServerFn } from './createServerFn' export { createMiddleware, + type CreateMiddlewareFn, type IntersectAllValidatorInputs, type IntersectAllValidatorOutputs, type FunctionMiddlewareServerFn, @@ -59,6 +60,7 @@ export { export type { CompiledFetcherFnOptions, CompiledFetcherFn, + CreateServerFn, CustomFetch, Fetcher, RscStream, diff --git a/packages/start-client-core/src/tests/createStartServerFn.test-d.ts b/packages/start-client-core/src/tests/createStartServerFn.test-d.ts new file mode 100644 index 00000000000..bb613d07323 --- /dev/null +++ b/packages/start-client-core/src/tests/createStartServerFn.test-d.ts @@ -0,0 +1,270 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { createMiddleware } from '../createMiddleware' +import { createStart } from '../createStart' + +/** + * These tests verify that `startInstance.createServerFn` and + * `startInstance.createMiddleware` carry the global middleware context + * types from the Start instance's own generic parameters, without + * requiring the consumer to have access to the app's `Register` + * module augmentation. + * + * This is the key feature for monorepo setups where feature packages + * need typed server function and middleware context from global middleware. + */ + +const localeMiddleware = createMiddleware({ type: 'request' }).server( + ({ next }) => { + return next({ + context: { + locale: 'en-us' as string, + }, + }) + }, +) + +const authMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + return next({ + context: { + user: { id: '123' as string, role: 'admin' as string }, + }, + }) + }, +) + +describe('startInstance.createServerFn', () => { + test('carries request middleware context types', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + startInstance.createServerFn({ method: 'GET' }).handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + }>() + }) + }) + + test('carries function middleware context types', () => { + const startInstance = createStart(() => ({ + functionMiddleware: [authMiddleware], + })) + + startInstance.createServerFn({ method: 'GET' }).handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + user: { id: string; role: string } + }>() + }) + }) + + test('carries both request and function middleware context', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + functionMiddleware: [authMiddleware], + })) + + startInstance.createServerFn({ method: 'GET' }).handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + user: { id: string; role: string } + }>() + }) + }) + + test('allows chaining additional middleware', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + const extraMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + return next({ + context: { + extra: 'value' as string, + }, + }) + }, + ) + + startInstance + .createServerFn({ method: 'GET' }) + .middleware([extraMiddleware]) + .handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + extra: string + }>() + }) + }) + + test('supports factory pattern via call signature', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + functionMiddleware: [authMiddleware], + })) + + // Create a factory by calling createServerFn then middleware + const factory = startInstance + .createServerFn({ method: 'GET' }) + .middleware([]) + + // Use the factory's call signature to create new server fns + factory().handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + user: { id: string; role: string } + }>() + }) + }) + + test('context is undefined when no middleware is configured', () => { + const startInstance = createStart(() => ({})) + + startInstance.createServerFn({ method: 'GET' }).handler((options) => { + expectTypeOf(options.context).toEqualTypeOf() + }) + }) + + test('preserves method type', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + startInstance.createServerFn({ method: 'POST' }).handler((options) => { + expectTypeOf(options.method).toEqualTypeOf<'POST'>() + }) + }) + + test('supports input validator', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + startInstance + .createServerFn({ method: 'GET' }) + .inputValidator((input: { query: string }) => ({ + parsed: input.query, + })) + .handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + }>() + expectTypeOf(options.data).toEqualTypeOf<{ + parsed: string + }>() + }) + }) +}) + +describe('startInstance.createMiddleware', () => { + test('function middleware server callback sees request middleware context', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + startInstance + .createMiddleware({ type: 'function' }) + .server(async (options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + }>() + + return options.next({ context: { extra: 'value' } }) + }) + }) + + test('function middleware server callback sees both request and function middleware context', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + functionMiddleware: [authMiddleware], + })) + + startInstance + .createMiddleware({ type: 'function' }) + .server(async (options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + user: { id: string; role: string } + }>() + + return options.next() + }) + }) + + test('request middleware server callback sees prior request middleware context', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + startInstance + .createMiddleware({ type: 'request' }) + .server(async (options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + }>() + + return options.next({ context: { theme: 'dark' as string } }) + }) + }) + + test('middleware context is undefined when no global middleware is configured', () => { + const startInstance = createStart(() => ({})) + + startInstance + .createMiddleware({ type: 'function' }) + .server(async (options) => { + expectTypeOf(options.context).toEqualTypeOf() + + return options.next() + }) + }) + + test('middleware created via startInstance can be used in startInstance.createServerFn', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + const extraMiddleware = startInstance + .createMiddleware({ type: 'function' }) + .server(async (options) => { + return options.next({ + context: { extra: 'value' as string }, + }) + }) + + startInstance + .createServerFn({ method: 'GET' }) + .middleware([extraMiddleware]) + .handler((options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + extra: string + }>() + }) + }) + + test('middleware can chain other middleware before defining server', () => { + const startInstance = createStart(() => ({ + requestMiddleware: [localeMiddleware], + })) + + const innerMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + return next({ context: { inner: true as boolean } }) + }, + ) + + startInstance + .createMiddleware({ type: 'function' }) + .middleware([innerMiddleware]) + .server(async (options) => { + expectTypeOf(options.context).toEqualTypeOf<{ + locale: string + inner: boolean + }>() + + return options.next() + }) + }) +}) diff --git a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts index 388750ad923..f4c21233406 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts @@ -39,6 +39,11 @@ const getLookupConfigurationsForEnv = ( rootExport: 'createServerFn', kind: 'Root', }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createStart', + kind: 'Root', + }, { libName: `@tanstack/${framework}-start`, rootExport: 'createIsomorphicFn', @@ -63,11 +68,6 @@ const getLookupConfigurationsForEnv = ( rootExport: 'createMiddleware', kind: 'Root', }, - { - libName: `@tanstack/${framework}-start`, - rootExport: 'createStart', - kind: 'Root', - }, ...commonConfigs, ] } else { diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index a99eed371ff..6545949504b 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -45,6 +45,11 @@ async function compile(opts: { rootExport: 'createServerFn', kind: 'Root', }, + { + libName: `@tanstack/react-start`, + rootExport: 'createStart', + kind: 'Root', + }, ], resolveId: async (id) => { return id diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/client/createStart.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/client/createStart.tsx new file mode 100644 index 00000000000..bfd5f13bd02 --- /dev/null +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/client/createStart.tsx @@ -0,0 +1,22 @@ +import { createClientRpc } from '@tanstack/react-start/client-rpc'; +import { createStart, createServerFn, createMiddleware } from '@tanstack/react-start'; +const authMiddleware = createMiddleware({ + type: 'function' +}).server(({ + next +}) => { + return next({ + context: { + auth: 'auth' + } + }); +}); +export const startInstance = createStart(() => ({ + functionMiddleware: [authMiddleware] +})); +export const getLocale = startInstance.createServerFn({ + method: 'GET' +}).handler(createClientRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRMb2NhbGVfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9")); +export const getUser = startInstance.createServerFn({ + method: 'GET' +}).middleware([authMiddleware]).handler(createClientRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRVc2VyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ")); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createStart.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createStart.tsx new file mode 100644 index 00000000000..dbd8134bff1 --- /dev/null +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createStart.tsx @@ -0,0 +1,22 @@ +import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { createStart, createServerFn, createMiddleware } from '@tanstack/react-start'; +const authMiddleware = createMiddleware({ + type: 'function' +}).server(({ + next +}) => { + return next({ + context: { + auth: 'auth' + } + }); +}); +export const startInstance = createStart(() => ({ + functionMiddleware: [authMiddleware] +})); +export const getLocale = startInstance.createServerFn({ + method: 'GET' +}).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRMb2NhbGVfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["getLocale_createServerFn_handler"]))); +export const getUser = startInstance.createServerFn({ + method: 'GET' +}).middleware([authMiddleware]).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRVc2VyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["getUser_createServerFn_handler"]))); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-provider/createStart.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-provider/createStart.tsx new file mode 100644 index 00000000000..9af2f977a19 --- /dev/null +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-provider/createStart.tsx @@ -0,0 +1,45 @@ +import { createServerRpc } from '@tanstack/react-start/server-rpc'; +import { createStart, createServerFn, createMiddleware } from '@tanstack/react-start'; +const authMiddleware = createMiddleware({ + type: 'function' +}).server(({ + next +}) => { + return next({ + context: { + auth: 'auth' + } + }); +}); +const startInstance = createStart(() => ({ + functionMiddleware: [authMiddleware] +})); +const getLocale_createServerFn_handler = createServerRpc({ + id: "eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRMb2NhbGVfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", + name: "getLocale", + filename: "src/test.ts" +}, opts => getLocale.__executeServer(opts)); +const getLocale = startInstance.createServerFn({ + method: 'GET' +}).handler(getLocale_createServerFn_handler, ({ + context +}) => { + return { + locale: context.locale + }; +}); +const getUser_createServerFn_handler = createServerRpc({ + id: "eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRVc2VyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", + name: "getUser", + filename: "src/test.ts" +}, opts => getUser.__executeServer(opts)); +const getUser = startInstance.createServerFn({ + method: 'GET' +}).middleware([authMiddleware]).handler(getUser_createServerFn_handler, ({ + context +}) => { + return { + user: context.auth + }; +}); +export { getLocale_createServerFn_handler, getUser_createServerFn_handler }; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createServerFn/test-files/createStart.tsx b/packages/start-plugin-core/tests/createServerFn/test-files/createStart.tsx new file mode 100644 index 00000000000..405a3967914 --- /dev/null +++ b/packages/start-plugin-core/tests/createServerFn/test-files/createStart.tsx @@ -0,0 +1,30 @@ +import { + createStart, + createServerFn, + createMiddleware, +} from '@tanstack/react-start' + +const authMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + return next({ + context: { auth: 'auth' }, + }) + }, +) + +export const startInstance = createStart(() => ({ + functionMiddleware: [authMiddleware], +})) + +export const getLocale = startInstance + .createServerFn({ method: 'GET' }) + .handler(({ context }) => { + return { locale: context.locale } + }) + +export const getUser = startInstance + .createServerFn({ method: 'GET' }) + .middleware([authMiddleware]) + .handler(({ context }) => { + return { user: context.auth } + }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb807061ad3..6a22daca103 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1880,6 +1880,58 @@ importers: specifier: ^5.7.2 version: 5.9.3 + e2e/react-start/monorepo: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.57.0 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@types/react': + specifier: ^19.2.8 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + srvx: + specifier: ^0.11.7 + version: 0.11.7 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/query-integration: dependencies: '@tanstack/react-query':