diff --git a/.changeset/empty-birds-crash.md b/.changeset/empty-birds-crash.md new file mode 100644 index 00000000000..7d17282afe0 --- /dev/null +++ b/.changeset/empty-birds-crash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Run Rsbuild Start compilation and server-function metadata writes in a single pre loader, preserving source maps, transform ordering, and persistent-cache replay. diff --git a/e2e/react-start/rsbuild/tests/error-overlay.spec.ts b/e2e/react-start/rsbuild/tests/error-overlay.spec.ts index d90647314ad..951b942126d 100644 --- a/e2e/react-start/rsbuild/tests/error-overlay.spec.ts +++ b/e2e/react-start/rsbuild/tests/error-overlay.spec.ts @@ -194,8 +194,8 @@ function normalizeErrorOverlayText(value: string): string { .replaceAll(fixtureRoot, '') .replaceAll(workspaceRoot, '') .replace( - /from [^)]+@rsbuild\/core\/dist\/transformLoader\.mjs/g, - 'from ', + /from [^)]+(?:@rsbuild\/core\/dist\/transformLoader\.mjs|start-plugin-core\/dist\/esm\/rsbuild\/start-compiler-loader\.js)/g, + 'from ', ) .replaceAll('\u00d7', 'x') .replaceAll('\u2570\u2500\u25b6', '->') @@ -378,7 +378,7 @@ test('shows a module syntax error through the error overlay', async ({ "title": "Build failed", "description": "SyntaxError: Unexpected token (16:22)", "runtime": "client", - "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: Unexpected token (16:22)", + "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: Unexpected token (16:22)", "importTrace": [ "../../../packages/react-start/dist/plugin/default-entry/client.tsx", "../../../packages/react-start/dist/esm/client.js", @@ -391,7 +391,7 @@ test('shows a module syntax error through the error overlay', async ({ "title": "Build failed", "description": "SyntaxError: Unexpected token (16:22)", "runtime": "server", - "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: Unexpected token (16:22)", + "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: Unexpected token (16:22)", "importTrace": [ "../../../packages/react-start/dist/plugin/default-entry/server.ts", "../../../packages/react-start/dist/esm/server.js", diff --git a/e2e/react-start/server-functions/package.json b/e2e/react-start/server-functions/package.json index 0fe7507b787..ce8af31d562 100644 --- a/e2e/react-start/server-functions/package.json +++ b/e2e/react-start/server-functions/package.json @@ -49,6 +49,14 @@ "vite": "^8.0.14" }, "nx": { + "targets": { + "test:e2e:rsbuild-cache": { + "dependsOn": [ + "^build" + ], + "parallelism": false + } + }, "metadata": { "playwrightModes": [ { diff --git a/e2e/react-start/server-functions/rsbuild.config.ts b/e2e/react-start/server-functions/rsbuild.config.ts index e5efd7624b3..c26a42c6eef 100644 --- a/e2e/react-start/server-functions/rsbuild.config.ts +++ b/e2e/react-start/server-functions/rsbuild.config.ts @@ -2,12 +2,27 @@ import { defineConfig } from '@rsbuild/core' import { pluginReact } from '@rsbuild/plugin-react' import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' import { getStartModeConfig } from './start-mode-config' +import type { RsbuildPluginAPI } from '@rsbuild/core' const outDir = process.env.E2E_DIST_DIR ?? 'dist' const startModeConfig = getStartModeConfig() export default defineConfig({ - plugins: [pluginReact(), tanstackStart(startModeConfig)], + plugins: [ + pluginReact(), + tanstackStart(startModeConfig), + { + name: 'server-function-input-fixture', + setup(api: RsbuildPluginAPI) { + api.transform( + { test: /consistent\.tsx$/, order: 'pre' }, + ({ code }) => { + return code.replaceAll('__fixture_createServerFn', 'createServerFn') + }, + ) + }, + }, + ], output: { distPath: { root: outDir, diff --git a/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts b/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts index bbc7cecba78..5a80ab81957 100644 --- a/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts +++ b/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts @@ -454,8 +454,37 @@ test('rsbuild watch updates and removes server function metadata', async ({ expect(staleResult.ok).toBe(false) expect(staleResult.status).toBeGreaterThanOrEqual(400) expect(staleResult.text).not.toContain('WATCH') + + writeFileSync(consistentRoutePath, originalSource) + await waitForRetry(devServer, 'restored server functions', () => + exerciseServerFunctionsWithLogs(page, baseURL, devServer), + ) } finally { writeFileSync(consistentRoutePath, originalSource) await stopDevServer(devServer) } }) + +test('rsbuild compiles server functions produced by a later pre transform', async ({ + page, +}) => { + test.setTimeout(120_000) + const originalSource = readFileSync(consistentRoutePath, 'utf8') + const port = await getAvailablePort() + let devServer: DevServer | undefined + try { + writeFileSync( + consistentRoutePath, + originalSource.replaceAll('createServerFn(', '__fixture_createServerFn('), + ) + devServer = startRsbuildDevServer(port, false) + const baseURL = `http://127.0.0.1:${port}` + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + } finally { + writeFileSync(consistentRoutePath, originalSource) + if (devServer) { + await stopDevServer(devServer) + } + } +}) diff --git a/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md b/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md index a97c500b0d2..65660d45f38 100644 --- a/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md +++ b/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md @@ -15,9 +15,9 @@ The important point is that server-function discovery is a side effect of compil ## Main Files - `plugin.ts` wires the Rsbuild plugin phases, virtual modules, RSC hooks, compiler ordering, and resolver rebuild hooks. -- `start-compiler-host.ts` registers the Start compiler transforms, captures server-function metadata, writes metadata to Rspack `buildInfo`, and replays cached metadata. -- `start-compiler-metadata.ts` defines the string keys and types used by the metadata loader. -- `start-compiler-metadata-loader.ts` is the real Rspack loader that writes server-function metadata to the current module's `buildInfo`. +- `start-compiler-host.ts` creates the compiler in Rspack `apply`, registers the loader, captures server-function metadata, and replays cached metadata. +- `start-compiler-metadata.ts` defines the persisted server-function metadata field and type. +- `start-compiler-loader.ts` handles virtual modules, filtering, compilation, source maps, and writes metadata directly to the current module's `buildInfo`. - `virtual-modules.ts` owns the resolver virtual module and writes updated virtual module content per Rsbuild environment. - `../start-compiler/handleCreateServerFn.ts` discovers server functions while compiling caller modules. - `../start-compiler/server-fn-resolver-module.ts` generates the resolver module from the current server-function registry. @@ -34,21 +34,21 @@ The shared registry is `serverFnsById`. It is passed to `virtual-modules.ts`, wh ## Cold Transform Path -`registerStartCompilerTransforms()` registers an `api.transform({ order: 'pre' })` transform for each Start compiler environment. +`registerStartCompilerTransforms()` registers a real pre loader through `modifyBundlerChain` for each Start compiler environment. When a matching module is compiled normally: -1. Rsbuild runs the Start transform in the pre-loader phase. +1. Rspack runs the Start compiler loader in the pre-loader phase. 2. The transform checks compiler virtual modules first. 3. It runs cheap code filters to skip files that cannot contain Start compiler patterns. -4. It lazily creates one `StartCompiler` per environment. +4. It uses the environment's `StartCompiler`, created in the Rspack plugin's `apply`. 5. It detects which compiler features are present in the current code. 6. It serializes access to the per-environment `StartCompiler` through `runCompilerTask()`. 7. It calls `compiler.compile({ id, code, detectedKinds })`. 8. During compile, `handleCreateServerFn()` reports discovered server functions through `onServerFnsById`. -9. The transform stores the discovered server functions for this module in the environment's pending metadata map. +9. The loader writes the discovered server functions directly to `this._module.buildInfo[SERVER_FN_BUILD_INFO_FIELD]` and returns code with composed source maps. -The module ID used for transform metadata is `ctx.resource`. The metadata loader later reads `this.resource`. Using the same Rspack resource string keeps the handoff stable across resource queries. +The compiler receives the full `this.resource`, including queries. Compilation and metadata writes happen in the same loader invocation, with no pending metadata map. ## Server-Function Discovery @@ -70,39 +70,37 @@ The compiler sometimes needs to read or resolve imported modules while compiling `start-compiler-host.ts` uses two native Rsbuild/Rspack surfaces for this: -- `ctx.resolve()` from the active Rsbuild transform context resolves module IDs. +- `ctx.resolve()` from the active Rspack loader context resolves module IDs. - `compiler.inputFileSystem` reads source through Rspack's input filesystem. -An `AsyncLocalStorage` value binds `loadModule` and `resolveId` back to the active transform context. That lets the compiler add dependencies to the current Rspack module without reaching into Rspack private resolver internals. +An `AsyncLocalStorage` value binds `loadModule` and `resolveId` back to the active loader context. That lets the compiler add dependencies to the current Rspack module without reaching into Rspack private resolver internals. ## Why A Real Loader Is Used Rsbuild `api.transform()` is implemented as a loader, but its callback only exposes the transform context. It does not expose the current `NormalModule` or `module.buildInfo`. -Rspack persists custom `module.buildInfo` fields. To write metadata there, Start installs a small real loader after the transform: +Rspack persists custom `module.buildInfo` fields. Start replaces `api.transform()` and the separate metadata loader with one pre loader: -1. `start-compiler-host.ts` adds a rule with `enforce: 'post'` for Start transformable modules. -2. The rule points to the emitted `start-compiler-metadata-loader.js` file. -3. The loader receives the environment's pending metadata map through loader options. -4. A public `NormalModule.getCompilationHooks(compilation).loader` hook adds a setter to the loader context. -5. The loader reads metadata for `this.resource` and calls the setter. -6. The setter writes to `module.buildInfo['tanstack.start.serverFns']`. +1. `start-compiler-host.ts` registers the rule in `modifyBundlerChain`, preserving pre-transform ordering. +2. The rule points to the emitted `start-compiler-loader.js` file. +3. The loader checks virtual modules, filters code, detects kinds and invokes the host's queued compile callback. +4. The loader writes metadata directly to `this._module.buildInfo['tanstack.start.serverFns']`, then returns code and the composed source map. Only string-keyed, JSON-serializable `buildInfo` fields are used. Rspack persists those fields with the module cache. Symbol keys and private cache internals are intentionally avoided. ## Cache-Enabled And Cache-Disabled Behavior -The metadata loader is installed regardless of `performance.buildCache`. +The compiler loader is installed regardless of `performance.buildCache`. -When persistent cache is enabled, Rspack may later restore a module without rerunning the Start transform or the metadata loader. The `buildInfo` payload is then replayed from the cached module. +When persistent cache is enabled, Rspack may later restore a module without rerunning the compiler loader. The `buildInfo` payload is then replayed from the cached module. -When persistent cache is disabled, the same loader path still runs during normal compilation and writes in-memory `buildInfo`. Keeping one path avoids conditional behavior and keeps cold, watch, cache-disabled, and cache-enabled builds aligned. The overhead is one small post loader for modules that already match the Start transform test. +When persistent cache is disabled, the same loader path still runs during normal compilation and writes in-memory `buildInfo`. Keeping one path avoids conditional behavior and keeps cold, watch, cache-disabled, and cache-enabled builds aligned. The cache-specific part is not the loader itself. The cache-specific part is that Rspack can persist and later restore the `buildInfo` field. ## Stale Metadata Clearing -If a module previously contained server functions and then no longer produces metadata, the metadata loader writes an empty payload: +If a module previously contained server functions and then no longer produces metadata, the compiler loader writes an empty payload: ```ts { version: 1, serverFnsById: {} } @@ -126,8 +124,6 @@ At that point Rspack has built or restored the module graph for the environment, The shared registry is rebuilt from snapshots instead of being append-only. This is what lets deleted or renamed server functions disappear after watch rebuilds or warm-cache restores. -The pending transform-to-loader metadata maps are cleared on each Rspack `compile` hook. That prevents metadata discovered in an earlier compilation from being written to a later module when the transform no longer runs or no longer discovers server functions. - ## Why Snapshots Are Per Environment Each Rsbuild environment can see different information for the same file. For example, client and SSR caller environments can mark functions as client-accessible, while server and provider environments own server execution details. The merged registry preserves the broadest known access information. @@ -171,29 +167,27 @@ RSC builds intentionally do not add these dependencies. Rspack's native RSC coor The implementation uses Rsbuild/Rspack integration points instead of cache internals: -- Rsbuild `api.transform()` for source transforms. -- Rsbuild transform `ctx.resolve()` for module resolution. +- Rsbuild `modifyBundlerChain()` to register the compiler pre loader. +- Rspack loader `ctx.resolve()` for module resolution. - Rspack `compiler.inputFileSystem` for source reads. - Rspack loaders for access to the loader pipeline. -- Rspack `NormalModule.getCompilationHooks(compilation).loader` to extend loader context. - Rspack `module.buildInfo` for module-scoped persisted metadata. - Rspack `compiler.hooks.finishMake` for module graph replay and resolver rebuild ordering. - Rspack `compilation.rebuildModule()` through the local `rebuildModulesContaining()` helper. - Rspack `MultiCompiler.setDependencies()` for non-RSC compiler ordering. - Rspack `experiments.VirtualModulesPlugin` for virtual module content. -The implementation avoids private persistent-cache files, direct cache mutation, direct `_module` access from loaders, global handoff state, and disabling module caching with `this.cacheable(false)`. +The implementation avoids private persistent-cache files, direct cache mutation, global handoff state, and disabling module caching with `this.cacheable(false)`. ## When A New Server Function Is Compiled For a new server function in a normal cold compile: -1. Rsbuild sends the source module through the Start pre transform. +1. Rsbuild sends the source module through the Start compiler pre loader. 2. `StartCompiler.compile()` rewrites the caller module and discovers the server function. 3. `onServerFnsById` merges the function into `serverFnsById` and the active module metadata object. -4. The transform stores `{ version: 1, serverFnsById: discoveredServerFnsById }` in the environment metadata map under the module resource. -5. The post metadata loader runs for the same resource. -6. The loader writes that payload into `module.buildInfo['tanstack.start.serverFns']`. -7. Rspack owns the module from there. If persistent cache is enabled, Rspack persists the module and its string-keyed `buildInfo` with its normal cache machinery. -8. At `finishMake -20`, Start reads current module `buildInfo` and rebuilds the environment snapshot. -9. At `finishMake -10`, Start rewrites and rebuilds the resolver virtual module so runtime lookups can find the new function. +4. The compile callback returns code, a source map, and the discovered server functions to the loader. +5. The loader writes that payload into `module.buildInfo['tanstack.start.serverFns']` and returns code with composed source maps. +6. Rspack owns the module from there. If persistent cache is enabled, Rspack persists the module and its string-keyed `buildInfo` with its normal cache machinery. +7. At `finishMake -20`, Start reads current module `buildInfo` and rebuilds the environment snapshot. +8. At `finishMake -10`, Start rewrites and rebuilds the resolver virtual module so runtime lookups can find the new function. 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 3cd1725fa09..7d89dae2925 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -3,30 +3,17 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { z } from 'zod' import { TRANSFORM_ID_REGEX } from '../constants' -import { detectKindsInCode } from '../start-compiler/compiler' import { getTransformCodeFilterForEnv } from '../start-compiler/config' -import { - createStartCompiler, - loadCompilerVirtualModule, - matchesCodeFilters, - mergeServerFnsById, -} from '../start-compiler/host' -import { cleanId } from '../start-compiler/utils' import { createHydrateCompilerPlugin } from '../hydrate-when-transform' -import { - SERVER_FN_BUILD_INFO_CONTEXT_KEY, - SERVER_FN_BUILD_INFO_FIELD, -} from './start-compiler-metadata' +import { createStartCompiler, mergeServerFnsById } from '../start-compiler/host' +import { cleanId } from '../start-compiler/utils' +import { SERVER_FN_BUILD_INFO_FIELD } from './start-compiler-metadata' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' -import type { - ServerFnBuildInfo, - ServerFnBuildInfoLoaderContext, -} from './start-compiler-metadata' +import type { StartCompilerLoaderOptions } from './start-compiler-loader' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, StartCompilerPlugin, - StartCompilerTransformResult, } from '../types' import type { DevServerFnModuleSpecifierEncoder, @@ -34,14 +21,7 @@ import type { ServerFn, } from '../start-compiler/types' -type RsbuildTransformContext = Parameters< - Parameters[1] ->[0] -type WarnableRsbuildTransformContext = RsbuildTransformContext & { - emitWarning?: (warning: Error) => void -} type RsbuildInputFileSystem = NonNullable -type ServerFnMetadataById = Map type StartCompilerEnvironment = { name: string type: 'client' | 'server' @@ -69,14 +49,10 @@ const rsbuildDevServerFnModuleSpecifierEncoder: DevServerFnModuleSpecifierEncode ({ extractedFilename }) => pathToFileURL(extractedFilename).href const currentDir = dirname(fileURLToPath(import.meta.url)) -const metadataLoaderFilename = 'start-compiler-metadata-loader.js' -const EMPTY_SERVER_FN_BUILD_INFO: ServerFnBuildInfo = { - version: 1, - serverFnsById: {}, -} +const compilerLoaderFilename = 'start-compiler-loader.js' -function resolveMetadataLoader(): string { - return resolve(currentDir, metadataLoaderFilename) +function resolveCompilerLoader(): string { + return resolve(currentDir, compilerLoaderFilename) } function readServerFnBuildInfo( @@ -92,26 +68,6 @@ function readServerFnBuildInfo( return result.data.serverFnsById } -function setServerFnBuildInfoLoaderContext( - loaderContext: Rspack.LoaderContext & ServerFnBuildInfoLoaderContext, - module: Rspack.Module, -) { - loaderContext[SERVER_FN_BUILD_INFO_CONTEXT_KEY] = (metadata) => { - if (metadata) { - module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] = metadata - } else if (module.buildInfo[SERVER_FN_BUILD_INFO_FIELD]) { - module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] = EMPTY_SERVER_FN_BUILD_INFO - } - } -} - -function warnTransformContext( - ctx: WarnableRsbuildTransformContext, - message: string, -): void { - ctx.emitWarning?.(new Error(message)) -} - export interface StartCompilerHostOptions { framework: CompileStartFrameworkOptions root: string | (() => string) @@ -126,10 +82,10 @@ export interface StartCompilerHostOptions { } /** - * Registers the shared StartCompiler as rsbuild transforms for client + ssr environments. + * Registers the shared StartCompiler as a pre loader for each Start environment. * - * Uses `api.transform()` to hook into the rsbuild loader pipeline, and the - * transform context's native `resolve()` for module resolution. + * The loader transforms source and writes metadata in the same invocation. + * Compiler instances and queues remain scoped to their environment. */ export function registerStartCompilerTransforms( api: RsbuildPluginAPI, @@ -137,27 +93,13 @@ export function registerStartCompilerTransforms( ): { serverFnsById: Record } { - const compilers = new Map>() // Serialize access to each StartCompiler's mutable per-environment caches. const compilerQueues = new Map>() - const inputFileSystems = new Map() - const transformContextStorage = - new AsyncLocalStorage() - const serverFnMetadataByEnvironment = new Map() + const transformContextStorage = new AsyncLocalStorage() const serverFnsByEnvironment = new Map>() const serverFnsById = opts.serverFnsById ?? {} const getRoot = () => typeof opts.root === 'function' ? opts.root() : opts.root - const getServerFnMetadata = (environmentName: string) => { - let metadataById = serverFnMetadataByEnvironment.get(environmentName) - - if (!metadataById) { - metadataById = new Map() - serverFnMetadataByEnvironment.set(environmentName, metadataById) - } - - return metadataById - } const runCompilerTask = async ( environmentName: string, task: () => Promise, @@ -197,35 +139,12 @@ export function registerStartCompilerTransforms( const isDev = api.context.action === 'dev' const mode = isDev ? 'dev' : 'build' - const metadataLoader = resolveMetadataLoader() + const compilerLoader = resolveCompilerLoader() const environments = opts.environments - // Rspack persistent cache restores modules without re-running api.transform. - // Keep server function modules cacheable by writing discovered metadata into - // buildInfo from a real loader, then replaying it from cached modules. - api.modifyRspackConfig((config, utils) => { - if (!environments.some((env) => env.name === utils.environment.name)) { - return - } - - const rules = config.module.rules ?? [] - rules.push({ - test: TRANSFORM_ID_REGEX[0], - enforce: 'post', - use: [ - { - loader: metadataLoader, - options: { - metadataById: getServerFnMetadata(utils.environment.name), - }, - }, - ], - }) - config.module.rules = rules - }) - for (const env of environments) { + let startCompiler: ReturnType const compilerTransforms = env.name === opts.providerEnvName ? opts.compilerTransforms : undefined const envCodeFilters = getTransformCodeFilterForEnv(env.type, { @@ -247,235 +166,163 @@ export function registerStartCompilerTransforms( opts.onServerFnsByIdChange?.() } - api.transform( - { - test: TRANSFORM_ID_REGEX[0], - environments: [env.name], - order: 'pre', - }, - async (ctx: RsbuildTransformContext) => { - return transformContextStorage.run(ctx, async () => { - const code = ctx.code - let nextCode = code - let previousResult: { - code: string - map: StartCompilerTransformResult['map'] - } | null = null - const id = ctx.resource - const root = getRoot() - - const virtualResult = loadCompilerVirtualModule(compilerPlugins, { - code, - id, - root, - env: env.type, - envName: env.name, - }) - if (virtualResult) { - nextCode = virtualResult.code - previousResult = { - code: virtualResult.code, - map: virtualResult.map ?? null, - } + const compile: StartCompilerLoaderOptions['compile'] = (ctx, input) => + transformContextStorage.run(ctx, async () => { + const discoveredServerFnsById: Record = {} + const result = await runCompilerTask(env.name, async () => { + activeServerFnMetadata = discoveredServerFnsById + try { + return await startCompiler.compile(input) + } finally { + activeServerFnMetadata = undefined } + }) + return { result, serverFnsById: discoveredServerFnsById } + }) - // Quick string-level check: does this file contain any patterns for this env? - if (!matchesCodeFilters(nextCode, envCodeFilters)) { - return previousResult ?? nextCode - } + api.modifyBundlerChain((chain, utils) => { + if (utils.environment.name !== env.name) { + return + } + // Register in the same phase as api.transform to preserve pre-loader order. + chain.module + .rule('tanstack-start-compiler') + .test(TRANSFORM_ID_REGEX[0]) + .enforce('pre') + .use('tanstack-start-compiler') + .loader(compilerLoader) + .options({ + env: env.type, + envName: env.name, + getRoot, + compilerPlugins, + compilerTransforms, + codeFilters: envCodeFilters, + compile, + } satisfies StartCompilerLoaderOptions) + }) - let compiler = compilers.get(env.name) - if (!compiler) { - compiler = createStartCompiler({ - env: env.type, - envName: env.name, - root, - mode, - framework: opts.framework, - providerEnvName: opts.providerEnvName, - generateFunctionId: opts.generateFunctionId, - compilerTransforms, - compilerPlugins, - serverFnProviderModuleDirectives, - onServerFnsById, - getKnownServerFns: () => serverFnsById, - encodeModuleSpecifierInDev: isDev - ? rsbuildDevServerFnModuleSpecifierEncoder - : undefined, - loadModule: async (moduleId: string) => { - const activeCtx = transformContextStorage.getStore() - if (!activeCtx) { - throw new Error( - `could not load module ${moduleId}: missing active rsbuild transform context for ${env.name}`, - ) - } + api.modifyRspackConfig((config, utils) => { + if (utils.environment.name !== env.name) { + return + } - const inputFileSystem = inputFileSystems.get(env.name) - if (!inputFileSystem) { - throw new Error( - `could not load module ${moduleId}: missing rspack input filesystem for ${env.name}`, - ) - } + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + if (!compiler.inputFileSystem) { + throw new Error(`missing rspack input filesystem for ${env.name}`) + } + const inputFileSystem = compiler.inputFileSystem + startCompiler = createStartCompiler({ + env: env.type, + envName: env.name, + root: getRoot(), + mode, + framework: opts.framework, + providerEnvName: opts.providerEnvName, + generateFunctionId: opts.generateFunctionId, + compilerTransforms, + compilerPlugins, + serverFnProviderModuleDirectives, + onServerFnsById, + getKnownServerFns: () => serverFnsById, + encodeModuleSpecifierInDev: isDev + ? rsbuildDevServerFnModuleSpecifierEncoder + : undefined, + loadModule: async (moduleId: string) => { + const activeCtx = transformContextStorage.getStore() + if (!activeCtx) { + throw new Error( + `could not load module ${moduleId}: missing active rsbuild transform context for ${env.name}`, + ) + } - const cleanedId = cleanId(moduleId) - activeCtx.addDependency(cleanedId) - const loaded = await readFileFromInputFileSystem( - inputFileSystem, - cleanedId, + const cleanedId = cleanId(moduleId) + activeCtx.addDependency(cleanedId) + const loaded = await readFileFromInputFileSystem( + inputFileSystem, + cleanedId, + ) + const moduleCode = Buffer.isBuffer(loaded) + ? loaded.toString('utf8') + : loaded + + startCompiler.ingestModule({ code: moduleCode, id: cleanedId }) + }, + resolveId: async ( + source: string, + importer?: string, + ): Promise => { + const activeCtx = transformContextStorage.getStore() + if (!activeCtx) { + throw new Error( + `could not resolve ${source}: missing active rsbuild transform context for ${env.name}`, ) - const moduleCode = Buffer.isBuffer(loaded) - ? loaded.toString('utf8') - : loaded - - compiler!.ingestModule({ code: moduleCode, id: cleanedId }) - }, - resolveId: async ( - source: string, - importer?: string, - ): Promise => { - const activeCtx = transformContextStorage.getStore() - if (!activeCtx) { - throw new Error( - `could not resolve ${source}: missing active rsbuild transform context for ${env.name}`, - ) - } + } - const context = importer - ? importer.replace(/[/\\][^/\\]*$/, '') - : getRoot() + const context = importer + ? importer.replace(/[/\\][^/\\]*$/, '') + : getRoot() - return await new Promise((resolve, reject) => { - activeCtx.resolve(context, source, (error, resolved) => { - if (error) { - reject(error) - return - } + return await new Promise((resolve, reject) => { + activeCtx.resolve(context, source, (error, resolved) => { + if (error) { + reject(error) + return + } - if (!resolved) { - resolve(null) - return - } + if (!resolved) { + resolve(null) + return + } - resolve(cleanId(resolved)) - }) + resolve(cleanId(resolved)) }) - }, - }) - compilers.set(env.name, compiler) - } - - const detectedKinds = detectKindsInCode(nextCode, env.type, { - compilerTransforms, - }) - const discoveredServerFnsById: Record = {} - const result = await runCompilerTask(env.name, async () => { - activeServerFnMetadata = discoveredServerFnsById - - try { - return await compiler.compile({ - id, - code: nextCode, - detectedKinds, - warn: (message) => warnTransformContext(ctx, message), }) - } finally { - activeServerFnMetadata = undefined - } - }) - - getServerFnMetadata(env.name).set(id, { - version: 1, - serverFnsById: discoveredServerFnsById, + }, }) - if (result) { - return { - code: result.code, - map: result.map ?? null, - } - } - - return previousResult ?? nextCode - }) - }, - ) - } + compiler.hooks.finishMake.tap( + { + name: 'TanStackStartCompilerCachedServerFnMetadata', + stage: -20, + }, + (compilation) => { + const restoredServerFnsById: Record = {} + + for (const module of compilation.modules) { + const metadata = readServerFnBuildInfo(module) + if (!metadata) { + continue + } - api.modifyRspackConfig((config, utils) => { - if (!environments.some((env) => env.name === utils.environment.name)) { - return - } + mergeServerFnsById(restoredServerFnsById, metadata) + } - config.plugins.push({ - apply(compiler: Rspack.Compiler) { - if (compiler.inputFileSystem) { - inputFileSystems.set(utils.environment.name, compiler.inputFileSystem) - } - - compiler.hooks.compilation.tap( - 'TanStackStartCompilerMetadataLoaderContext', - (compilation) => { - utils.rspack.NormalModule.getCompilationHooks( - compilation, - ).loader.tap( - 'TanStackStartCompilerMetadataLoaderContext', - (loaderContext, module) => { - setServerFnBuildInfoLoaderContext(loaderContext, module) - }, - ) - }, - ) - - compiler.hooks.compile.tap('TanStackStartCompilerMetadataCleanup', () => - getServerFnMetadata(utils.environment.name).clear(), - ) - - compiler.hooks.finishMake.tap( - { - name: 'TanStackStartCompilerCachedServerFnMetadata', - stage: -20, - }, - (compilation) => { - const restoredServerFnsById: Record = {} - - for (const module of compilation.modules) { - const metadata = readServerFnBuildInfo(module) - if (!metadata) { - continue + serverFnsByEnvironment.set( + utils.environment.name, + restoredServerFnsById, + ) + replaceServerFnsByIdFromEnvironmentSnapshots() + }, + ) + + compiler.hooks.watchRun.tap( + 'TanStackStartCompilerModuleInvalidation', + (watchCompiler) => { + for (const file of watchCompiler.modifiedFiles ?? []) { + startCompiler.invalidateModule(file) } - mergeServerFnsById(restoredServerFnsById, metadata) - } - - serverFnsByEnvironment.set( - utils.environment.name, - restoredServerFnsById, - ) - replaceServerFnsByIdFromEnvironmentSnapshots() - }, - ) - - compiler.hooks.watchRun.tap( - 'TanStackStartCompilerModuleInvalidation', - (watchCompiler) => { - const startCompiler = compilers.get(utils.environment.name) - - if (!startCompiler) { - return - } - - for (const file of watchCompiler.modifiedFiles ?? []) { - startCompiler.invalidateModule(file) - } - - for (const file of watchCompiler.removedFiles ?? []) { - startCompiler.invalidateModule(file) - } - }, - ) - }, + for (const file of watchCompiler.removedFiles ?? []) { + startCompiler.invalidateModule(file) + } + }, + ) + }, + }) }) - }) + } return { serverFnsById } } diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts new file mode 100644 index 00000000000..af8ea41a331 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -0,0 +1,94 @@ +import remapping from '@jridgewell/remapping' +import { detectKindsInCode } from '../start-compiler/compiler' +import { + loadCompilerVirtualModule, + matchesCodeFilters, +} from '../start-compiler/host' +import { SERVER_FN_BUILD_INFO_FIELD } from './start-compiler-metadata' +import type { SourceMapInput } from '@jridgewell/remapping' +import type { Rspack } from '@rsbuild/core' +import type { StartCompiler } from '../start-compiler/compiler' +import type { ServerFn } from '../start-compiler/types' +import type { + StartCompilerImportTransform, + StartCompilerPlugin, + StartCompilerTransformResult, +} from '../types' +import type { ServerFnBuildInfo } from './start-compiler-metadata' + +export interface StartCompilerLoaderOptions { + env: 'client' | 'server' + envName: string + getRoot: () => string + compilerPlugins: Array + compilerTransforms?: Array + codeFilters: ReadonlyArray + compile: ( + context: Rspack.LoaderContext, + input: Parameters[0], + ) => Promise<{ + result: Awaited> + serverFnsById: Record + }> +} + +const EMPTY_SERVER_FN_BUILD_INFO: ServerFnBuildInfo = { + version: 1, + serverFnsById: {}, +} + +const tanStackStartCompilerLoader: Rspack.LoaderDefinition = + async function (source, map): Promise { + const callback = this.async() + + try { + const options = this.getOptions() + const id = this.resource + const virtualResult = loadCompilerVirtualModule(options.compilerPlugins, { + code: source, + id, + root: options.getRoot(), + env: options.env, + envName: options.envName, + }) + let result: StartCompilerTransformResult = virtualResult ?? { + code: source, + } + let serverFnsById: Record | null = null + + // Filter virtual output, which can introduce new Start compiler patterns. + if (matchesCodeFilters(result.code, options.codeFilters)) { + const compiled = await options.compile(this, { + id, + code: result.code, + detectedKinds: detectKindsInCode(result.code, options.env, options), + warn: (message) => this.emitWarning(new Error(message)), + }) + result = compiled.result ?? result + serverFnsById = compiled.serverFnsById + } + + // Filtering out a module must also clear metadata from an earlier build. + const buildInfo = this._module.buildInfo + if (serverFnsById !== null) { + buildInfo[SERVER_FN_BUILD_INFO_FIELD] = { + version: 1, + serverFnsById, + } satisfies ServerFnBuildInfo + } else if (buildInfo[SERVER_FN_BUILD_INFO_FIELD]) { + buildInfo[SERVER_FN_BUILD_INFO_FIELD] = EMPTY_SERVER_FN_BUILD_INFO + } + const mergedMap = + map && result.map + ? remapping( + [result.map as SourceMapInput, map as SourceMapInput], + () => null, + ) + : (result.map ?? map) + callback(null, result.code, mergedMap as typeof map) + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))) + } + } + +export default tanStackStartCompilerLoader diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts deleted file mode 100644 index 89a22d82cd5..00000000000 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SERVER_FN_BUILD_INFO_CONTEXT_KEY } from './start-compiler-metadata' -import type { Rspack } from '@rsbuild/core' -import type { - ServerFnBuildInfoLoaderContext, - ServerFnMetadataLoaderOptions, -} from './start-compiler-metadata' - -const tanStackStartCompilerMetadataLoader: Rspack.LoaderDefinition< - ServerFnMetadataLoaderOptions, - ServerFnBuildInfoLoaderContext -> = function (source, map): void { - const { metadataById } = this.getOptions() - const id = this.resource - const metadata = metadataById.get(id) - const setBuildInfo = this[SERVER_FN_BUILD_INFO_CONTEXT_KEY] - - setBuildInfo?.(metadata ?? null) - - this.callback(null, source, map) -} - -export default tanStackStartCompilerMetadataLoader diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts index dfa61192461..4383cb84d20 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts @@ -1,20 +1,8 @@ import type { ServerFn } from '../start-compiler/types' export const SERVER_FN_BUILD_INFO_FIELD = 'tanstack.start.serverFns' -export const SERVER_FN_BUILD_INFO_CONTEXT_KEY = - 'tanstack.start.setServerFnBuildInfo' export type ServerFnBuildInfo = { version: 1 serverFnsById: Record } - -export type SetServerFnBuildInfo = (metadata: ServerFnBuildInfo | null) => void - -export type ServerFnBuildInfoLoaderContext = { - [SERVER_FN_BUILD_INFO_CONTEXT_KEY]?: SetServerFnBuildInfo -} - -export type ServerFnMetadataLoaderOptions = { - metadataById: Map -} diff --git a/packages/start-plugin-core/tests/rsbuild/start-compiler-loader.test.ts b/packages/start-plugin-core/tests/rsbuild/start-compiler-loader.test.ts new file mode 100644 index 00000000000..9bf56d43a58 --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/start-compiler-loader.test.ts @@ -0,0 +1,557 @@ +import fs from 'node:fs' +import { SourceMapConsumer } from 'source-map' +import { describe, expect, test, vi } from 'vitest' +import loader from '../../src/rsbuild/start-compiler-loader' +import { registerStartCompilerTransforms } from '../../src/rsbuild/start-compiler-host' +import { SERVER_FN_BUILD_INFO_FIELD } from '../../src/rsbuild/start-compiler-metadata' +import type { StartCompilerHostOptions } from '../../src/rsbuild/start-compiler-host' +import type { StartCompilerLoaderOptions } from '../../src/rsbuild/start-compiler-loader' +import type { ServerFnBuildInfo } from '../../src/rsbuild/start-compiler-metadata' +import type { + ModifyBundlerChainFn, + ModifyRspackConfigFn, + RsbuildPluginAPI, + Rspack, +} from '@rsbuild/core' + +function invokeLoader( + options: StartCompilerLoaderOptions, + context: Rspack.LoaderContext, + source: string, + map?: Rspack.RawSourceMap, + buildInfo: Record = {}, +) { + return new Promise<{ code: string; map?: Rspack.RawSourceMap }>( + (resolve, reject) => { + Object.assign(context, { + getOptions: () => options, + _module: { buildInfo }, + async: + () => + ( + error: Error | null, + code: string, + outputMap?: Rspack.RawSourceMap, + ) => { + if (error) { + reject(error) + } else { + resolve({ code, map: outputMap }) + } + }, + }) + void loader.call( + context as Rspack.LoaderContext, + source, + map, + ) + }, + ) +} + +function runLoader( + compile: StartCompilerLoaderOptions['compile'], + map?: Rspack.RawSourceMap, + buildInfo: Record = {}, + codeFilters = [/./], +) { + const result = invokeLoader( + { + env: 'client', + envName: 'client', + getRoot: () => '/src', + compilerPlugins: [], + codeFilters, + compile, + }, + createContext('/src/entry.ts?variant=client'), + 'input', + map, + buildInfo, + ) + return { result, buildInfo } +} + +describe('Start compiler loader', () => { + test('returns transformed code and the metadata for the full resource', async () => { + const metadata: ServerFnBuildInfo = { version: 1, serverFnsById: {} } + const compile = vi.fn(async (context: Rspack.LoaderContext) => { + expect(context.resource).toBe('/src/entry.ts?variant=client') + return { + result: { code: 'output' }, + serverFnsById: metadata.serverFnsById, + } + }) + const { result, buildInfo } = runLoader(compile) + expect(await result).toEqual({ code: 'output', map: undefined }) + expect(buildInfo[SERVER_FN_BUILD_INFO_FIELD]).toEqual(metadata) + }) + + test('preserves an input map and clears metadata when compilation is skipped', async () => { + const map: Rspack.RawSourceMap = { + version: 3, + file: 'intermediate.ts', + sources: ['original.ts'], + names: [], + mappings: 'AAAA', + } + const { result, buildInfo } = runLoader( + async () => { + throw new Error('filtered module must not compile') + }, + map, + { + [SERVER_FN_BUILD_INFO_FIELD]: { + version: 1, + serverFnsById: { + old: { + functionId: 'old', + functionName: 'old', + filename: '/src/entry.ts', + extractedFilename: '/src/entry.ts?serverFn', + }, + }, + }, + }, + [], + ) + expect(await result).toEqual({ code: 'input', map }) + expect(buildInfo[SERVER_FN_BUILD_INFO_FIELD]).toEqual({ + version: 1, + serverFnsById: {}, + }) + }) + + test('composes the compiler map with a preceding loader map', async () => { + const original: Rspack.RawSourceMap = { + version: 3, + file: 'intermediate.ts', + sources: ['original.ts'], + sourcesContent: ['\n\noriginal'], + names: [], + mappings: 'AAEA', + } + const generated: Rspack.RawSourceMap = { + version: 3, + file: 'output.js', + sources: ['intermediate.ts'], + names: [], + mappings: ';AAAA', + } + const { result } = runLoader( + async () => ({ + result: { code: '\noutput', map: generated }, + serverFnsById: {}, + }), + original, + ) + const output = await result + await SourceMapConsumer.with(output.map!, null, (consumer) => { + expect(consumer.originalPositionFor({ line: 2, column: 0 })).toEqual({ + source: 'original.ts', + line: 3, + column: 0, + name: null, + }) + expect(consumer.sourceContentFor('original.ts')).toBe('\n\noriginal') + }) + }) + + test('propagates compilation failures to the bundler', async () => { + const { result, buildInfo } = runLoader(async () => { + throw new Error('compile failed') + }) + await expect(result).rejects.toThrow('compile failed') + expect(buildInfo).toEqual({}) + }) +}) + +function createEnvironment( + overrides: Partial & { + inputFileSystem?: Rspack.Compiler['inputFileSystem'] + } = {}, +) { + const { inputFileSystem = fs, ...hostOptions } = overrides + const environment = hostOptions.environments?.[0] ?? { + name: 'client', + type: 'client', + } + let options: StartCompilerLoaderOptions + let watchRun: (compiler: Rspack.Compiler) => void + const use = { + loader: () => use, + options: (value: StartCompilerLoaderOptions) => { + options = value + }, + } + const rule = { + test: () => rule, + enforce: () => rule, + use: () => use, + } + const config = { plugins: [] as Array } + registerStartCompilerTransforms( + { + context: { action: 'build' }, + modifyBundlerChain: (handler: ModifyBundlerChainFn) => { + handler( + { module: { rule: () => rule } } as unknown as Parameters< + typeof handler + >[0], + { environment } as unknown as Parameters[1], + ) + }, + modifyRspackConfig: (handler: ModifyRspackConfigFn) => { + handler( + config as Parameters[0], + { environment } as unknown as Parameters[1], + ) + }, + } as RsbuildPluginAPI, + { + framework: 'react', + root: '/src', + environments: [environment], + providerEnvName: 'ssr', + ...hostOptions, + }, + ) + const compiler = { + inputFileSystem, + hooks: { + finishMake: { tap: vi.fn() }, + watchRun: { + tap: (_name: string, callback: typeof watchRun) => { + watchRun = callback + }, + }, + }, + } as unknown as Rspack.Compiler + for (const plugin of config.plugins) { + plugin.apply(compiler) + } + return { + async transformModule(context: Rspack.LoaderContext, source: string) { + const buildInfo: Record = {} + const result = await invokeLoader( + options, + context, + source, + undefined, + buildInfo, + ) + const metadata = buildInfo[SERVER_FN_BUILD_INFO_FIELD] as + | ServerFnBuildInfo + | undefined + return { ...result, serverFnsById: metadata?.serverFnsById ?? null } + }, + invalidateModule(file: string) { + compiler.modifiedFiles = new Set([file]) + watchRun(compiler) + }, + } +} + +function createContext(resource: string) { + return { + resource, + emitWarning: vi.fn(), + addDependency: vi.fn(), + resolve: ( + _context: string, + request: string, + callback: (error: Error | null, result: string) => void, + ) => { + // Allow other loader calls to enter while compiler initialization resolves imports. + queueMicrotask(() => callback(null, request)) + }, + } as unknown as Rspack.LoaderContext +} + +const serverFnSource = (name: string) => ` + import { createServerFn } from '@tanstack/react-start' + export const ${name} = createServerFn().handler(() => '${name}') +` + +describe('Start compiler host environment', () => { + test('runs plugin-only transforms without any Start lookup kinds', async () => { + const environment = createEnvironment({ + compilerPlugins: [ + { + name: 'plugin-only', + detect: /pluginOnly/, + transformAst: ({ ast, types }) => { + ast.program.body.push( + types.exportNamedDeclaration( + types.variableDeclaration('const', [ + types.variableDeclarator( + types.identifier('transformed'), + types.booleanLiteral(true), + ), + ]), + ), + ) + return true + }, + }, + ], + }) + const result = await environment.transformModule( + createContext('/src/plugin.ts'), + 'export const pluginOnly = true', + ) + expect(result.code).toContain('export const transformed = true') + expect(result.serverFnsById).toEqual({}) + }) + + test('isolates metadata for concurrent modules in the same environment', async () => { + const environment = createEnvironment() + const results = await Promise.all( + ['first', 'second'].map((name) => + environment.transformModule( + createContext(`/src/${name}.ts?variant=caller`), + serverFnSource(name), + ), + ), + ) + for (const [i, name] of ['first', 'second'].entries()) { + const functions = Object.values(results[i]!.serverFnsById!) + expect(functions).toHaveLength(1) + expect(functions[0]!.filename).toBe(`/src/${name}.ts`) + expect(results[i]!.code).toContain('createClientRpc') + } + }) + + test('keeps client and server compiler state separate', async () => { + const client = createEnvironment() + const server = createEnvironment({ + environments: [{ name: 'ssr', type: 'server' }], + }) + const compileShared = (environment: typeof client) => + environment.transformModule( + createContext('/src/shared.ts'), + serverFnSource('shared'), + ) + const [clientResult, serverResult] = await Promise.all([ + compileShared(client), + compileShared(server), + ]) + expect(clientResult.code).toContain('createClientRpc') + expect(serverResult.code).toContain('createSsrRpc') + expect(Object.values(clientResult.serverFnsById!)).toHaveLength(1) + expect(Object.values(serverResult.serverFnsById!)).toHaveLength(1) + }) + + test('keeps dependency tracking on the requesting context across queued modules', async () => { + const environment = createEnvironment({ + inputFileSystem: { + readFile: ( + _file: string, + callback: (error: null, data: Buffer) => void, + ) => { + queueMicrotask(() => + callback( + null, + Buffer.from( + "export { createServerFn } from '@tanstack/react-start'", + ), + ), + ) + }, + } as unknown as Rspack.Compiler['inputFileSystem'], + }) + const contexts = ['first', 'second'].map((name) => { + const context = createContext(`/src/${name}/entry.ts`) + context.resolve = (directory, request, callback) => { + queueMicrotask(() => + callback( + null, + request === './factory' ? `${directory}/factory.ts` : request, + ), + ) + } + return context + }) + const results = await Promise.all( + contexts.map((context, index) => + environment.transformModule( + context, + serverFnSource(`fn${index}`).replace( + '@tanstack/react-start', + './factory', + ), + ), + ), + ) + for (const [index, name] of ['first', 'second'].entries()) { + const sourceDependencies = vi + .mocked(contexts[index]!.addDependency) + .mock.calls.map(([file]) => file) + .filter((file) => file.startsWith('/src/')) + expect(sourceDependencies).toEqual([`/src/${name}/factory.ts`]) + expect( + Object.values(results[index]!.serverFnsById!).map((fn) => fn.filename), + ).toEqual([`/src/${name}/entry.ts`]) + expect(results[index]!.code).toContain('createClientRpc') + } + }) + + test('continues queued work after a compilation fails', async () => { + const environment = createEnvironment() + const results = await Promise.allSettled([ + environment.transformModule( + createContext('/src/broken.ts'), + 'createServerFn( ', + ), + environment.transformModule( + createContext('/src/valid.ts'), + serverFnSource('valid'), + ), + ]) + expect(results[0]!.status).toBe('rejected') + expect(results[1]!.status).toBe('fulfilled') + if (results[1]!.status === 'fulfilled') { + expect( + Object.values(results[1].value.serverFnsById!).map((fn) => fn.filename), + ).toEqual(['/src/valid.ts']) + } + }) + + test('filters virtual output and retains its map without resolving compiler imports', async () => { + const map: Rspack.RawSourceMap = { + version: 3, + file: 'virtual.js', + sources: ['virtual.ts'], + names: [], + mappings: 'AAAA', + } + const loadVirtualModule = vi.fn(() => ({ + code: 'export const answer = 42', + map, + })) + const environment = createEnvironment({ + root: () => '/project', + compilerPlugins: [ + { + name: 'virtual', + virtualModuleIdPattern: /virtual/, + loadVirtualModule, + }, + ], + }) + const context = createContext('/src/virtual.ts?query') + context.resolve = vi.fn(() => { + throw new Error('filtered module must not resolve compiler imports') + }) + expect(await environment.transformModule(context, 'placeholder')).toEqual({ + code: 'export const answer = 42', + map, + serverFnsById: null, + }) + expect(loadVirtualModule).toHaveBeenCalledWith({ + code: 'placeholder', + id: '/src/virtual.ts?query', + root: '/project', + env: 'client', + envName: 'client', + }) + expect(context.resolve).not.toHaveBeenCalled() + }) + + test('compiles Start patterns introduced by a virtual module', async () => { + const environment = createEnvironment({ + compilerPlugins: [ + { + name: 'virtual', + virtualModuleIdPattern: /virtual/, + loadVirtualModule: () => ({ code: serverFnSource('virtualFn') }), + }, + ], + }) + const result = await environment.transformModule( + createContext('/src/virtual.ts'), + 'placeholder', + ) + expect(result.code).toContain('createClientRpc') + expect(Object.values(result.serverFnsById!)).toHaveLength(1) + }) + + test('retains virtual code and map when a matching plugin makes no changes', async () => { + const map: Rspack.RawSourceMap = { + version: 3, + file: 'virtual.js', + sources: ['original.ts'], + names: [], + mappings: 'AAAA', + } + const transformAst = vi.fn(() => false) + const environment = createEnvironment({ + compilerPlugins: [ + { + name: 'virtual-no-op', + virtualModuleIdPattern: /virtual/, + detect: /pluginOnly/, + loadVirtualModule: () => ({ + code: 'export const pluginOnly = true', + map, + }), + transformAst, + }, + ], + }) + const result = await environment.transformModule( + createContext('/src/virtual.ts?query'), + 'placeholder', + ) + expect(transformAst).toHaveBeenCalledOnce() + expect(result).toEqual({ + code: 'export const pluginOnly = true', + map, + serverFnsById: {}, + }) + }) + + test('tracks compiler dependencies and reloads them after invalidation', async () => { + let dependency = "export { createServerFn } from '@tanstack/react-start'" + const readFile = vi.fn( + (_file: string, callback: (error: null, data: Buffer) => void) => { + queueMicrotask(() => callback(null, Buffer.from(dependency))) + }, + ) + const environment = createEnvironment({ + inputFileSystem: { + readFile, + } as unknown as Rspack.Compiler['inputFileSystem'], + }) + const context = createContext('/src/consumer.ts') + context.resolve = (_directory, request, callback) => { + queueMicrotask(() => + callback(null, request === './factory' ? '/src/factory.ts' : request), + ) + } + const source = serverFnSource('fromFactory').replace( + '@tanstack/react-start', + './factory', + ) + const first = await environment.transformModule(context, source) + expect(first.code).toContain('createClientRpc') + expect(Object.values(first.serverFnsById!)).toHaveLength(1) + expect(context.addDependency).toHaveBeenCalledWith('/src/factory.ts') + expect(readFile).toHaveBeenCalledWith( + '/src/factory.ts', + expect.any(Function), + ) + + dependency = + 'export function createServerFn() { return { handler: (fn) => fn } }' + readFile.mockClear() + environment.invalidateModule('/src/factory.ts') + const second = await environment.transformModule(context, source) + expect(second.code).toBe(source) + expect(second.serverFnsById!).toEqual({}) + expect(readFile).toHaveBeenCalledWith( + '/src/factory.ts', + expect.any(Function), + ) + }) +}) diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index 39b3e69a9d0..7b4236afe99 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -22,7 +22,7 @@ export default mergeConfig( './src/rsbuild/index.ts', './src/rsbuild/types.ts', './src/rsbuild/import-protection-loader.ts', - './src/rsbuild/start-compiler-metadata-loader.ts', + './src/rsbuild/start-compiler-loader.ts', ], srcDir: './src', outDir: './dist',