diff --git a/.changeset/rsc-css-auto-injection.md b/.changeset/rsc-css-auto-injection.md
new file mode 100644
index 0000000000..d9c0049604
--- /dev/null
+++ b/.changeset/rsc-css-auto-injection.md
@@ -0,0 +1,9 @@
+---
+'@tanstack/react-start': patch
+'@tanstack/react-start-rsc': patch
+'@tanstack/start-plugin-core': patch
+---
+
+Add compiler-driven RSC CSS auto-injection for Start RSC render APIs and wire it into the React Start Vite and Rsbuild adapters. This ensures same-file CSS module dependencies are discovered for `renderServerComponent`, `createCompositeComponent`, and JSX-based `renderToReadableStream` calls.
+
+Also add a configurable server function provider module directive hook used by the React Rsbuild RSC adapter to emit `"use server-entry"` only for extracted provider files.
diff --git a/e2e/react-start/rsc/src/routeTree.gen.ts b/e2e/react-start/rsc/src/routeTree.gen.ts
index d053db0f94..015d3edf82 100644
--- a/e2e/react-start/rsc/src/routeTree.gen.ts
+++ b/e2e/react-start/rsc/src/routeTree.gen.ts
@@ -42,6 +42,7 @@ import { Route as RscDeferredComponentRouteImport } from './routes/rsc-deferred-
import { Route as RscDeferredRouteImport } from './routes/rsc-deferred'
import { Route as RscCssPreloadComplexRouteImport } from './routes/rsc-css-preload-complex'
import { Route as RscCssModulesRouteImport } from './routes/rsc-css-modules'
+import { Route as RscCssAutoinjectRouteImport } from './routes/rsc-css-autoinject'
import { Route as RscContextRouteImport } from './routes/rsc-context'
import { Route as RscComponentSlotRouteImport } from './routes/rsc-component-slot'
import { Route as RscClientPreloadRouteImport } from './routes/rsc-client-preload'
@@ -221,6 +222,11 @@ const RscCssModulesRoute = RscCssModulesRouteImport.update({
path: '/rsc-css-modules',
getParentRoute: () => rootRouteImport,
} as any)
+const RscCssAutoinjectRoute = RscCssAutoinjectRouteImport.update({
+ id: '/rsc-css-autoinject',
+ path: '/rsc-css-autoinject',
+ getParentRoute: () => rootRouteImport,
+} as any)
const RscContextRoute = RscContextRouteImport.update({
id: '/rsc-context',
path: '/rsc-context',
@@ -296,6 +302,7 @@ export interface FileRoutesByFullPath {
'/rsc-client-preload': typeof RscClientPreloadRoute
'/rsc-component-slot': typeof RscComponentSlotRoute
'/rsc-context': typeof RscContextRoute
+ '/rsc-css-autoinject': typeof RscCssAutoinjectRoute
'/rsc-css-modules': typeof RscCssModulesRoute
'/rsc-css-preload-complex': typeof RscCssPreloadComplexRoute
'/rsc-deferred': typeof RscDeferredRoute
@@ -344,6 +351,7 @@ export interface FileRoutesByTo {
'/rsc-client-preload': typeof RscClientPreloadRoute
'/rsc-component-slot': typeof RscComponentSlotRoute
'/rsc-context': typeof RscContextRoute
+ '/rsc-css-autoinject': typeof RscCssAutoinjectRoute
'/rsc-css-modules': typeof RscCssModulesRoute
'/rsc-css-preload-complex': typeof RscCssPreloadComplexRoute
'/rsc-deferred': typeof RscDeferredRoute
@@ -393,6 +401,7 @@ export interface FileRoutesById {
'/rsc-client-preload': typeof RscClientPreloadRoute
'/rsc-component-slot': typeof RscComponentSlotRoute
'/rsc-context': typeof RscContextRoute
+ '/rsc-css-autoinject': typeof RscCssAutoinjectRoute
'/rsc-css-modules': typeof RscCssModulesRoute
'/rsc-css-preload-complex': typeof RscCssPreloadComplexRoute
'/rsc-deferred': typeof RscDeferredRoute
@@ -443,6 +452,7 @@ export interface FileRouteTypes {
| '/rsc-client-preload'
| '/rsc-component-slot'
| '/rsc-context'
+ | '/rsc-css-autoinject'
| '/rsc-css-modules'
| '/rsc-css-preload-complex'
| '/rsc-deferred'
@@ -491,6 +501,7 @@ export interface FileRouteTypes {
| '/rsc-client-preload'
| '/rsc-component-slot'
| '/rsc-context'
+ | '/rsc-css-autoinject'
| '/rsc-css-modules'
| '/rsc-css-preload-complex'
| '/rsc-deferred'
@@ -539,6 +550,7 @@ export interface FileRouteTypes {
| '/rsc-client-preload'
| '/rsc-component-slot'
| '/rsc-context'
+ | '/rsc-css-autoinject'
| '/rsc-css-modules'
| '/rsc-css-preload-complex'
| '/rsc-deferred'
@@ -588,6 +600,7 @@ export interface RootRouteChildren {
RscClientPreloadRoute: typeof RscClientPreloadRoute
RscComponentSlotRoute: typeof RscComponentSlotRoute
RscContextRoute: typeof RscContextRoute
+ RscCssAutoinjectRoute: typeof RscCssAutoinjectRoute
RscCssModulesRoute: typeof RscCssModulesRoute
RscCssPreloadComplexRoute: typeof RscCssPreloadComplexRoute
RscDeferredRoute: typeof RscDeferredRoute
@@ -861,6 +874,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RscCssModulesRouteImport
parentRoute: typeof rootRouteImport
}
+ '/rsc-css-autoinject': {
+ id: '/rsc-css-autoinject'
+ path: '/rsc-css-autoinject'
+ fullPath: '/rsc-css-autoinject'
+ preLoaderRoute: typeof RscCssAutoinjectRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/rsc-context': {
id: '/rsc-context'
path: '/rsc-context'
@@ -964,6 +984,7 @@ const rootRouteChildren: RootRouteChildren = {
RscClientPreloadRoute: RscClientPreloadRoute,
RscComponentSlotRoute: RscComponentSlotRoute,
RscContextRoute: RscContextRoute,
+ RscCssAutoinjectRoute: RscCssAutoinjectRoute,
RscCssModulesRoute: RscCssModulesRoute,
RscCssPreloadComplexRoute: RscCssPreloadComplexRoute,
RscDeferredRoute: RscDeferredRoute,
diff --git a/e2e/react-start/rsc/src/routes/__root.tsx b/e2e/react-start/rsc/src/routes/__root.tsx
index 9ebb559527..8690f7934d 100644
--- a/e2e/react-start/rsc/src/routes/__root.tsx
+++ b/e2e/react-start/rsc/src/routes/__root.tsx
@@ -352,6 +352,14 @@ function RootComponent() {
>
CSS Preload Complex
+
+ CSS Auto Inject
+
+ auto injected css
+
{label}
+
+ This server component is defined in the same route file as the RSC
+ render call and uses a CSS module from that route file.
+
+
+ )
+}
+
+const getRenderableCssModule = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ return renderServerComponent(
+ ,
+ )
+ },
+)
+
+const getCompositeCssModule = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ return createCompositeComponent(() => ({
+ Card: (
+
+ ),
+ }))
+ },
+)
+
+const getFlightCssModuleResponse = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const stream = renderToReadableStream(
+ ,
+ )
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/x-component',
+ },
+ })
+ },
+)
+
+export const Route = createFileRoute('/rsc-css-autoinject')({
+ loader: async () => {
+ const [renderable, composite] = await Promise.all([
+ getRenderableCssModule(),
+ getCompositeCssModule(),
+ ])
+
+ return { renderable, composite }
+ },
+ component: RscCssAutoinjectComponent,
+})
+
+function RscCssAutoinjectComponent() {
+ const { renderable, composite } = Route.useLoaderData()
+
+ return (
+
+
RSC CSS Auto Injection
+
+ This route verifies Start injects RSC CSS resources for same-file CSS
+ module server components across all Start RSC render APIs.
+
+
+
+ {renderable}
+
+
+
+
+ )
+}
+
+function FlightCssModuleCard() {
+ const [node, setNode] = React.useState(null)
+ const [error, setError] = React.useState(null)
+
+ React.useEffect(() => {
+ let cancelled = false
+
+ async function loadFlightCard() {
+ try {
+ const response = await getFlightCssModuleResponse()
+ const result = await createFromFetch(Promise.resolve(response))
+ if (!cancelled) {
+ setNode(result)
+ }
+ } catch (e) {
+ if (!cancelled) {
+ setError(e instanceof Error ? e.message : String(e))
+ }
+ }
+ }
+
+ loadFlightCard()
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (error) {
+ return {error}
+ }
+
+ return <>{node}>
+}
diff --git a/e2e/react-start/rsc/tests/rsc-css-autoinject.spec.ts b/e2e/react-start/rsc/tests/rsc-css-autoinject.spec.ts
new file mode 100644
index 0000000000..5448210a4e
--- /dev/null
+++ b/e2e/react-start/rsc/tests/rsc-css-autoinject.spec.ts
@@ -0,0 +1,29 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import { waitForHydration } from './hydration'
+
+test.describe('RSC CSS Auto Injection Tests', () => {
+ test('same-file CSS modules are auto-injected for all RSC render APIs', async ({
+ page,
+ }) => {
+ await page.goto('/rsc-css-autoinject')
+ await page.waitForURL('/rsc-css-autoinject')
+ await waitForHydration(page)
+
+ const renderable = page.getByTestId('rsc-css-autoinject-renderable')
+ const composite = page.getByTestId('rsc-css-autoinject-composite')
+ const flight = page.getByTestId('rsc-css-autoinject-flight')
+
+ await expect(renderable).toBeVisible()
+ await expect(composite).toBeVisible()
+ await expect(flight).toBeVisible()
+
+ for (const card of [renderable, composite, flight]) {
+ await expect(card).toHaveCSS('background-color', 'rgb(204, 251, 241)')
+ await expect(card).toHaveCSS('border-radius', '14px')
+ }
+
+ const html = await page.content()
+ expect(html).toContain('data-rsc-css-href')
+ })
+})
diff --git a/packages/react-start-rsc/package.json b/packages/react-start-rsc/package.json
index 0d5778d9a5..2465c072b0 100644
--- a/packages/react-start-rsc/package.json
+++ b/packages/react-start-rsc/package.json
@@ -69,6 +69,12 @@
"default": "./dist/esm/plugin/vite.js"
}
},
+ "./plugin/rscCssTransform": {
+ "import": {
+ "types": "./dist/esm/src/plugin/rscCssTransform.d.ts",
+ "default": "./dist/esm/plugin/rscCssTransform.js"
+ }
+ },
"./rsbuild/ssr-decode": {
"import": {
"types": "./dist/esm/src/rsbuild/ssr-decode.d.ts",
diff --git a/packages/react-start-rsc/src/createCompositeComponent.ts b/packages/react-start-rsc/src/createCompositeComponent.ts
index d86f6e1b87..c6a43b0f9d 100644
--- a/packages/react-start-rsc/src/createCompositeComponent.ts
+++ b/packages/react-start-rsc/src/createCompositeComponent.ts
@@ -9,6 +9,7 @@ import {
RSC_SLOT_USAGES_STREAM,
SERVER_COMPONENT_STREAM,
} from './ServerComponentTypes'
+import { createRscCssEnvelope } from './rscCssEnvelope'
import type {
AnyCompositeComponent,
CompositeComponentResult,
@@ -16,6 +17,7 @@ import type {
ServerComponentStream,
ValidateCompositeComponent,
} from './ServerComponentTypes'
+import type { RscCssEnvelopeOptions } from './rscCssEnvelope'
import './rscSsrHandler' // Import for global declaration side effect
@@ -51,6 +53,7 @@ import './rscSsrHandler' // Import for global declaration side effect
*/
export async function createCompositeComponent(
component: ValidateCompositeComponent,
+ options?: RscCssEnvelopeOptions,
): Promise> {
const isDev = process.env.NODE_ENV === 'development'
@@ -76,14 +79,17 @@ export async function createCompositeComponent(
// Wrapper that renders the user's component inside Flight render context
async function ServerComponentWrapper() {
- return (component as React.FC)(proxyProps)
+ return createRscCssEnvelope(
+ await (component as React.FC)(proxyProps),
+ options,
+ )
}
// Render using createElement so React calls our component during Flight rendering
// This is critical for React.cache to work - the component must be invoked
// during renderToReadableStream's execution, not before
const flightStream = renderToReadableStream(
- createElement(ServerComponentWrapper),
+ createElement(ServerComponentWrapper as any),
)
// Check if this is an SSR request (router) or a direct server function call
diff --git a/packages/react-start-rsc/src/createServerComponentFromStream.ts b/packages/react-start-rsc/src/createServerComponentFromStream.ts
index f81e37f0d9..74d75eae88 100644
--- a/packages/react-start-rsc/src/createServerComponentFromStream.ts
+++ b/packages/react-start-rsc/src/createServerComponentFromStream.ts
@@ -6,6 +6,7 @@ import { createFromReadableStream as browserDecode } from 'virtual:tanstack-rsc-
import { awaitLazyElements } from './awaitLazyElements'
import { createRscProxy } from './createRscProxy'
+import { unwrapRscCssEnvelope } from './rscCssEnvelope'
import type {
AnyCompositeComponent,
RscSlotUsageEvent,
@@ -80,9 +81,9 @@ function setupStreamDecode(stream: ReadableStream): {
await awaitLazyElements(result, (href) => {
cssHrefs.add(href)
})
- cachedTree = result
+ cachedTree = unwrapRscCssEnvelope(result)
cacheReady = true
- return result
+ return cachedTree
},
)
diff --git a/packages/react-start-rsc/src/plugin/rscCssTransform.ts b/packages/react-start-rsc/src/plugin/rscCssTransform.ts
new file mode 100644
index 0000000000..15b882ccd7
--- /dev/null
+++ b/packages/react-start-rsc/src/plugin/rscCssTransform.ts
@@ -0,0 +1,150 @@
+import type {
+ StartCompilerImportTransform,
+ StartCompilerTransformContext,
+} from '@tanstack/start-plugin-core'
+
+const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split'
+const RSC_CSS_OPTIONS_KEY = '__tanstackStartRscCss'
+
+type BabelTypes = StartCompilerTransformContext['types']
+type BabelExpression = ReturnType<
+ StartCompilerTransformContext['parseExpression']
+>
+
+type RscCssTransformKind =
+ | 'renderServerComponent'
+ | 'createCompositeComponent'
+ | 'renderToReadableStream'
+
+export function createRscCssCompilerTransforms(opts: {
+ loadCssExpression: string
+ serverFnProviderOnly?: boolean | undefined
+}): Array {
+ let loadCssExpression: BabelExpression | undefined
+
+ const getLoadCssExpression = (context: StartCompilerTransformContext) => {
+ loadCssExpression ??= context.parseExpression(opts.loadCssExpression)
+ return loadCssExpression
+ }
+
+ return [
+ createRscCssCompilerTransform({
+ serverFnProviderOnly: opts.serverFnProviderOnly,
+ getLoadCssExpression,
+ kind: 'renderServerComponent',
+ name: 'react-rsc-render-server-component-css',
+ }),
+ createRscCssCompilerTransform({
+ serverFnProviderOnly: opts.serverFnProviderOnly,
+ getLoadCssExpression,
+ kind: 'createCompositeComponent',
+ name: 'react-rsc-create-composite-component-css',
+ }),
+ createRscCssCompilerTransform({
+ serverFnProviderOnly: opts.serverFnProviderOnly,
+ getLoadCssExpression,
+ kind: 'renderToReadableStream',
+ name: 'react-rsc-render-to-readable-stream-css',
+ }),
+ ]
+}
+
+function createRscCssCompilerTransform(opts: {
+ name: string
+ kind: RscCssTransformKind
+ getLoadCssExpression: (
+ context: StartCompilerTransformContext,
+ ) => BabelExpression
+ serverFnProviderOnly?: boolean | undefined
+}): StartCompilerImportTransform {
+ return {
+ name: opts.name,
+ environment: 'server',
+ imports: [
+ {
+ libName: '@tanstack/react-start/rsc',
+ rootExport: opts.kind,
+ },
+ {
+ libName: '@tanstack/react-start-rsc',
+ rootExport: opts.kind,
+ },
+ ],
+ detect: new RegExp(`\\b${opts.kind}\\b`),
+ transform: (candidates, context) => {
+ if (
+ opts.serverFnProviderOnly &&
+ !context.id.includes(TSS_SERVERFN_SPLIT_PARAM)
+ ) {
+ return
+ }
+
+ const t = context.types
+ const loadCssExpression = opts.getLoadCssExpression(context)
+ const cloneLoadCssExpression = () => t.cloneNode(loadCssExpression)
+
+ for (const candidate of candidates) {
+ const args = candidate.path.node.arguments
+ if (args.length !== 1) continue
+
+ if (opts.kind === 'renderToReadableStream') {
+ const firstArg = args[0]
+ if (!firstArg || !t.isExpression(firstArg)) continue
+ if (!isTopLevelJsx(t, firstArg)) continue
+
+ args[0] = createCssFragment(
+ t,
+ firstArg,
+ cloneLoadCssExpression(),
+ ) as typeof firstArg
+ continue
+ }
+
+ args.push(
+ t.objectExpression([
+ t.objectProperty(
+ t.identifier(RSC_CSS_OPTIONS_KEY),
+ cloneLoadCssExpression(),
+ ),
+ ]),
+ )
+ }
+ },
+ }
+}
+
+function isTopLevelJsx(t: BabelTypes, expr: BabelExpression): boolean {
+ const unwrapped = unwrapTransparentExpression(t, expr)
+ return t.isJSXElement(unwrapped) || t.isJSXFragment(unwrapped)
+}
+
+function unwrapTransparentExpression(
+ t: BabelTypes,
+ expr: BabelExpression,
+): BabelExpression {
+ let current = expr
+ while (
+ t.isParenthesizedExpression(current) ||
+ t.isTSAsExpression(current) ||
+ t.isTSSatisfiesExpression(current) ||
+ t.isTSTypeAssertion(current) ||
+ t.isTSNonNullExpression(current)
+ ) {
+ current = current.expression
+ }
+ return current
+}
+
+function createCssFragment(
+ t: BabelTypes,
+ original: BabelExpression,
+ loadCssExpression: BabelExpression,
+) {
+ const unwrapped = unwrapTransparentExpression(t, original)
+ return t.jsxFragment(t.jsxOpeningFragment(), t.jsxClosingFragment(), [
+ t.jsxExpressionContainer(loadCssExpression),
+ t.isJSXElement(unwrapped) || t.isJSXFragment(unwrapped)
+ ? unwrapped
+ : t.jsxExpressionContainer(original),
+ ])
+}
diff --git a/packages/react-start-rsc/src/plugin/vite.ts b/packages/react-start-rsc/src/plugin/vite.ts
index e1847b0378..90da9d8b96 100644
--- a/packages/react-start-rsc/src/plugin/vite.ts
+++ b/packages/react-start-rsc/src/plugin/vite.ts
@@ -1,6 +1,7 @@
import { fileURLToPath } from 'node:url'
import path from 'pathe'
import { createVirtualModule } from '@tanstack/start-plugin-core/vite'
+import { createRscCssCompilerTransforms } from './rscCssTransform'
import type {
TanStackStartVitePluginCoreOptions,
ViteRscForwardSsrResolverStrategy,
@@ -26,6 +27,7 @@ export function configureRsc(): {
providerEnvironmentName: TanStackStartVitePluginCoreOptions['providerEnvironmentName']
ssrResolverStrategy: TanStackStartVitePluginCoreOptions['ssrResolverStrategy']
serializationAdapters: TanStackStartVitePluginCoreOptions['serializationAdapters']
+ compilerTransforms: TanStackStartVitePluginCoreOptions['compilerTransforms']
} {
const serializationAdapters: TanStackStartVitePluginCoreOptions['serializationAdapters'] =
[
@@ -56,6 +58,9 @@ export function configureRsc(): {
providerEnvironmentName: RSC_ENV_NAME,
ssrResolverStrategy,
serializationAdapters,
+ compilerTransforms: createRscCssCompilerTransforms({
+ loadCssExpression: 'import.meta.viteRsc.loadCss()',
+ }),
}
}
export function reactStartRscVitePlugin(): PluginOption {
@@ -121,6 +126,32 @@ export function reactStartRscVitePlugin(): PluginOption {
},
},
+ {
+ name: 'tanstack-react-start:rsc-scan-virtual-fallback',
+ apply: 'build',
+ applyToEnvironment(env) {
+ return env.name === RSC_ENV_NAME
+ },
+ load: {
+ filter: {
+ id: /^(virtual:tanstack-rsc-runtime|virtual:vite-rsc\/encryption-key)$/,
+ },
+ handler(id) {
+ if (this.environment.config.build.write !== false) return
+
+ if (id === RSC_RUNTIME_VIRTUAL_ID) {
+ return `export { renderToReadableStream, createFromReadableStream, createTemporaryReferenceSet, decodeReply, loadServerAction, decodeAction, decodeFormState } from '@vitejs/plugin-rsc/rsc'`
+ }
+
+ if (id === 'virtual:vite-rsc/encryption-key') {
+ return `export default () => ''`
+ }
+
+ return undefined
+ },
+ },
+ },
+
// Runtime bridge into the Vite RSC environment.
createVirtualModule({
name: 'tanstack-react-start:rsc-runtime-virtual',
diff --git a/packages/react-start-rsc/src/renderServerComponent.ts b/packages/react-start-rsc/src/renderServerComponent.ts
index f57fda3cdd..d282d63111 100644
--- a/packages/react-start-rsc/src/renderServerComponent.ts
+++ b/packages/react-start-rsc/src/renderServerComponent.ts
@@ -3,12 +3,14 @@ import { getRequest } from '@tanstack/start-server-core'
import { getStartContext } from '@tanstack/start-storage-context'
import { ReplayableStream } from './ReplayableStream'
import { RENDERABLE_RSC, SERVER_COMPONENT_STREAM } from './ServerComponentTypes'
+import { createRscCssEnvelope } from './rscCssEnvelope'
import type {
AnyRenderableServerComponent,
RenderableServerComponentBuilder,
ServerComponentStream,
ValidateRenderableServerComponent,
} from './ServerComponentTypes'
+import type { RscCssEnvelopeOptions } from './rscCssEnvelope'
import './rscSsrHandler'
// Import for global declaration side effect
@@ -57,8 +59,11 @@ export function isRenderableRscHandle(
*/
export async function renderServerComponent(
node: ValidateRenderableServerComponent,
+ options?: RscCssEnvelopeOptions,
): Promise> {
- const flightStream = renderToReadableStream(node)
+ const flightStream = renderToReadableStream(
+ createRscCssEnvelope(node, options),
+ )
// Check if this is an SSR request (router) or a direct server function call
const ctx = getStartContext({ throwIfNotFound: false })
diff --git a/packages/react-start-rsc/src/rscCssEnvelope.ts b/packages/react-start-rsc/src/rscCssEnvelope.ts
new file mode 100644
index 0000000000..54e1ac4932
--- /dev/null
+++ b/packages/react-start-rsc/src/rscCssEnvelope.ts
@@ -0,0 +1,38 @@
+import type React from 'react'
+
+const RSC_CSS_ENVELOPE_MARKER = '__tanstackStartRscCssEnvelope'
+const RSC_CSS_ENVELOPE_RESOURCES = '__tanstackStartRscCss'
+const RSC_CSS_ENVELOPE_VALUE = '__tanstackStartRscValue'
+
+export interface RscCssEnvelopeOptions {
+ [RSC_CSS_ENVELOPE_RESOURCES]?: React.ReactNode
+}
+
+export function createRscCssEnvelope(
+ value: TValue,
+ options?: RscCssEnvelopeOptions,
+): TValue | Record {
+ const resources = options?.[RSC_CSS_ENVELOPE_RESOURCES]
+ if (resources === undefined || resources === null || resources === false) {
+ return value
+ }
+
+ return {
+ [RSC_CSS_ENVELOPE_MARKER]: true,
+ [RSC_CSS_ENVELOPE_RESOURCES]: resources,
+ [RSC_CSS_ENVELOPE_VALUE]: value,
+ }
+}
+
+export function unwrapRscCssEnvelope(value: unknown): unknown {
+ if (!value || typeof value !== 'object') {
+ return value
+ }
+
+ const maybeEnvelope = value as Record
+ if (maybeEnvelope[RSC_CSS_ENVELOPE_MARKER] !== true) {
+ return value
+ }
+
+ return maybeEnvelope[RSC_CSS_ENVELOPE_VALUE]
+}
diff --git a/packages/react-start-rsc/src/serialization.server.ts b/packages/react-start-rsc/src/serialization.server.ts
index ac71ab5aea..b3e1b71cad 100644
--- a/packages/react-start-rsc/src/serialization.server.ts
+++ b/packages/react-start-rsc/src/serialization.server.ts
@@ -14,6 +14,7 @@ import {
} from './ServerComponentTypes'
import { createRscProxy } from './createRscProxy'
import { awaitLazyElements } from './awaitLazyElements'
+import { unwrapRscCssEnvelope } from './rscCssEnvelope'
import type {
AnyCompositeComponent,
ServerComponentStream,
@@ -62,11 +63,12 @@ setOnClientReference(
if (!ctx || runtime === 'rsbuild') return
if (!ctx.requestAssets) ctx.requestAssets = []
- const seenHrefs = new Set(
- ctx.requestAssets
- .filter((a) => a.tag === 'link' && a.attrs?.href)
- .map((a) => a.attrs!.href as string),
- )
+ const seenHrefs = new Set()
+ for (const asset of ctx.requestAssets) {
+ if (asset.tag === 'link' && asset.attrs?.href) {
+ seenHrefs.add(asset.attrs.href as string)
+ }
+ }
for (const href of deps.js) {
if (seenHrefs.has(href)) continue
@@ -100,13 +102,13 @@ const ssrHandler: RscSsrHandler = {
return decodeCollectorStorage.run(cssCollector, async () => {
return jsCollectorStorage.run(jsCollector, async () => {
- const tree = await ssrDecode(readableStream)
- await awaitLazyElements(tree, (href) => {
+ const decodedTree = await ssrDecode(readableStream)
+ await awaitLazyElements(decodedTree, (href) => {
cssCollector.add(href)
})
return {
- tree,
+ tree: unwrapRscCssEnvelope(decodedTree),
cssHrefs: cssCollector.size > 0 ? cssCollector : undefined,
jsPreloads: jsCollector.size > 0 ? jsCollector : undefined,
}
diff --git a/packages/react-start-rsc/tests/rscCssTransform.test.tsx b/packages/react-start-rsc/tests/rscCssTransform.test.tsx
new file mode 100644
index 0000000000..0e2c2e45cf
--- /dev/null
+++ b/packages/react-start-rsc/tests/rscCssTransform.test.tsx
@@ -0,0 +1,164 @@
+import { describe, expect, test } from 'vitest'
+import {
+ StartCompiler,
+ detectKindsInCode,
+ getLookupKindsForEnv,
+} from '../../start-plugin-core/src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../../start-plugin-core/src/start-compiler/config'
+import { createRscCssCompilerTransforms } from '../src/plugin/rscCssTransform'
+import type { StartCompilerImportTransform } from '../../start-plugin-core/src/types'
+
+const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split'
+
+async function compileWithRscCssTransform(opts: {
+ code: string
+ id?: string | undefined
+ loadCssExpression?: string | undefined
+ serverFnProviderOnly?: boolean | undefined
+}) {
+ const compilerTransforms = createRscCssCompilerTransforms({
+ loadCssExpression:
+ opts.loadCssExpression ?? 'import.meta.viteRsc.loadCss()',
+ serverFnProviderOnly: opts.serverFnProviderOnly,
+ })
+
+ const compiler = new StartCompiler({
+ env: 'server',
+ envName: 'rsc',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'rsc',
+ mode: 'build',
+ lookupKinds: getLookupKindsForEnv('server', { compilerTransforms }),
+ lookupConfigurations: getLookupConfigurationsForEnv('server', 'react', {
+ compilerTransforms,
+ }),
+ compilerTransforms,
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (id) => id,
+ })
+
+ const result = await compiler.compile({
+ id: opts.id ?? '/test/src/route.tsx',
+ code: opts.code,
+ detectedKinds: detectKindsInCode(opts.code, 'server', {
+ compilerTransforms,
+ }),
+ })
+
+ return result?.code ?? null
+}
+
+describe('RSC CSS compiler transforms', () => {
+ test('injects Vite CSS resources into all supported RSC render APIs', async () => {
+ const code = await compileWithRscCssTransform({
+ code: `
+ import {
+ createCompositeComponent,
+ renderServerComponent,
+ renderToReadableStream,
+ } from '@tanstack/react-start/rsc'
+
+ export const renderable = renderServerComponent()
+ export const composite = createCompositeComponent(() => ({
+ Card: ,
+ }))
+ export const stream = renderToReadableStream()
+ `,
+ })
+
+ expect(code).toMatchInlineSnapshot(`
+ "import { createCompositeComponent, renderServerComponent, renderToReadableStream } from '@tanstack/react-start/rsc';
+ export const renderable = renderServerComponent(, {
+ __tanstackStartRscCss: import.meta.viteRsc.loadCss()
+ });
+ export const composite = createCompositeComponent(() => ({
+ Card:
+ }), {
+ __tanstackStartRscCss: import.meta.viteRsc.loadCss()
+ });
+ export const stream = renderToReadableStream(<>{import.meta.viteRsc.loadCss()}>);"
+ `)
+ })
+
+ test('does not rewrite calls outside the transform constraints', async () => {
+ const code = await compileWithRscCssTransform({
+ code: `
+ import {
+ renderServerComponent,
+ renderToReadableStream,
+ } from '@tanstack/react-start/rsc'
+
+ const node =
+ export const existingOptions = renderServerComponent(, {
+ alreadyConfigured: true,
+ })
+ export const nonJsxStream = renderToReadableStream(node)
+ `,
+ })
+
+ expect(code).toMatchInlineSnapshot(`
+ "import { renderServerComponent, renderToReadableStream } from '@tanstack/react-start/rsc';
+ const node = ;
+ export const existingOptions = renderServerComponent(, {
+ alreadyConfigured: true
+ });
+ export const nonJsxStream = renderToReadableStream(node);"
+ `)
+ })
+
+ test('honors provider-only transforms for Rsbuild', async () => {
+ const source = `
+ import { renderServerComponent } from '@tanstack/react-start/rsc'
+
+ export const renderable = renderServerComponent()
+ `
+
+ const callerCode = await compileWithRscCssTransform({
+ code: source,
+ loadCssExpression: 'import.meta.rspackRsc.loadCss()',
+ serverFnProviderOnly: true,
+ })
+
+ const providerCode = await compileWithRscCssTransform({
+ code: source,
+ id: `/test/src/route.tsx?${TSS_SERVERFN_SPLIT_PARAM}`,
+ loadCssExpression: 'import.meta.rspackRsc.loadCss()',
+ serverFnProviderOnly: true,
+ })
+
+ expect({ callerCode, providerCode }).toMatchInlineSnapshot(`
+ {
+ "callerCode": "import { renderServerComponent } from '@tanstack/react-start/rsc';
+ export const renderable = renderServerComponent();",
+ "providerCode": "import { renderServerComponent } from '@tanstack/react-start/rsc';
+ export const renderable = renderServerComponent(, {
+ __tanstackStartRscCss: import.meta.rspackRsc.loadCss()
+ });",
+ }
+ `)
+ })
+
+ test('detects transform imports without enabling them outside server builds', () => {
+ const compilerTransforms: Array =
+ createRscCssCompilerTransforms({
+ loadCssExpression: 'import.meta.viteRsc.loadCss()',
+ })
+
+ const code = `
+ import { renderServerComponent } from '@tanstack/react-start/rsc'
+ export const renderable = renderServerComponent()
+ `
+
+ expect(detectKindsInCode(code, 'server', { compilerTransforms }))
+ .toMatchInlineSnapshot(`
+ Set {
+ "External:react-rsc-render-server-component-css",
+ }
+ `)
+ expect(
+ detectKindsInCode(code, 'client', { compilerTransforms }),
+ ).toMatchInlineSnapshot(`Set {}`)
+ })
+})
diff --git a/packages/react-start-rsc/vite.config.ts b/packages/react-start-rsc/vite.config.ts
index 919b8f9fa7..c8dfcf917b 100644
--- a/packages/react-start-rsc/vite.config.ts
+++ b/packages/react-start-rsc/vite.config.ts
@@ -36,6 +36,7 @@ export default mergeConfig(
'./src/index.rsc.ts',
'./src/serialization.client.ts',
'./src/serialization.server.ts',
+ './src/plugin/rscCssTransform.ts',
'./src/plugin/vite.ts',
'./src/entry/rsc.tsx',
'./src/rsbuild/ssr-decode.ts',
diff --git a/packages/react-start/src/plugin/rsbuild.ts b/packages/react-start/src/plugin/rsbuild.ts
index 6813007da9..7e0fcbb6c2 100644
--- a/packages/react-start/src/plugin/rsbuild.ts
+++ b/packages/react-start/src/plugin/rsbuild.ts
@@ -2,6 +2,7 @@ import {
RSBUILD_ENVIRONMENT_NAMES,
tanStackStartRsbuild,
} from '@tanstack/start-plugin-core/rsbuild'
+import { createRscCssCompilerTransforms } from '@tanstack/react-start-rsc/plugin/rscCssTransform'
import { reactStartDefaultEntryPaths } from './shared'
import type {
TanStackStartRsbuildInputConfig,
@@ -28,6 +29,9 @@ export function tanstackStart(
providerEnvironmentName: rscConfig.providerEnvironmentName,
ssrIsProvider: false,
serializationAdapters: rscConfig.serializationAdapters,
+ compilerTransforms: rscConfig.compilerTransforms,
+ serverFnProviderModuleDirectives:
+ rscConfig.serverFnProviderModuleDirectives,
rsc: true,
}
}
@@ -47,6 +51,8 @@ export function tanstackStart(
function configureRscRsbuild(): {
providerEnvironmentName: TanStackStartRsbuildPluginCoreOptions['providerEnvironmentName']
serializationAdapters: TanStackStartRsbuildPluginCoreOptions['serializationAdapters']
+ compilerTransforms: TanStackStartRsbuildPluginCoreOptions['compilerTransforms']
+ serverFnProviderModuleDirectives: TanStackStartRsbuildPluginCoreOptions['serverFnProviderModuleDirectives']
} {
return {
providerEnvironmentName: RSBUILD_ENVIRONMENT_NAMES.server,
@@ -64,5 +70,10 @@ function configureRscRsbuild(): {
},
},
],
+ compilerTransforms: createRscCssCompilerTransforms({
+ loadCssExpression: 'import.meta.rspackRsc.loadCss()',
+ serverFnProviderOnly: true,
+ }),
+ serverFnProviderModuleDirectives: ['use server-entry'],
}
}
diff --git a/packages/react-start/src/plugin/vite.ts b/packages/react-start/src/plugin/vite.ts
index 8011ece031..8849f39ea6 100644
--- a/packages/react-start/src/plugin/vite.ts
+++ b/packages/react-start/src/plugin/vite.ts
@@ -45,6 +45,7 @@ export function tanstackStart(
ssrIsProvider: false,
ssrResolverStrategy: rscConfig.ssrResolverStrategy,
serializationAdapters: rscConfig.serializationAdapters,
+ compilerTransforms: rscConfig.compilerTransforms,
}
}
return [
diff --git a/packages/start-plugin-core/src/index.ts b/packages/start-plugin-core/src/index.ts
index bc377e00b6..a6ee23a345 100644
--- a/packages/start-plugin-core/src/index.ts
+++ b/packages/start-plugin-core/src/index.ts
@@ -1,3 +1,8 @@
export type { TanStackStartInputConfig } from './schema'
-export type { TanStackStartCoreOptions } from './types'
+export type {
+ StartCompilerImportTransform,
+ StartCompilerTransformCandidate,
+ StartCompilerTransformContext,
+ TanStackStartCoreOptions,
+} from './types'
export { START_ENVIRONMENT_NAMES } from './constants'
diff --git a/packages/start-plugin-core/src/rsbuild/index.ts b/packages/start-plugin-core/src/rsbuild/index.ts
index 5f9b6ef526..46d1f1b58b 100644
--- a/packages/start-plugin-core/src/rsbuild/index.ts
+++ b/packages/start-plugin-core/src/rsbuild/index.ts
@@ -1,4 +1,9 @@
export { RSBUILD_ENVIRONMENT_NAMES } from './planning'
export type { TanStackStartRsbuildPluginCoreOptions } from './types'
export type { TanStackStartRsbuildInputConfig } from './schema'
+export type {
+ StartCompilerImportTransform,
+ StartCompilerTransformCandidate,
+ StartCompilerTransformContext,
+} from '../types'
export { tanStackStartRsbuild } from './plugin'
diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts
index da11d65b35..08d299e507 100644
--- a/packages/start-plugin-core/src/rsbuild/plugin.ts
+++ b/packages/start-plugin-core/src/rsbuild/plugin.ts
@@ -239,6 +239,9 @@ export function tanStackStartRsbuild(
root: () => resolvedStartConfig.root || process.cwd(),
providerEnvName: serverFnProviderEnv,
generateFunctionId: startPluginOpts.serverFns?.generateFunctionId,
+ compilerTransforms: corePluginOpts.compilerTransforms,
+ serverFnProviderModuleDirectives:
+ corePluginOpts.serverFnProviderModuleDirectives,
serverFnsById,
onServerFnsByIdChange: () => {
updateServerFnResolver?.()
diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts
index 6d5d580dcb..066d5b2867 100644
--- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts
+++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts
@@ -11,7 +11,10 @@ import {
import { cleanId } from '../start-compiler/utils'
import { RSBUILD_ENVIRONMENT_NAMES } from './planning'
import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core'
-import type { CompileStartFrameworkOptions } from '../types'
+import type {
+ CompileStartFrameworkOptions,
+ StartCompilerImportTransform,
+} from '../types'
import type {
DevServerFnModuleSpecifierEncoder,
GenerateFunctionIdFnOptional,
@@ -36,6 +39,8 @@ export interface StartCompilerHostOptions {
root: string | (() => string)
providerEnvName: string
generateFunctionId?: GenerateFunctionIdFnOptional
+ compilerTransforms?: Array | undefined
+ serverFnProviderModuleDirectives?: ReadonlyArray | undefined
serverFnsById?: Record
onServerFnsByIdChange?: () => void
}
@@ -79,11 +84,21 @@ export function registerStartCompilerTransforms(
// Pre-compute code filter patterns per environment type
const codeFilters: Record<'client' | 'server', Array> = {
client: getTransformCodeFilterForEnv('client'),
- server: getTransformCodeFilterForEnv('server'),
+ server: getTransformCodeFilterForEnv('server', {
+ compilerTransforms: opts.compilerTransforms,
+ }),
}
for (const env of environments) {
const envCodeFilters = codeFilters[env.type]
+ const compilerTransforms =
+ env.name === RSBUILD_ENVIRONMENT_NAMES.server
+ ? opts.compilerTransforms
+ : undefined
+ const serverFnProviderModuleDirectives =
+ env.name === opts.providerEnvName
+ ? opts.serverFnProviderModuleDirectives
+ : undefined
api.transform(
{
@@ -113,6 +128,8 @@ export function registerStartCompilerTransforms(
framework: opts.framework,
providerEnvName: opts.providerEnvName,
generateFunctionId: opts.generateFunctionId,
+ compilerTransforms,
+ serverFnProviderModuleDirectives,
onServerFnsById,
getKnownServerFns: () => serverFnsById,
encodeModuleSpecifierInDev: isDev
@@ -180,7 +197,9 @@ export function registerStartCompilerTransforms(
compilers.set(env.name, compiler)
}
- const detectedKinds = detectKindsInCode(code, env.type)
+ const detectedKinds = detectKindsInCode(code, env.type, {
+ compilerTransforms,
+ })
const result = await compiler.compile({ id, code, detectedKinds })
if (!result) {
diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts
index cc68c65ac3..ec42547fc2 100644
--- a/packages/start-plugin-core/src/start-compiler/compiler.ts
+++ b/packages/start-plugin-core/src/start-compiler/compiler.ts
@@ -21,7 +21,11 @@ import type {
RewriteCandidate,
ServerFn,
} from './types'
-import type { CompileStartFrameworkOptions } from '../types'
+import type {
+ CompileStartFrameworkOptions,
+ StartCompilerEnvironment,
+ StartCompilerImportTransform,
+} from '../types'
type Binding =
| {
@@ -38,7 +42,7 @@ type Binding =
type Kind = 'None' | `Root` | `Builder` | LookupKind
-export type LookupKind =
+export type BuiltInLookupKind =
| 'ServerFn'
| 'Middleware'
| 'IsomorphicFn'
@@ -46,6 +50,10 @@ export type LookupKind =
| 'ClientOnlyFn'
| 'ClientOnlyJSX'
+export type ExternalLookupKind = `External:${string}`
+
+export type LookupKind = BuiltInLookupKind | ExternalLookupKind
+
// Detection strategy for each kind
type MethodChainSetup = {
type: 'methodChain'
@@ -54,16 +62,37 @@ type MethodChainSetup = {
type DirectCallSetup = {
type: 'directCall'
// The factory function name used to create this kind (e.g., 'createServerOnlyFn')
- factoryName: string
+ factoryNames: Set
}
type JSXSetup = { type: 'jsx'; componentName: string }
function isLookupKind(kind: Kind): kind is LookupKind {
- return kind in LookupSetup
+ return kind in BuiltInLookupSetup || isExternalLookupKind(kind)
+}
+
+export function getExternalLookupKind(
+ transform: StartCompilerImportTransform,
+): ExternalLookupKind {
+ return `External:${transform.name}`
+}
+
+function isExternalLookupKind(kind: Kind): kind is ExternalLookupKind {
+ return typeof kind === 'string' && kind.startsWith('External:')
+}
+
+export function isCompilerTransformEnabledForEnv(
+ transform: StartCompilerImportTransform,
+ env: StartCompilerEnvironment,
+): boolean {
+ if (!transform.environment) return true
+ if (Array.isArray(transform.environment)) {
+ return transform.environment.includes(env)
+ }
+ return transform.environment === env
}
-const LookupSetup: Record<
- LookupKind,
+const BuiltInLookupSetup: Record<
+ BuiltInLookupKind,
MethodChainSetup | DirectCallSetup | JSXSetup
> = {
ServerFn: {
@@ -78,8 +107,14 @@ const LookupSetup: Record<
type: 'methodChain',
candidateCallIdentifier: new Set(['server', 'client']),
},
- ServerOnlyFn: { type: 'directCall', factoryName: 'createServerOnlyFn' },
- ClientOnlyFn: { type: 'directCall', factoryName: 'createClientOnlyFn' },
+ ServerOnlyFn: {
+ type: 'directCall',
+ factoryNames: new Set(['createServerOnlyFn']),
+ },
+ ClientOnlyFn: {
+ type: 'directCall',
+ factoryNames: new Set(['createClientOnlyFn']),
+ },
ClientOnlyJSX: { type: 'jsx', componentName: 'ClientOnly' },
}
@@ -87,7 +122,7 @@ const LookupSetup: Record<
// These patterns are used for:
// 1. Pre-scanning code to determine which kinds to look for (before AST parsing)
// 2. Deriving the plugin's transform code filter
-export const KindDetectionPatterns: Record = {
+export const KindDetectionPatterns: Record = {
ServerFn: /\bcreateServerFn\b|\.\s*handler\s*\(/,
Middleware: /createMiddleware/,
IsomorphicFn: /createIsomorphicFn/,
@@ -97,7 +132,10 @@ export const KindDetectionPatterns: Record = {
}
// Which kinds are valid for each environment
-export const LookupKindsPerEnv: Record<'client' | 'server', Set> = {
+export const LookupKindsPerEnv: Record<
+ 'client' | 'server',
+ Set
+> = {
client: new Set([
'Middleware',
'ServerFn',
@@ -114,6 +152,21 @@ export const LookupKindsPerEnv: Record<'client' | 'server', Set> = {
] as const),
}
+export function getLookupKindsForEnv(
+ env: 'client' | 'server',
+ opts?: {
+ compilerTransforms?: Array | undefined
+ },
+): Set {
+ const kinds: Set = new Set(LookupKindsPerEnv[env])
+ for (const transform of opts?.compilerTransforms ?? []) {
+ if (isCompilerTransformEnabledForEnv(transform, env)) {
+ kinds.add(getExternalLookupKind(transform))
+ }
+ }
+ return kinds
+}
+
/**
* Handler type for processing candidates of a specific kind.
* The kind is passed as the third argument to allow shared handlers (like handleEnvOnlyFn).
@@ -121,15 +174,15 @@ export const LookupKindsPerEnv: Record<'client' | 'server', Set> = {
type KindHandler = (
candidates: Array,
context: CompilationContext,
- kind: LookupKind,
+ kind: BuiltInLookupKind,
) => void
/**
* Registry mapping each LookupKind to its handler function.
* When adding a new kind, add its handler here.
*/
-const KindHandlers: Record<
- Exclude,
+const BuiltInKindHandlers: Record<
+ Exclude,
KindHandler
> = {
ServerFn: handleCreateServerFn,
@@ -140,8 +193,14 @@ const KindHandlers: Record<
// ClientOnlyJSX is handled separately via JSX traversal, not here
}
+const BuiltInKindHandlerOrder: Array<
+ Exclude
+> = ['ServerFn', 'Middleware', 'IsomorphicFn', 'ServerOnlyFn', 'ClientOnlyFn']
+
// All lookup kinds as an array for iteration with proper typing
-const AllLookupKinds = Object.keys(LookupSetup) as Array
+const AllBuiltInLookupKinds = Object.keys(
+ BuiltInLookupSetup,
+) as Array
/**
* Detects which LookupKinds are present in the code using string matching.
@@ -150,24 +209,34 @@ const AllLookupKinds = Object.keys(LookupSetup) as Array
export function detectKindsInCode(
code: string,
env: 'client' | 'server',
+ opts?: {
+ compilerTransforms?: Array | undefined
+ },
): Set {
const detected = new Set()
- const validForEnv = LookupKindsPerEnv[env]
+ const validForEnv = getLookupKindsForEnv(env, opts)
- for (const kind of AllLookupKinds) {
+ for (const kind of AllBuiltInLookupKinds) {
if (validForEnv.has(kind) && KindDetectionPatterns[kind].test(code)) {
detected.add(kind)
}
}
+ for (const transform of opts?.compilerTransforms ?? []) {
+ if (!isCompilerTransformEnabledForEnv(transform, env)) continue
+ if (transform.detect.test(code)) {
+ detected.add(getExternalLookupKind(transform))
+ }
+ }
+
return detected
}
// Pre-computed map: identifier name -> Set for fast candidate detection (method chain only)
// Multiple kinds can share the same identifier (e.g., 'server' and 'client' are used by both Middleware and IsomorphicFn)
const IdentifierToKinds = new Map>()
-for (const kind of AllLookupKinds) {
- const setup = LookupSetup[kind]
+for (const kind of AllBuiltInLookupKinds) {
+ const setup = BuiltInLookupSetup[kind]
if (setup.type === 'methodChain') {
for (const id of setup.candidateCallIdentifier) {
let kinds = IdentifierToKinds.get(id)
@@ -180,15 +249,19 @@ for (const kind of AllLookupKinds) {
}
}
-// Factory function names for direct call patterns.
-// Used to filter nested candidates - we only want to include actual factory calls,
-// not invocations of already-created functions (e.g., `myServerFn()` should NOT be a candidate)
-const DirectCallFactoryNames = new Set()
-for (const kind of AllLookupKinds) {
- const setup = LookupSetup[kind]
- if (setup.type === 'directCall') {
- DirectCallFactoryNames.add(setup.factoryName)
+function getLookupSetup(
+ kind: LookupKind,
+ externalLookupSetup?: Map,
+): MethodChainSetup | DirectCallSetup | JSXSetup | undefined {
+ if (kind in BuiltInLookupSetup) {
+ return BuiltInLookupSetup[kind as BuiltInLookupKind]
+ }
+
+ if (isExternalLookupKind(kind)) {
+ return externalLookupSetup?.get(kind)
}
+
+ return undefined
}
export type LookupConfig = {
@@ -206,19 +279,6 @@ interface ModuleInfo {
reExportAllSources: Array
}
-/**
- * Computes whether any file kinds need direct-call candidate detection.
- * This applies to directCall types (ServerOnlyFn, ClientOnlyFn).
- */
-function needsDirectCallDetection(kinds: Set): boolean {
- for (const kind of kinds) {
- if (LookupSetup[kind].type === 'directCall') {
- return true
- }
- }
- return false
-}
-
/**
* Checks if all kinds in the set are guaranteed to be top-level only.
* Only ServerFn is always declared at module level (must be assigned to a variable).
@@ -232,9 +292,12 @@ function areAllKindsTopLevelOnly(kinds: Set): boolean {
/**
* Checks if we need to detect JSX elements (e.g., ).
*/
-function needsJSXDetection(kinds: Set): boolean {
+function needsJSXDetection(
+ kinds: Set,
+ externalLookupSetup?: Map,
+): boolean {
for (const kind of kinds) {
- if (LookupSetup[kind].type === 'jsx') {
+ if (getLookupSetup(kind, externalLookupSetup)?.type === 'jsx') {
return true
}
}
@@ -247,7 +310,11 @@ function needsJSXDetection(kinds: Set): boolean {
* This is stricter than top-level detection because we need to filter out
* invocations of existing server functions (e.g., `myServerFn()`).
*/
-function isNestedDirectCallCandidate(node: t.CallExpression): boolean {
+function isNestedDirectCallCandidate(
+ node: t.CallExpression,
+ lookupKinds: Set,
+ externalLookupSetup?: Map,
+): boolean {
let calleeName: string | undefined
if (t.isIdentifier(node.callee)) {
calleeName = node.callee.name
@@ -257,7 +324,15 @@ function isNestedDirectCallCandidate(node: t.CallExpression): boolean {
) {
calleeName = node.callee.property.name
}
- return calleeName !== undefined && DirectCallFactoryNames.has(calleeName)
+ if (!calleeName) return false
+ for (const kind of lookupKinds) {
+ if (isExternalLookupKind(kind)) continue
+ const setup = getLookupSetup(kind, externalLookupSetup)
+ if (setup?.type === 'directCall' && setup.factoryNames.has(calleeName)) {
+ return true
+ }
+ }
+ return false
}
function isSimpleDirectCallExpression(node: t.CallExpression): boolean {
@@ -304,14 +379,87 @@ function isTopLevelDirectCallCandidate(
function isDirectCallCandidateForKind(
kind: Exclude,
+ externalLookupSetup?: Map,
+): boolean {
+ return getLookupSetup(kind, externalLookupSetup)?.type === 'directCall'
+}
+
+function hasBuiltInDirectCallKinds(kinds: Set): boolean {
+ for (const kind of kinds) {
+ if (isExternalLookupKind(kind)) continue
+ if (BuiltInLookupSetup[kind].type === 'directCall') return true
+ }
+ return false
+}
+
+function hasExternalLookupKinds(kinds: Set): boolean {
+ for (const kind of kinds) {
+ if (isExternalLookupKind(kind)) return true
+ }
+ return false
+}
+
+interface ExternalDirectCallCandidates {
+ identifiers: Map
+ namespaces: Map>
+}
+
+interface CallExpressionCandidate {
+ path: babel.NodePath
+ /** Set when import scanning already proved the call's lookup kind. */
+ kind?: Exclude
+}
+
+function hasExternalDirectCallCandidates(
+ candidates: ExternalDirectCallCandidates,
): boolean {
- return LookupSetup[kind].type === 'directCall'
+ return candidates.identifiers.size > 0 || candidates.namespaces.size > 0
+}
+
+function getExternalDirectCallCandidateKind(
+ path: babel.NodePath,
+ candidates: ExternalDirectCallCandidates,
+): ExternalLookupKind | undefined {
+ const node = path.node
+
+ if (t.isIdentifier(node.callee)) {
+ const kind = candidates.identifiers.get(node.callee.name)
+ if (!kind) return undefined
+
+ const binding = path.scope.getBinding(node.callee.name)
+ return binding?.path.isImportSpecifier() ? kind : undefined
+ }
+
+ if (
+ t.isMemberExpression(node.callee) &&
+ t.isIdentifier(node.callee.object) &&
+ t.isIdentifier(node.callee.property)
+ ) {
+ const kind = candidates.namespaces
+ .get(node.callee.object.name)
+ ?.get(node.callee.property.name)
+ if (!kind) return undefined
+
+ const binding = path.scope.getBinding(node.callee.object.name)
+ return binding?.path.isImportNamespaceSpecifier() ? kind : undefined
+ }
+
+ return undefined
}
export class StartCompiler {
private moduleCache = new Map()
private initialized = false
private validLookupKinds: Set
+ private externalTransformsByKind = new Map<
+ ExternalLookupKind,
+ StartCompilerImportTransform
+ >()
+ private externalLookupSetup = new Map()
+ private externalDirectCallKindsBySource = new Map<
+ string,
+ Map
+ >()
private resolveIdCache = new Map()
private exportResolutionCache = new Map<
string,
@@ -360,6 +508,8 @@ export class StartCompiler {
* Called after each file is compiled with its new functions.
*/
onServerFnsById?: (d: Record) => void
+ compilerTransforms?: Array | undefined
+ serverFnProviderModuleDirectives?: ReadonlyArray | undefined
/**
* Returns the currently known server functions from previous builds.
* Used by server callers to look up canonical extracted filenames.
@@ -369,6 +519,31 @@ export class StartCompiler {
},
) {
this.validLookupKinds = options.lookupKinds
+ for (const transform of options.compilerTransforms ?? []) {
+ const kind = getExternalLookupKind(transform)
+ if (!this.validLookupKinds.has(kind)) continue
+
+ this.externalTransformsByKind.set(kind, transform)
+
+ const factoryNames = new Set()
+ for (const entry of transform.imports) {
+ factoryNames.add(entry.rootExport)
+
+ let rootExports = this.externalDirectCallKindsBySource.get(
+ entry.libName,
+ )
+ if (!rootExports) {
+ rootExports = new Map()
+ this.externalDirectCallKindsBySource.set(entry.libName, rootExports)
+ }
+ rootExports.set(entry.rootExport, kind)
+ }
+
+ this.externalLookupSetup.set(kind, {
+ type: 'directCall',
+ factoryNames,
+ })
+ }
}
/**
@@ -446,6 +621,46 @@ export class StartCompiler {
return this.options.mode ?? 'dev'
}
+ private getExternalDirectCallCandidates(
+ kinds: Set,
+ moduleInfo: ModuleInfo,
+ ): ExternalDirectCallCandidates {
+ const identifiers = new Map()
+ const namespaces = new Map>()
+
+ if (this.externalDirectCallKindsBySource.size === 0) {
+ return { identifiers, namespaces }
+ }
+
+ for (const [localName, binding] of moduleInfo.bindings) {
+ if (binding.type !== 'import') continue
+
+ const rootExports = this.externalDirectCallKindsBySource.get(
+ binding.source,
+ )
+ if (!rootExports) continue
+
+ if (binding.importedName === '*') {
+ const namespaceExports = new Map()
+ for (const [rootExport, kind] of rootExports) {
+ if (kinds.has(kind)) {
+ namespaceExports.set(rootExport, kind)
+ }
+ }
+ if (namespaceExports.size > 0) {
+ namespaces.set(localName, namespaceExports)
+ }
+ } else {
+ const kind = rootExports.get(binding.importedName)
+ if (kind && kinds.has(kind)) {
+ identifiers.set(localName, kind)
+ }
+ }
+ }
+
+ return { identifiers, namespaces }
+ }
+
private async resolveIdCached(id: string, importer?: string) {
if (this.mode === 'dev') {
return this.options.resolveId(id, importer)
@@ -482,7 +697,7 @@ export class StartCompiler {
]),
)
- // Register start-client-core exports for internal package usage (e.g., react-start-rsc).
+ // Register start-client-core exports for internal package usage.
// These don't need module resolution - only the knownRootImports fast path.
this.knownRootImports.set(
'@tanstack/start-client-core',
@@ -506,8 +721,8 @@ export class StartCompiler {
// For JSX lookups (e.g., ClientOnlyJSX), we only need the knownRootImports
// fast path to verify imports. Skip synthetic root module setup.
if (config.kind !== 'Root') {
- const setup = LookupSetup[config.kind]
- if (setup.type === 'jsx') {
+ const setup = getLookupSetup(config.kind, this.externalLookupSetup)
+ if (setup?.type === 'jsx') {
continue
}
}
@@ -780,7 +995,12 @@ export class StartCompiler {
return null
}
- const checkDirectCalls = needsDirectCallDetection(fileKinds)
+ const hasExternalKinds = hasExternalLookupKinds(fileKinds)
+ const checkDirectCalls =
+ hasBuiltInDirectCallKinds(fileKinds) ||
+ (fileKinds.has('ServerFn') &&
+ !hasExternalKinds &&
+ hasBuiltInDirectCallKinds(this.validLookupKinds))
// Optimization: ServerFn is always a top-level declaration (must be assigned to a variable).
// If the file only has ServerFn, we can skip full AST traversal and only visit
// the specific top-level declarations that have candidates.
@@ -793,7 +1013,7 @@ export class StartCompiler {
// Single-pass traversal to:
// 1. Collect candidate paths (only candidates, not all CallExpressions)
// 2. Build a map for looking up paths of nested calls in method chains
- const candidatePaths: Array> = []
+ const candidatePaths: Array = []
// Map for nested chain lookup - only populated for CallExpressions that are
// part of a method chain (callee.object is a CallExpression)
const chainCallPaths = new Map<
@@ -803,9 +1023,16 @@ export class StartCompiler {
// JSX candidates (e.g., )
const jsxCandidatePaths: Array> = []
- const checkJSX = needsJSXDetection(fileKinds)
+ const checkJSX = needsJSXDetection(fileKinds, this.externalLookupSetup)
// Get module info that was just cached by ingestModule
const moduleInfo = this.moduleCache.get(id)!
+ const externalDirectCallCandidates = this.getExternalDirectCallCandidates(
+ fileKinds,
+ moduleInfo,
+ )
+ const checkExternalDirectCalls = hasExternalDirectCallCandidates(
+ externalDirectCallCandidates,
+ )
if (canUseFastPath) {
// Fast path: only visit top-level statements that have potential candidates
@@ -829,7 +1056,8 @@ export class StartCompiler {
if (decl.init && t.isCallExpression(decl.init)) {
if (
isMethodChainCandidate(decl.init, fileKinds) ||
- isTopLevelDirectCallCandidateNode(decl.init)
+ (checkDirectCalls &&
+ isTopLevelDirectCallCandidateNode(decl.init))
) {
candidateIndices.push(i)
break // Only need to mark this statement once
@@ -870,12 +1098,23 @@ export class StartCompiler {
// Method chain pattern
if (isMethodChainCandidate(node, fileKinds)) {
- candidatePaths.push(path)
+ candidatePaths.push({ path })
return
}
+ if (checkExternalDirectCalls) {
+ const kind = getExternalDirectCallCandidateKind(
+ path,
+ externalDirectCallCandidates,
+ )
+ if (kind) {
+ candidatePaths.push({ path, kind })
+ return
+ }
+ }
+
if (isTopLevelDirectCallCandidate(path)) {
- candidatePaths.push(path)
+ candidatePaths.push({ path })
}
},
})
@@ -904,19 +1143,39 @@ export class StartCompiler {
// Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.)
if (isMethodChainCandidate(node, fileKinds)) {
- candidatePaths.push(path)
+ candidatePaths.push({ path })
return
}
- if (isTopLevelDirectCallCandidate(path)) {
- candidatePaths.push(path)
+ // External direct-call transforms are import-bound. Direct imports
+ // already identify the transform kind, so skip async import tracing.
+ if (checkExternalDirectCalls) {
+ const kind = getExternalDirectCallCandidateKind(
+ path,
+ externalDirectCallCandidates,
+ )
+ if (kind) {
+ candidatePaths.push({ path, kind })
+ return
+ }
+ }
+
+ if (checkDirectCalls && isTopLevelDirectCallCandidate(path)) {
+ candidatePaths.push({ path })
return
}
// Pattern 2: Direct call pattern
if (checkDirectCalls) {
- if (isNestedDirectCallCandidate(node)) {
- candidatePaths.push(path)
+ if (
+ isNestedDirectCallCandidate(
+ node,
+ fileKinds,
+ this.externalLookupSetup,
+ )
+ ) {
+ candidatePaths.push({ path })
+ return
}
}
},
@@ -955,13 +1214,34 @@ export class StartCompiler {
return null
}
- // Resolve all candidates in parallel to determine their kinds
- const resolvedCandidates = await Promise.all(
- candidatePaths.map(async (path) => ({
- path,
- kind: await this.resolveExprKind(path.node, id),
- })),
- )
+ // Resolve only candidates whose import scan did not already prove the kind.
+ const resolvedCandidates: Array<{
+ path: babel.NodePath
+ kind: Kind
+ }> = []
+ const unresolvedCandidates: Array = []
+
+ for (const candidate of candidatePaths) {
+ if (candidate.kind) {
+ resolvedCandidates.push({
+ path: candidate.path,
+ kind: candidate.kind,
+ })
+ } else {
+ unresolvedCandidates.push(candidate)
+ }
+ }
+
+ if (unresolvedCandidates.length > 0) {
+ resolvedCandidates.push(
+ ...(await Promise.all(
+ unresolvedCandidates.map(async (candidate) => ({
+ path: candidate.path,
+ kind: await this.resolveExprKind(candidate.path.node, id),
+ })),
+ )),
+ )
+ }
// Filter to valid candidates
const validCandidates = resolvedCandidates.filter(({ path, kind }) => {
@@ -976,7 +1256,7 @@ export class StartCompiler {
kind !== 'ClientOnlyJSX' &&
!isMethodChainCandidate(path.node, fileKinds)
) {
- return isDirectCallCandidateForKind(kind)
+ return isDirectCallCandidateForKind(kind, this.externalLookupSetup)
}
return true
@@ -1062,9 +1342,16 @@ export class StartCompiler {
root: this.options.root,
framework: this.options.framework,
providerEnvName: this.options.providerEnvName,
+ types: t,
+ parseExpression: (expressionCode) =>
+ babel.template.expression(expressionCode, {
+ placeholderPattern: false,
+ })() as t.Expression,
generateFunctionId: (opts) => this.generateFunctionId(opts),
getKnownServerFns: this.options.getKnownServerFns,
+ serverFnProviderModuleDirectives:
+ this.options.serverFnProviderModuleDirectives,
onServerFnsById: this.options.onServerFnsById,
}
@@ -1084,12 +1371,19 @@ export class StartCompiler {
}
}
- // Process each kind using its registered handler
- for (const [kind, candidates] of candidatesByKind) {
- const handler = KindHandlers[kind]
+ // External transforms run before built-ins by default so they can augment
+ // user handlers before server function extraction clones provider bodies.
+ this.runExternalTransforms('pre', candidatesByKind, context)
+
+ for (const kind of BuiltInKindHandlerOrder) {
+ const candidates = candidatesByKind.get(kind)
+ if (!candidates) continue
+ const handler = BuiltInKindHandlers[kind]
handler(candidates, context, kind)
}
+ this.runExternalTransforms('post', candidatesByKind, context)
+
// Handle JSX candidates (e.g., )
// Validation was already done during traversal - just call the handler
for (const jsxPath of jsxCandidatePaths) {
@@ -1116,6 +1410,24 @@ export class StartCompiler {
return result
}
+ private runExternalTransforms(
+ order: 'pre' | 'post',
+ candidatesByKind: Map<
+ Exclude,
+ Array
+ >,
+ context: CompilationContext,
+ ) {
+ for (const [kind, transform] of this.externalTransformsByKind) {
+ if ((transform.order ?? 'pre') !== order) continue
+
+ const candidates = candidatesByKind.get(kind)
+ if (!candidates) continue
+
+ transform.transform(candidates, context)
+ }
+ }
+
private async resolveIdentifierKind(
ident: string,
id: string,
@@ -1293,7 +1605,8 @@ export class StartCompiler {
// `const createSO = createServerOnlyFn` should still propagate the kind.
if (
isLookupKind(resolvedKind) &&
- LookupSetup[resolvedKind].type === 'directCall' &&
+ getLookupSetup(resolvedKind, this.externalLookupSetup)?.type ===
+ 'directCall' &&
binding.init &&
t.isCallExpression(binding.init)
) {
@@ -1420,6 +1733,12 @@ export class StartCompiler {
binding.type === 'import' &&
binding.importedName === '*'
) {
+ const knownExports = this.knownRootImports.get(binding.source)
+ const knownKind = knownExports?.get(callee.property.name)
+ if (knownKind) {
+ return knownKind
+ }
+
// resolve the property from the target module
const targetModuleId = await this.resolveIdCached(
binding.source,
diff --git a/packages/start-plugin-core/src/start-compiler/config.ts b/packages/start-plugin-core/src/start-compiler/config.ts
index 1ad26d0fa0..e9d7d95e5e 100644
--- a/packages/start-plugin-core/src/start-compiler/config.ts
+++ b/packages/start-plugin-core/src/start-compiler/config.ts
@@ -1,27 +1,47 @@
-import { KindDetectionPatterns, LookupKindsPerEnv } from './compiler'
-import type { LookupConfig, LookupKind } from './compiler'
-import type { CompileStartFrameworkOptions } from '../types'
+import {
+ KindDetectionPatterns,
+ getExternalLookupKind,
+ getLookupKindsForEnv,
+ isCompilerTransformEnabledForEnv,
+} from './compiler'
+import type { BuiltInLookupKind, LookupConfig } from './compiler'
+import type {
+ CompileStartFrameworkOptions,
+ StartCompilerImportTransform,
+} from '../types'
export function getTransformCodeFilterForEnv(
env: 'client' | 'server',
+ opts?: {
+ compilerTransforms?: Array | undefined
+ },
): Array {
- const validKinds = LookupKindsPerEnv[env]
+ const validKinds = getLookupKindsForEnv(env, opts)
const patterns: Array = []
for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array<
- [LookupKind, RegExp]
+ [BuiltInLookupKind, RegExp]
>) {
if (validKinds.has(kind)) {
patterns.push(pattern)
}
}
+ for (const transform of opts?.compilerTransforms ?? []) {
+ if (isCompilerTransformEnabledForEnv(transform, env)) {
+ patterns.push(transform.detect)
+ }
+ }
+
return patterns
}
export function getLookupConfigurationsForEnv(
env: 'client' | 'server',
framework: CompileStartFrameworkOptions,
+ opts?: {
+ compilerTransforms?: Array | undefined
+ },
): Array {
const commonConfigs: Array = [
{
@@ -46,6 +66,20 @@ export function getLookupConfigurationsForEnv(
},
]
+ const externalConfigs: Array = []
+ for (const transform of opts?.compilerTransforms ?? []) {
+ if (!isCompilerTransformEnabledForEnv(transform, env)) continue
+
+ const kind = getExternalLookupKind(transform)
+ for (const imported of transform.imports) {
+ externalConfigs.push({
+ libName: imported.libName,
+ rootExport: imported.rootExport,
+ kind,
+ })
+ }
+ }
+
if (env === 'client') {
return [
{
@@ -59,10 +93,11 @@ export function getLookupConfigurationsForEnv(
kind: 'Root',
},
...commonConfigs,
+ ...externalConfigs,
]
}
- return [
+ const serverConfigs: Array = [
...commonConfigs,
{
libName: `@tanstack/${framework}-router`,
@@ -70,4 +105,6 @@ export function getLookupConfigurationsForEnv(
kind: 'ClientOnlyJSX',
},
]
+
+ return [...serverConfigs, ...externalConfigs]
}
diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts
index bbb1e3d7fc..ed2728a20d 100644
--- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts
+++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts
@@ -196,6 +196,13 @@ export function handleCreateServerFn(
}
const isProviderFile = context.id.includes(TSS_SERVERFN_SPLIT_PARAM)
+ if (isProviderFile && context.serverFnProviderModuleDirectives) {
+ ensureDirectivePrologue(
+ context.ast,
+ context.serverFnProviderModuleDirectives,
+ )
+ }
+
// Get environment-specific configuration
const envConfig = getEnvConfig(context, isProviderFile)
@@ -504,3 +511,25 @@ function safeRemoveExports(ast: t.File): void {
return node
})
}
+
+function ensureDirectivePrologue(
+ ast: t.File,
+ directiveValues: ReadonlyArray,
+): void {
+ const directives = ast.program.directives
+ const existingDirectives = new Set(
+ directives.map((directive) => directive.value.value),
+ )
+ const missingDirectives: Array = []
+
+ for (const directiveValue of directiveValues) {
+ if (!directiveValue || existingDirectives.has(directiveValue)) continue
+
+ existingDirectives.add(directiveValue)
+ missingDirectives.push(directiveValue)
+ }
+
+ for (let i = missingDirectives.length - 1; i >= 0; i--) {
+ directives.unshift(t.directive(t.directiveLiteral(missingDirectives[i]!)))
+ }
+}
diff --git a/packages/start-plugin-core/src/start-compiler/host.ts b/packages/start-plugin-core/src/start-compiler/host.ts
index 23263c418a..e1da89d7f2 100644
--- a/packages/start-plugin-core/src/start-compiler/host.ts
+++ b/packages/start-plugin-core/src/start-compiler/host.ts
@@ -1,6 +1,9 @@
-import { LookupKindsPerEnv, StartCompiler } from './compiler'
+import { StartCompiler, getLookupKindsForEnv } from './compiler'
import { getLookupConfigurationsForEnv } from './config'
-import type { CompileStartFrameworkOptions } from '../types'
+import type {
+ CompileStartFrameworkOptions,
+ StartCompilerImportTransform,
+} from '../types'
import type {
DevServerFnModuleSpecifierEncoder,
GenerateFunctionIdFnOptional,
@@ -15,6 +18,8 @@ export interface CreateStartCompilerOptions {
providerEnvName: string
mode: 'dev' | 'build'
generateFunctionId?: GenerateFunctionIdFnOptional
+ compilerTransforms?: Array | undefined
+ serverFnProviderModuleDirectives?: ReadonlyArray | undefined
onServerFnsById?: (d: Record) => void
getKnownServerFns: () => Record
encodeModuleSpecifierInDev?: DevServerFnModuleSpecifierEncoder
@@ -29,16 +34,21 @@ export function createStartCompiler(
env: options.env,
envName: options.envName,
root: options.root,
- lookupKinds: LookupKindsPerEnv[options.env],
+ lookupKinds: getLookupKindsForEnv(options.env, {
+ compilerTransforms: options.compilerTransforms,
+ }),
lookupConfigurations: getLookupConfigurationsForEnv(
options.env,
options.framework,
+ { compilerTransforms: options.compilerTransforms },
),
mode: options.mode,
framework: options.framework,
providerEnvName: options.providerEnvName,
generateFunctionId: options.generateFunctionId,
onServerFnsById: options.onServerFnsById,
+ compilerTransforms: options.compilerTransforms,
+ serverFnProviderModuleDirectives: options.serverFnProviderModuleDirectives,
getKnownServerFns: options.getKnownServerFns,
devServerFnModuleSpecifierEncoder: options.encodeModuleSpecifierInDev,
loadModule: options.loadModule,
diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts
index 11fffb01f7..725ac26f0f 100644
--- a/packages/start-plugin-core/src/start-compiler/types.ts
+++ b/packages/start-plugin-core/src/start-compiler/types.ts
@@ -1,28 +1,18 @@
import type * as babel from '@babel/core'
import type * as t from '@babel/types'
-import type { CompileStartFrameworkOptions } from '../types'
+import type { StartCompilerTransformContext } from '../types'
/**
* Context passed to all plugin handlers during compilation.
* Contains both read-only input data and mutable state that handlers update.
*/
-export interface CompilationContext {
- readonly ast: t.File
- readonly code: string
- readonly id: string
- readonly env: 'client' | 'server'
- readonly envName: string
- readonly mode: 'dev' | 'build'
- readonly root: string
- /** The framework being used (e.g., 'react', 'solid') */
- readonly framework: CompileStartFrameworkOptions
- /** The Vite environment name for the server function provider */
- readonly providerEnvName: string
-
+export interface CompilationContext extends StartCompilerTransformContext {
/** Generate a unique function ID */
generateFunctionId: GenerateFunctionIdFn
/** Get known server functions from previous builds (e.g., client build) */
getKnownServerFns: () => Record
+ /** Module-level directives to add to extracted server function provider files. */
+ serverFnProviderModuleDirectives: ReadonlyArray | undefined
/**
* Callback when server functions are discovered.
@@ -30,6 +20,7 @@ export interface CompilationContext {
*/
onServerFnsById: ((d: Record) => void) | undefined
}
+
/**
* Batched plugin handler signature.
* Receives ALL candidates of a specific kind in one call.
diff --git a/packages/start-plugin-core/src/types.ts b/packages/start-plugin-core/src/types.ts
index a5f4324315..bc76726875 100644
--- a/packages/start-plugin-core/src/types.ts
+++ b/packages/start-plugin-core/src/types.ts
@@ -1,3 +1,5 @@
+import type * as babel from '@babel/core'
+import type * as t from '@babel/types'
import type { TanStackStartOutputConfig } from './schema'
export type CompileStartFrameworkOptions = 'react' | 'solid' | 'vue'
@@ -20,6 +22,46 @@ export type SerializationAdapterConfig =
| SerializationAdapterModuleRef
| SerializationAdapterByRuntime
+export type StartCompilerEnvironment = 'client' | 'server'
+
+export interface StartCompilerImportTransformImport {
+ libName: string
+ rootExport: string
+}
+
+export interface StartCompilerTransformCandidate {
+ path: babel.NodePath
+}
+
+export interface StartCompilerTransformContext {
+ readonly ast: t.File
+ readonly code: string
+ readonly id: string
+ readonly env: StartCompilerEnvironment
+ readonly envName: string
+ readonly mode: 'dev' | 'build'
+ readonly root: string
+ readonly framework: CompileStartFrameworkOptions
+ readonly providerEnvName: string
+ readonly types: typeof t
+ parseExpression: (code: string) => t.Expression
+}
+
+export interface StartCompilerImportTransform {
+ name: string
+ environment?:
+ | StartCompilerEnvironment
+ | Array
+ | undefined
+ imports: Array
+ detect: RegExp
+ order?: 'pre' | 'post' | undefined
+ transform: (
+ candidates: Array,
+ context: StartCompilerTransformContext,
+ ) => void
+}
+
export interface NormalizedBasePaths {
publicBase: string
assetBase: {
@@ -60,6 +102,8 @@ export interface TanStackStartCoreOptions {
providerEnvironmentName: string
ssrIsProvider: boolean
serializationAdapters?: Array | undefined
+ compilerTransforms?: Array | undefined
+ serverFnProviderModuleDirectives?: ReadonlyArray | undefined
}
export interface ResolvedStartConfig {
diff --git a/packages/start-plugin-core/src/vite/index.ts b/packages/start-plugin-core/src/vite/index.ts
index d5d91e435d..d50bb40981 100644
--- a/packages/start-plugin-core/src/vite/index.ts
+++ b/packages/start-plugin-core/src/vite/index.ts
@@ -3,6 +3,11 @@ export type {
ViteRscForwardSsrResolverStrategy,
} from './types'
export type { TanStackStartViteInputConfig } from './schema'
+export type {
+ StartCompilerImportTransform,
+ StartCompilerTransformCandidate,
+ StartCompilerTransformContext,
+} from '../types'
export { START_ENVIRONMENT_NAMES, VITE_ENVIRONMENT_NAMES } from '../constants'
export { createVirtualModule } from './createVirtualModule'
export { tanStackStartVite } from './plugin'
diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts
index e6cee02e48..915fcd7aca 100644
--- a/packages/start-plugin-core/src/vite/plugin.ts
+++ b/packages/start-plugin-core/src/vite/plugin.ts
@@ -217,6 +217,9 @@ export function tanStackStartVite(
environments,
generateFunctionId:
normalizedStartPluginOpts.serverFns?.generateFunctionId,
+ compilerTransforms: corePluginOpts.compilerTransforms,
+ serverFnProviderModuleDirectives:
+ corePluginOpts.serverFnProviderModuleDirectives,
providerEnvName: serverFnProviderEnv,
}),
importProtectionPlugin({
diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts
index 2e77e47f1f..3130ca47c1 100644
--- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts
@@ -20,7 +20,10 @@ import {
decodeViteDevServerModuleSpecifier,
} from './module-specifier'
import { mergeHotUpdateModules } from './hot-update'
-import type { CompileStartFrameworkOptions } from '../../types'
+import type {
+ CompileStartFrameworkOptions,
+ StartCompilerImportTransform,
+} from '../../types'
import type {
GenerateFunctionIdFnOptional,
ServerFn,
@@ -166,6 +169,8 @@ export interface StartCompilerPluginOptions {
* Custom function ID generator (optional).
*/
generateFunctionId?: GenerateFunctionIdFnOptional
+ compilerTransforms?: Array | undefined
+ serverFnProviderModuleDirectives?: ReadonlyArray | undefined
/**
* The Vite environment name for the server function provider.
*/
@@ -202,8 +207,18 @@ export function startCompilerPlugin(
name: string
type: 'client' | 'server'
}): PluginOption {
+ const compilerTransforms =
+ environment.name === opts.providerEnvName
+ ? opts.compilerTransforms
+ : undefined
+ const serverFnProviderModuleDirectives =
+ environment.name === opts.providerEnvName
+ ? opts.serverFnProviderModuleDirectives
+ : undefined
// Derive transform code filter from KindDetectionPatterns (single source of truth)
- const transformCodeFilter = getTransformCodeFilterForEnv(environment.type)
+ const transformCodeFilter = getTransformCodeFilterForEnv(environment.type, {
+ compilerTransforms,
+ })
return {
name: `tanstack-start-core::server-fn:${environment.name}`,
enforce: 'pre',
@@ -238,6 +253,8 @@ export function startCompilerPlugin(
framework: opts.framework,
providerEnvName: opts.providerEnvName,
generateFunctionId: opts.generateFunctionId,
+ compilerTransforms,
+ serverFnProviderModuleDirectives,
onServerFnsById,
getKnownServerFns: () => serverFnsById,
encodeModuleSpecifierInDev:
@@ -281,7 +298,9 @@ export function startCompilerPlugin(
}
// Detect which kinds are present in this file before parsing
- const detectedKinds = detectKindsInCode(code, environment.type)
+ const detectedKinds = detectKindsInCode(code, environment.type, {
+ compilerTransforms,
+ })
const result = await compiler.compile({
id,
diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts
index 6b5b15c202..58a91e3e1d 100644
--- a/packages/start-plugin-core/tests/compiler.test.ts
+++ b/packages/start-plugin-core/tests/compiler.test.ts
@@ -2,8 +2,11 @@ import { describe, expect, test } from 'vitest'
import {
StartCompiler,
detectKindsInCode,
+ getLookupKindsForEnv,
} from '../src/start-compiler/compiler'
+import { getLookupConfigurationsForEnv } from '../src/start-compiler/config'
import type { LookupConfig, LookupKind } from '../src/start-compiler/compiler'
+import type { StartCompilerImportTransform } from '../src/types'
// Default test options for StartCompiler
function getDefaultTestOptions(env: 'client' | 'server') {
@@ -17,7 +20,12 @@ function getDefaultTestOptions(env: 'client' | 'server') {
}
// Helper to create a compiler with all kinds enabled
-function createFullCompiler(env: 'client' | 'server') {
+function createFullCompiler(
+ env: 'client' | 'server',
+ opts?: {
+ serverFnProviderModuleDirectives?: ReadonlyArray | undefined
+ },
+) {
const lookupKinds: Set =
env === 'client'
? new Set([
@@ -66,6 +74,55 @@ function createFullCompiler(env: 'client' | 'server') {
loadModule: async () => {},
resolveId: async (id) => id,
mode: 'build',
+ serverFnProviderModuleDirectives: opts?.serverFnProviderModuleDirectives,
+ })
+}
+
+function createExternalTransformCompiler() {
+ const compilerTransforms: Array = [
+ {
+ name: 'test-render-option-injection',
+ environment: 'server',
+ imports: [
+ {
+ libName: '@example/runtime',
+ rootExport: 'renderThing',
+ },
+ ],
+ detect: /\brenderThing\b/,
+ transform: (candidates, context) => {
+ const t = context.types
+ for (const candidate of candidates) {
+ const args = candidate.path.node.arguments
+ if (args.length !== 1) continue
+ args.push(
+ t.objectExpression([
+ t.objectProperty(
+ t.identifier('injected'),
+ context.parseExpression('loadThing()'),
+ ),
+ ]),
+ )
+ }
+ },
+ },
+ ]
+
+ return new StartCompiler({
+ env: 'server',
+ envName: 'server',
+ root: '/test',
+ framework: 'react',
+ providerEnvName: 'server',
+ lookupKinds: getLookupKindsForEnv('server', { compilerTransforms }),
+ lookupConfigurations: getLookupConfigurationsForEnv('server', 'react', {
+ compilerTransforms,
+ }),
+ getKnownServerFns: () => ({}),
+ loadModule: async () => {},
+ resolveId: async (id) => id,
+ mode: 'build',
+ compilerTransforms,
})
}
@@ -377,6 +434,105 @@ describe('compiler handles multiple files with different kinds', () => {
})
})
+describe('compiler handles external import transforms', () => {
+ test('runs configured direct-call transforms before server function extraction', async () => {
+ const compiler = createExternalTransformCompiler()
+
+ const result = await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ import { renderThing as renderAlias } from '@example/runtime'
+
+ export const getComponent = createServerFn({ method: 'GET' }).handler(async () => {
+ return renderAlias()
+ })
+ `,
+ id: '/test/src/routes/card.tsx?tss-serverfn-split',
+ })
+
+ expect(result).not.toBeNull()
+ expect(result!.code).toContain('injected: loadThing()')
+ expect(result!.code).toContain('renderAlias(,')
+ })
+
+ test('runs configured direct-call transforms for namespace imports', async () => {
+ const compiler = createExternalTransformCompiler()
+
+ const result = await compiler.compile({
+ code: `
+ import * as runtime from '@example/runtime'
+
+ export const component = runtime.renderThing()
+ `,
+ id: '/test/src/routes/card.tsx',
+ })
+
+ expect(result).not.toBeNull()
+ expect(result!.code).toContain('injected: loadThing()')
+ expect(result!.code).toContain('runtime.renderThing(,')
+ })
+
+ test('does not run external transforms for shadowed import names', async () => {
+ const compiler = createExternalTransformCompiler()
+
+ const result = await compiler.compile({
+ code: `
+ import { renderThing } from '@example/runtime'
+
+ export function component() {
+ const renderThing = (node: React.ReactNode) => node
+ return renderThing()
+ }
+ `,
+ id: '/test/src/routes/card.tsx',
+ })
+
+ expect(result).toBeNull()
+ })
+})
+
+describe('server function provider module directives', () => {
+ const code = `
+ import { createServerFn } from '@tanstack/react-start'
+
+ export const getMessage = createServerFn({ method: 'GET' }).handler(() => {
+ return 'hello'
+ })
+ `
+
+ test('does not add module directives without compiler configuration', async () => {
+ const compiler = createFullCompiler('server')
+
+ const result = await compiler.compile({
+ code,
+ id: '/test/src/routes/message.tsx?tss-serverfn-split',
+ })
+
+ expect(result).not.toBeNull()
+ expect(result!.code).not.toContain('"use server-entry"')
+ })
+
+ test('adds configured module directives to provider files only', async () => {
+ const compiler = createFullCompiler('server', {
+ serverFnProviderModuleDirectives: ['use server-entry'],
+ })
+
+ const providerResult = await compiler.compile({
+ code,
+ id: '/test/src/routes/message.tsx?tss-serverfn-split',
+ })
+ const callerResult = await compiler.compile({
+ code,
+ id: '/test/src/routes/message.tsx',
+ })
+
+ expect(providerResult).not.toBeNull()
+ expect(providerResult!.code).toContain('"use server-entry";')
+ expect(callerResult).not.toBeNull()
+ expect(callerResult!.code).not.toContain('"use server-entry"')
+ })
+})
+
describe('edge cases for detectedKinds', () => {
test('empty detectedKinds returns null (no candidates to process)', async () => {
const compiler = createFullCompiler('client')