From 6bf1896e92691040a8bdff315a26c062a1c524e9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:05:19 +0000 Subject: [PATCH 1/4] fix: run build-time render in a worker; invalidate cached server manifest - SPA-mode index.html and prerendering (classic and RSC) evaluate the server bundle in a worker thread that is terminated afterwards, so a module-scope handle in the app's server graph cannot keep rsbuild build alive (#135). - The node server-manifest module declares a file dependency on the captured manifest so Rspack's persistent cache invalidates it when the web build's asset names change (#136). --- .changeset/spa-build-process.md | 8 + README.md | 2 +- rslib.config.ts | 1 + src/build-output-transforms.ts | 12 ++ src/index.ts | 23 +++ src/prerender-build.ts | 157 ++++++++-------- src/rsc-prerender.ts | 39 +--- src/server-build-worker-client.ts | 165 +++++++++++++++++ src/server-build-worker-protocol.ts | 57 ++++++ src/server-build-worker.ts | 163 +++++++++++++++++ .../integration/helpers/rsbuild.ts | 5 + .../integration/spa-build-process-test.ts | 173 ++++++++++++++++++ tests/rsc-prerender.test.ts | 19 +- 13 files changed, 701 insertions(+), 123 deletions(-) create mode 100644 .changeset/spa-build-process.md create mode 100644 src/server-build-worker-client.ts create mode 100644 src/server-build-worker-protocol.ts create mode 100644 src/server-build-worker.ts create mode 100644 tests/react-router-framework/integration/spa-build-process-test.ts diff --git a/.changeset/spa-build-process.md b/.changeset/spa-build-process.md new file mode 100644 index 00000000..d14dec31 --- /dev/null +++ b/.changeset/spa-build-process.md @@ -0,0 +1,8 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Fix two `ssr: false` / prerender build issues: + +- `rsbuild build` no longer hangs when the app's server graph opens a ref'd handle at module scope (for example a `BroadcastChannel`). Build-time rendering (SPA-mode `index.html` and prerendering, classic and RSC) now evaluates the server bundle in a worker thread that is terminated once rendering is done, instead of importing it into the build process (#135). +- With `performance.buildCache` enabled, a warm build no longer renders `index.html` against the previous build's asset URLs. The server-manifest module now declares a file dependency on the captured manifest, so Rspack's persistent cache invalidates it whenever the web build's asset names change (#136). diff --git a/README.md b/README.md index 3b591e88..53690829 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ React Router's SPA Mode still requires a build-time server render of the root ro When `ssr: false`: - The plugin builds both `web` and `node` internally. -- It generates `build/client/index.html` by running the server build once (requesting `basename` with the `X-React-Router-SPA-Mode: yes` header). +- It generates `build/client/index.html` by running the server build once (requesting `basename` with the `X-React-Router-SPA-Mode: yes` header). The server bundle is evaluated in a worker thread that is terminated afterwards, with `process.env.IS_RR_BUILD_REQUEST === 'yes'` set, so module-scope side effects in your root route's import graph run at build time but cannot keep `rsbuild build` alive. The same applies to prerendering. - It removes `build/server` after generating `index.html`, so the output is deployable as static assets. **Important:** In SPA mode, use `clientLoader` instead of `loader` for data loading since there's no server at runtime. diff --git a/rslib.config.ts b/rslib.config.ts index 48abc6f7..14a20cc8 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -14,6 +14,7 @@ const config = defineConfig({ 'route-module-transform-loader': './src/route-module-transform-loader.ts', 'rsc-route-transform-loader': './src/rsc-route-transform-loader.ts', + 'server-build-worker': './src/server-build-worker.ts', 'templates/entry.server': './src/templates/entry.server.tsx', 'templates/entry.client': './src/templates/entry.client.tsx', 'templates/entry.rsc': './src/templates/entry.rsc.tsx', diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index 32bb4be5..fac4dd75 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import type { RsbuildPluginAPI, TransformHandler } from '@rsbuild/core'; import jsesc from 'jsesc'; import { relative } from 'pathe'; @@ -80,6 +81,8 @@ type RegisterBuildOutputTransformsOptions = { resolvedServerOutput: 'module' | 'commonjs'; performanceProfiler: ReactRouterPerformanceProfiler; getLatestServerManifest: () => ReactRouterManifest | null; + /** File holding the captured manifests; a dependency of the server-manifest module. */ + serverManifestStampPath: string; getLatestServerManifestByBundleId: ( bundleId: string ) => ReactRouterManifest | undefined; @@ -112,6 +115,7 @@ export const registerBuildOutputTransforms = ({ resolvedServerOutput, performanceProfiler, getLatestServerManifest, + serverManifestStampPath, getLatestServerManifestByBundleId, routes, pluginOptions, @@ -205,6 +209,14 @@ export const registerBuildOutputTransforms = ({ }; } + // Tie this module's cache identity to the manifest content: the + // virtual source never changes, so without this dependency Rspack's + // persistent cache serves a previous build's manifest (#136). + if (existsSync(serverManifestStampPath)) { + args.addDependency(serverManifestStampPath); + } else { + args.addMissingDependency(serverManifestStampPath); + } const bundleMatch = args.resource.match( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); diff --git a/src/index.ts b/src/index.ts index 6491e5b3..e8047913 100644 --- a/src/index.ts +++ b/src/index.ts @@ -530,6 +530,26 @@ export const pluginReactRouter = ( let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; + // The node `server-manifest` module's source is a constant; its real + // content is injected by a transform from the web compilation's emitted + // asset names. Rspack's persistent cache would therefore reuse a previous + // build's module even when those names changed (#136). The transform + // declares this file, which holds the captured manifests, as a file + // dependency so the cache invalidates exactly when the manifest changes. + const serverManifestStampPath = resolve( + api.context.cachePath, + 'react-router', + 'server-manifest.json' + ); + const writeServerManifestStamp = (): void => { + fsExtra.outputFileSync( + serverManifestStampPath, + JSON.stringify({ + base: latestServerManifest, + bundles: latestServerManifestsByBundleId, + }) + ); + }; const routeByFilePath = new Map( Object.values(routes).map(route => [ @@ -732,6 +752,7 @@ export const pluginReactRouter = ( }; if (modePlan.kind !== 'classic') { + writeServerManifestStamp(); return; } @@ -756,6 +777,7 @@ export const pluginReactRouter = ( latestServerManifestsByBundleId[bundleId] = bundleManifest; manifestsByEntryName[entryName] = bundleManifest; } + writeServerManifestStamp(); if (!isBuild) { modePlan.artifacts.devRuntime.captureWeb( @@ -1159,6 +1181,7 @@ export const pluginReactRouter = ( resolvedServerOutput, performanceProfiler, getLatestServerManifest: () => latestServerManifest, + serverManifestStampPath, getLatestServerManifestByBundleId: bundleId => latestServerManifestsByBundleId[bundleId], routes, diff --git a/src/prerender-build.ts b/src/prerender-build.ts index 7c6e18e2..eb00ac3a 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -1,14 +1,9 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import * as Effect from 'effect/Effect'; import type { RsbuildPluginAPI } from '@rsbuild/core'; -import { - createRequestHandler, - matchRoutes, - type ServerBuild, -} from 'react-router'; +import { matchRoutes } from 'react-router'; import { dirname, relative, resolve } from 'pathe'; import { PLUGIN_NAME, SPA_FALLBACK_HTML_FILE } from './constants.js'; import { getBuildManifest } from './build-manifest.js'; @@ -31,23 +26,12 @@ import type { Config, ResolvedReactRouterConfig, } from './react-router-config.js'; -import { resolveServerBuildModule } from './server-utils.js'; +import { startServerBuildWorker } from './server-build-worker-client.js'; +import type { ServerBuildDescription } from './server-build-worker-protocol.js'; import type { PluginOptions, Route } from './types.js'; import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; -type BuildRouteModule = { - loader?: unknown; - default?: unknown; - ErrorBoundary?: unknown; -}; - -type PrerenderServerBuild = ServerBuild & { - routes: Record; - assets?: { - routes?: Record; - }; - prerender?: string[]; -}; +type PrerenderServerBuild = ServerBuildDescription; type PrerenderBuildApi = Pick< RsbuildPluginAPI, @@ -596,75 +580,80 @@ export const runReactRouterPrerenderBuild = async ( await mkdir(clientBuildDir, { recursive: true }); if (!ssr || isPrerenderEnabled) { - process.env.IS_RR_BUILD_REQUEST = 'yes'; - const buildModule = await import(pathToFileURL(serverBuildPath).toString()); - const build = (await resolveServerBuildModule( - buildModule, - `Server build ${JSON.stringify(serverBuildPath)}` - )) as PrerenderServerBuild; - const requestHandler = createRequestHandler(build, 'production'); - - if (isPrerenderEnabled) { - if (!ssr) { - const generated = latestBrowserManifest - ? { - manifest: latestBrowserManifest, - moduleExportsByRouteId: latestBrowserManifestModuleExports, - } - : await generateReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - createReactRouterManifestOptions({ - routeChunks: routeChunkOptions, - routeModuleAnalysis, - }) - ); - assertValidSsrFalsePrerenderExports({ - routes, - manifestRoutes: generated.manifest.routes, - routeExports: generated.moduleExportsByRouteId, - prerenderPaths, - api, - }); - } + // The server bundle runs in a worker that is terminated afterwards, so a + // handle its module graph opens cannot keep the build alive (#135). + const worker = await startServerBuildWorker({ + serverBuildPath, + mode: 'classic', + }); + try { + const build: PrerenderServerBuild = await worker.describe(); + const requestHandler = worker.handler; + + if (isPrerenderEnabled) { + if (!ssr) { + const generated = latestBrowserManifest + ? { + manifest: latestBrowserManifest, + moduleExportsByRouteId: latestBrowserManifestModuleExports, + } + : await generateReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + createReactRouterManifestOptions({ + routeChunks: routeChunkOptions, + routeModuleAnalysis, + }) + ); + assertValidSsrFalsePrerenderExports({ + routes, + manifestRoutes: generated.manifest.routes, + routeExports: generated.moduleExportsByRouteId, + prerenderPaths, + api, + }); + } + + validatePrerenderPathMatches(routes, prerenderPaths); - validatePrerenderPathMatches(routes, prerenderPaths); + if (prerenderPaths.length > 0) { + api.logger.info( + `Prerender (html): ${prerenderPaths.length} path(s)...` + ); + } - if (prerenderPaths.length > 0) { - api.logger.info( - `Prerender (html): ${prerenderPaths.length} path(s)...` + const buildRoutes = createPrerenderRoutes(build.routes); + await runPluginEffect( + createBoundedPrerenderTasksEffect( + prerenderPaths, + getPrerenderConcurrency(prerenderConfig), + path => + createPrerenderPathEffect({ + path, + build, + buildRoutes, + requestHandler, + clientBuildDir, + options, + }) + ) ); } - const buildRoutes = createPrerenderRoutes(build.routes); - await runPluginEffect( - createBoundedPrerenderTasksEffect( - prerenderPaths, - getPrerenderConcurrency(prerenderConfig), - path => - createPrerenderPathEffect({ - path, - build, - buildRoutes, - requestHandler, - clientBuildDir, - options, - }) - ) - ); - } - - if (!ssr) { - await handleSpaMode({ - handler: requestHandler, - build, - clientBuildDir, - basename, - api, - }); + if (!ssr) { + await handleSpaMode({ + handler: requestHandler, + build, + clientBuildDir, + basename, + api, + }); + } + } finally { + await worker.close(); } } diff --git a/src/rsc-prerender.ts b/src/rsc-prerender.ts index 3d8c7b14..dfcf35b5 100644 --- a/src/rsc-prerender.ts +++ b/src/rsc-prerender.ts @@ -1,10 +1,10 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; import type { RsbuildPluginAPI } from '@rsbuild/core'; import { dirname, relative, resolve } from 'pathe'; import * as Effect from 'effect/Effect'; import { PLUGIN_NAME, SPA_FALLBACK_HTML_FILE } from './constants.js'; +import { startServerBuildWorker } from './server-build-worker-client.js'; import { createBuildRequestEffect, createBoundedPrerenderTasksEffect, @@ -145,29 +145,6 @@ const createRedirectHtml = ({ `; }; -const resolveRscRequestHandler = ( - buildModule: unknown, - serverBuildPath: string -): RscRequestHandler => { - const moduleRecord = buildModule as - | { default?: { fetch?: unknown; default?: { fetch?: unknown } } } - | undefined; - const handler = - typeof moduleRecord?.default?.fetch === 'function' - ? moduleRecord.default.fetch - : typeof moduleRecord?.default?.default?.fetch === 'function' - ? moduleRecord.default.default.fetch - : null; - if (!handler) { - throw new Error( - `[${PLUGIN_NAME}] RSC server build ${JSON.stringify( - serverBuildPath - )} must default-export an object with a fetch function.` - ); - } - return handler as RscRequestHandler; -}; - const writePrerenderedFile = async ({ api, clientBuildDir, @@ -330,11 +307,11 @@ export const runReactRouterRscPrerenderBuild = async ( const clientBuildDir = resolve(buildDirectory, 'client'); await mkdir(clientBuildDir, { recursive: true }); - const previousBuildRequestFlag = process.env.IS_RR_BUILD_REQUEST; - process.env.IS_RR_BUILD_REQUEST = 'yes'; + // The server bundle runs in a worker that is terminated afterwards, so a + // handle its module graph opens cannot keep the build alive (#135). + const worker = await startServerBuildWorker({ serverBuildPath, mode: 'rsc' }); try { - const buildModule = await import(pathToFileURL(serverBuildPath).toString()); - const handler = resolveRscRequestHandler(buildModule, serverBuildPath); + const handler: RscRequestHandler = request => worker.handler(request); api.logger.info(`Prerender: ${prerenderRequests.length} path(s)...`); @@ -353,10 +330,6 @@ export const runReactRouterRscPrerenderBuild = async ( ) ); } finally { - if (previousBuildRequestFlag === undefined) { - delete process.env.IS_RR_BUILD_REQUEST; - } else { - process.env.IS_RR_BUILD_REQUEST = previousBuildRequestFlag; - } + await worker.close(); } }; diff --git a/src/server-build-worker-client.ts b/src/server-build-worker-client.ts new file mode 100644 index 00000000..7aaba4d2 --- /dev/null +++ b/src/server-build-worker-client.ts @@ -0,0 +1,165 @@ +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; +import type { + ServerBuildDescription, + ServerBuildWorkerData, + ServerBuildWorkerRequest, + ServerBuildWorkerResponse, +} from './server-build-worker-protocol.js'; + +const workerPath = fileURLToPath( + new URL('./server-build-worker.js', import.meta.url) +); + +export type ServerBuildWorker = { + /** Plain-data view of the classic server build (routes, assets, prerender). */ + describe(): Promise; + /** Runs the request against the server build in the worker. */ + handler(request: Request): Promise; + /** Terminates the worker, and with it any handle the server graph opened. */ + close(): Promise; +}; + +type DistributiveOmit = T extends unknown + ? Omit + : never; + +const headerEntries = (headers: Headers): [string, string][] => { + const entries: [string, string][] = []; + headers.forEach((value, key) => entries.push([key, value])); + return entries; +}; + +type Pending = { + resolve: (message: ServerBuildWorkerResponse) => void; + reject: (error: Error) => void; +}; + +/** + * Evaluate a built server bundle in a worker thread and proxy requests to it. + * Build-time rendering used to `import()` the bundle into the build process; + * a module-scope handle in the app's server graph then kept `rsbuild build` + * alive forever (#135). The worker is terminated by `close()`. + */ +export const startServerBuildWorker = async ( + data: ServerBuildWorkerData +): Promise => { + const worker = new Worker(workerPath, { workerData: data }); + const pending = new Map(); + let nextId = 0; + let failure: Error | undefined; + + const failAll = (error: Error): void => { + failure = error; + for (const { reject } of pending.values()) { + reject(error); + } + pending.clear(); + }; + + worker.on('message', (message: ServerBuildWorkerResponse) => { + const entry = pending.get(message.id); + if (!entry) { + return; + } + pending.delete(message.id); + entry.resolve(message); + }); + worker.on('error', error => + failAll(error instanceof Error ? error : new Error(String(error))) + ); + worker.on('exit', code => { + if (pending.size > 0) { + failAll( + new Error( + `Server build worker exited with code ${code} while rendering` + ) + ); + } + }); + + const send = ( + request: DistributiveOmit, + transfer: ArrayBuffer[] = [] + ): Promise => + new Promise((resolve, reject) => { + if (failure) { + reject(failure); + return; + } + const id = nextId++; + pending.set(id, { resolve, reject }); + worker.postMessage({ ...request, id }, transfer); + }); + + const toError = (message: ServerBuildWorkerResponse): Error => { + if (message.ok) { + return new Error('Unexpected server build worker reply'); + } + const error = new Error(message.error.message); + error.name = message.error.name ?? error.name; + if (message.error.stack) { + error.stack = message.error.stack; + } + return error; + }; + + // Wait for the bundle to be evaluated; import errors surface as worker + // 'error' events, which reject this pending entry. + await new Promise((resolve, reject) => { + pending.set(-1, { + resolve: message => (message.ok ? resolve() : reject(toError(message))), + reject, + }); + }); + + return { + async describe() { + const message = await send({ type: 'describe' }); + if (!message.ok) { + throw toError(message); + } + if (!('description' in message) || !message.description) { + throw new Error('Server build worker has no build description'); + } + return message.description; + }, + async handler(request) { + const hasBody = request.method !== 'GET' && request.method !== 'HEAD'; + const body = hasBody + ? new Uint8Array(await request.arrayBuffer()) + : undefined; + const message = await send( + { + type: 'request', + url: request.url, + method: request.method, + headers: headerEntries(request.headers), + body, + }, + body ? [body.buffer as ArrayBuffer] : [] + ); + if (!message.ok) { + throw toError(message); + } + if (!('response' in message)) { + throw new Error('Server build worker returned no response'); + } + const { + status, + statusText, + headers, + body: responseBody, + } = message.response; + return new Response( + status === 204 || status === 304 || status === 101 + ? null + : (responseBody as unknown as BodyInit), + { status, statusText, headers } + ); + }, + async close() { + await worker.terminate(); + }, + }; +}; diff --git a/src/server-build-worker-protocol.ts b/src/server-build-worker-protocol.ts new file mode 100644 index 00000000..51325171 --- /dev/null +++ b/src/server-build-worker-protocol.ts @@ -0,0 +1,57 @@ +// Messages between the build process and `server-build-worker`. + +export type ServerBuildWorkerData = { + serverBuildPath: string; + mode: 'classic' | 'rsc'; +}; + +export type ServerBuildWorkerRequest = + | { id: number; type: 'describe' } + | { + id: number; + type: 'request'; + url: string; + method: string; + headers: [string, string][]; + body?: Uint8Array; + }; + +export type SerializedResponse = { + status: number; + statusText: string; + headers: [string, string][]; + body: Uint8Array; +}; + +export type SerializedError = { + message: string; + stack?: string; + name?: string; +}; + +export type ServerBuildWorkerResponse = + | { id: number; ok: true; ready: true } + | { id: number; ok: true; description?: ServerBuildDescription } + | { id: number; ok: true; response: SerializedResponse } + | { id: number; ok: false; error: SerializedError }; + +/** + * The parts of a classic React Router server build that build-time rendering + * reads, as plain data: route module exports are reported by presence only. + */ +export type ServerBuildDescription = { + basename?: string; + prerender?: string[]; + routes: Record< + string, + { + id?: string; + parentId?: string; + path?: string; + index?: boolean; + caseSensitive?: boolean; + module: { default: boolean; ErrorBoundary: boolean; loader: boolean }; + } + >; + assets: { routes: Record }; +}; diff --git a/src/server-build-worker.ts b/src/server-build-worker.ts new file mode 100644 index 00000000..d33d1c10 --- /dev/null +++ b/src/server-build-worker.ts @@ -0,0 +1,163 @@ +// Worker entry: evaluates a freshly built server bundle and serves requests to +// it for build-time rendering (SPA-mode `index.html`, prerendering). Running +// the bundle here instead of in the build process means any handle the app's +// server graph creates at module scope (a `BroadcastChannel`, a timer, a +// connection) dies with `worker.terminate()` and cannot keep `rsbuild build` +// alive (#135). The worker sets `IS_RR_BUILD_REQUEST` for its own module +// graph only. +import { parentPort, workerData } from 'node:worker_threads'; +import { pathToFileURL } from 'node:url'; +import { createRequestHandler } from 'react-router'; +import { resolveServerBuildModule } from './server-build-resolution.js'; +import type { + ServerBuildWorkerData, + ServerBuildWorkerRequest, + ServerBuildWorkerResponse, + ServerBuildDescription, +} from './server-build-worker-protocol.js'; + +type BuildRouteLike = { + id?: string; + parentId?: string; + path?: string; + index?: boolean; + caseSensitive?: boolean; + module?: Record; +}; + +const port = parentPort; +if (!port) { + throw new Error('server-build-worker must run as a worker thread'); +} + +const { serverBuildPath, mode } = workerData as ServerBuildWorkerData; +process.env.IS_RR_BUILD_REQUEST = 'yes'; + +const post = ( + message: ServerBuildWorkerResponse, + transfer: ArrayBuffer[] = [] +): void => { + port.postMessage(message, transfer); +}; + +const headerEntries = (headers: Headers): [string, string][] => { + const entries: [string, string][] = []; + headers.forEach((value, key) => entries.push([key, value])); + return entries; +}; + +const serializeError = (error: unknown) => { + const value = error as { message?: unknown; stack?: unknown; name?: unknown }; + return { + message: String(value?.message ?? error), + stack: typeof value?.stack === 'string' ? value.stack : undefined, + name: typeof value?.name === 'string' ? value.name : undefined, + }; +}; + +const describeClassicBuild = (build: { + basename?: string; + prerender?: string[]; + routes?: Record; + assets?: { routes?: Record }; +}): ServerBuildDescription => ({ + basename: build.basename, + prerender: build.prerender, + routes: Object.fromEntries( + Object.entries(build.routes ?? {}).map(([id, route]) => [ + id, + { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: { + default: route.module?.default !== undefined, + ErrorBoundary: route.module?.ErrorBoundary !== undefined, + loader: route.module?.loader !== undefined, + }, + }, + ]) + ), + assets: { + routes: Object.fromEntries( + Object.entries(build.assets?.routes ?? {}).map(([id, route]) => [ + id, + { hasLoader: route.hasLoader }, + ]) + ), + }, +}); + +const resolveRscFetch = ( + buildModule: unknown +): ((request: Request) => Promise) => { + const moduleRecord = buildModule as + | { default?: { fetch?: unknown; default?: { fetch?: unknown } } } + | undefined; + const fetch = + typeof moduleRecord?.default?.fetch === 'function' + ? moduleRecord.default.fetch + : typeof moduleRecord?.default?.default?.fetch === 'function' + ? moduleRecord.default.default.fetch + : null; + if (!fetch) { + throw new Error( + `RSC server build ${JSON.stringify( + serverBuildPath + )} must default-export an object with a fetch function.` + ); + } + return fetch as (request: Request) => Promise; +}; + +const buildModule = await import(pathToFileURL(serverBuildPath).href); +let description: ServerBuildDescription | undefined; +let handler: (request: Request) => Promise; +if (mode === 'classic') { + const build = await resolveServerBuildModule( + buildModule, + `Server build ${JSON.stringify(serverBuildPath)}` + ); + description = describeClassicBuild( + build as unknown as Parameters[0] + ); + handler = createRequestHandler(build, 'production'); +} else { + handler = resolveRscFetch(buildModule); +} + +port.on('message', async (message: ServerBuildWorkerRequest) => { + try { + if (message.type === 'describe') { + post({ id: message.id, ok: true, description }); + return; + } + const response = await handler( + new Request(message.url, { + method: message.method, + headers: message.headers, + body: message.body as BodyInit | undefined, + }) + ); + const body = new Uint8Array(await response.arrayBuffer()); + post( + { + id: message.id, + ok: true, + response: { + status: response.status, + statusText: response.statusText, + headers: headerEntries(response.headers), + body, + }, + }, + [body.buffer as ArrayBuffer] + ); + } catch (error) { + post({ id: message.id, ok: false, error: serializeError(error) }); + } +}); + +post({ id: -1, ok: true, ready: true }); diff --git a/tests/react-router-framework/integration/helpers/rsbuild.ts b/tests/react-router-framework/integration/helpers/rsbuild.ts index 11e9f9c9..effcb236 100644 --- a/tests/react-router-framework/integration/helpers/rsbuild.ts +++ b/tests/react-router-framework/integration/helpers/rsbuild.ts @@ -199,9 +199,12 @@ const colorEnv = { export const build = ({ cwd, env = {}, + timeout, }: { cwd: string; env?: Record; + /** Kill the build (SIGKILL) after this many ms; `status` is then `null`. */ + timeout?: number; }) => { let nodeBin = process.argv[0]; prepareFixtureProjectDependencies(cwd); @@ -213,6 +216,8 @@ export const build = ({ ...colorEnv, ...env, }), + timeout, + killSignal: "SIGKILL", }); }; diff --git a/tests/react-router-framework/integration/spa-build-process-test.ts b/tests/react-router-framework/integration/spa-build-process-test.ts new file mode 100644 index 00000000..a1aff8fd --- /dev/null +++ b/tests/react-router-framework/integration/spa-build-process-test.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { js } from "./helpers/create-fixture.js"; +import { build, createProject, reactRouterConfig } from "./helpers/rsbuild.js"; + +// Build-time rendering (SPA-mode `index.html`, prerendering) evaluates the +// freshly built server bundle. These tests pin down two properties of that +// step that only show up in real builds: +// - #135: the build process must exit even when the app's server graph opens +// a ref'd handle at module scope (the bundle runs in a terminated worker). +// - #136: with Rspack's persistent cache, a warm build must render against +// the assets it emitted, not the previous build's. + +// Generous: a hung build never exits, so any finite bound distinguishes. +const BUILD_TIMEOUT_MS = 180_000; + +const rsbuildConfigFile = ({ + rsc = false, + buildCache = false, +}: { rsc?: boolean; buildCache?: boolean } = {}) => { + const plugin = rsc ? "pluginReactRouterRSC" : "pluginReactRouter"; + return js` + import { defineConfig } from "@rsbuild/core"; + import { pluginReact } from "@rsbuild/plugin-react"; + import { ${plugin} } from "rsbuild-plugin-react-router"; + + export default defineConfig({ + plugins: [pluginReact(), ${plugin}()], + performance: { buildCache: ${String(buildCache)} }, + }); + `; +}; + +// Node backs BroadcastChannel with a ref'd MessagePort, and it has been a +// global since v18, so `typeof BroadcastChannel !== "undefined"` guards pass +// at build time too. A common SPA pattern (cross-tab sign-out sync). +const moduleScopeHandleFiles = ({ rsc = false } = {}) => ({ + "app/auth-channel.ts": js` + export const channel = new BroadcastChannel("app-signout"); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, ScrollRestoration${rsc ? "" : ", Scripts"} } from "react-router"; + import "./auth-channel"; + + export default function App() { + return ( + + + + + + + + + ${rsc ? "" : ""} + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Home

; + } + `, +}); + +const expectBuildExited = (result: ReturnType) => { + const stderr = result.stderr.toString("utf8"); + expect( + result.signal, + `build did not exit within ${BUILD_TIMEOUT_MS}ms\n${stderr}`, + ).toBeNull(); + expect(result.status, stderr).toBe(0); + return result.stdout.toString("utf8"); +}; + +test.describe("build process with a module-scope handle in the server graph (#135)", () => { + test("ssr: false exits after generating index.html", async () => { + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ ssr: false }), + "rsbuild.config.ts": rsbuildConfigFile(), + ...moduleScopeHandleFiles(), + }); + const stdout = expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect(stdout).toContain("Removed server build"); + expect(fs.existsSync(path.join(cwd, "build/client/index.html"))).toBe(true); + expect(fs.existsSync(path.join(cwd, "build/server"))).toBe(false); + }); + + test("prerender exits after writing the prerendered pages", async () => { + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: true, + prerender: ["/"], + }), + "rsbuild.config.ts": rsbuildConfigFile(), + ...moduleScopeHandleFiles(), + }); + expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect( + fs.readFileSync(path.join(cwd, "build/client/index.html"), "utf8"), + ).toContain("

Home

"); + }); + + test("RSC prerender exits after writing the prerendered pages", async () => { + const cwd = await createProject( + { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/"], + }), + "rsbuild.config.ts": rsbuildConfigFile({ rsc: true }), + ...moduleScopeHandleFiles({ rsc: true }), + }, + "rsc-framework", + ); + expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect( + fs.readFileSync(path.join(cwd, "build/client/index.html"), "utf8"), + ).toContain("

Home

"); + }); +}); + +test.describe("ssr: false with performance.buildCache (#136)", () => { + test("a warm build renders index.html against its own assets", async () => { + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ ssr: false }), + "rsbuild.config.ts": rsbuildConfigFile({ buildCache: true }), + "app/routes/_index.tsx": js` + export default function Index() { + return

Home

; + } + `, + }); + const referencedScripts = () => { + const html = fs.readFileSync( + path.join(cwd, "build/client/index.html"), + "utf8", + ); + const urls = [...html.matchAll(/["']\/(static\/js\/[^"']+\.js)["']/g)].map( + (match) => match[1], + ); + expect(urls.length).toBeGreaterThan(0); + return [...new Set(urls)]; + }; + const emitted = (url: string) => + fs.existsSync(path.join(cwd, "build/client", url)); + + // Cold build: fills the persistent cache. + expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + const coldScripts = referencedScripts(); + expect(coldScripts.filter((url) => !emitted(url))).toEqual([]); + + // Change the root route so its (and the manifest's) content hash moves. + const rootPath = path.join(cwd, "app/root.tsx"); + fs.writeFileSync( + rootPath, + fs + .readFileSync(rootPath, "utf8") + .replace('', ''), + ); + fs.rmSync(path.join(cwd, "build"), { recursive: true, force: true }); + + // Warm build: the server-manifest module must not be served from cache. + expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + const warmScripts = referencedScripts(); + expect(warmScripts).not.toEqual(coldScripts); + expect(warmScripts.filter((url) => !emitted(url))).toEqual([]); + }); +}); diff --git a/tests/rsc-prerender.test.ts b/tests/rsc-prerender.test.ts index 9a1bcbb0..c7f01d81 100644 --- a/tests/rsc-prerender.test.ts +++ b/tests/rsc-prerender.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { createLogger } from '@rsbuild/core'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { SPA_FALLBACK_REQUEST_PATH, extractRscFlightData, @@ -13,6 +13,18 @@ import { runReactRouterRscPrerenderBuild, } from '../src/rsc-prerender'; +// The server bundle is evaluated in a worker shipped with `dist/`, which does +// not exist when unit tests run from source; the worker itself is exercised by +// the React Router integration suite (spa-build-process-test.ts). Stand in a +// handler that always fails so the error reporting path is what's under test. +rstest.mock('../src/server-build-worker-client', () => ({ + startServerBuildWorker: async () => ({ + describe: async () => ({ routes: {}, assets: { routes: {} } }), + handler: async () => new Response(null, { status: 500 }), + close: async () => {}, + }), +})); + const flightScript = (chunk: string) => ``; @@ -165,10 +177,7 @@ describe('runReactRouterRscPrerenderBuild', () => { try { const serverDirectory = resolve(buildDirectory, 'server'); await mkdir(serverDirectory); - await writeFile( - resolve(serverDirectory, 'index.js'), - 'export default { fetch: async () => new Response(null, { status: 500 }) };' - ); + await writeFile(resolve(serverDirectory, 'index.js'), ''); await expect( runReactRouterRscPrerenderBuild({ From 58c133a354cec85ab6575134f23b97a7e6b96a89 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:19:00 +0000 Subject: [PATCH 2/4] test: keep the buildCache fixture's persistent cache out of the shared template node_modules --- .../integration/spa-build-process-test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/react-router-framework/integration/spa-build-process-test.ts b/tests/react-router-framework/integration/spa-build-process-test.ts index a1aff8fd..cb0f3f58 100644 --- a/tests/react-router-framework/integration/spa-build-process-test.ts +++ b/tests/react-router-framework/integration/spa-build-process-test.ts @@ -28,7 +28,11 @@ const rsbuildConfigFile = ({ export default defineConfig({ plugins: [pluginReact(), ${plugin}()], - performance: { buildCache: ${String(buildCache)} }, + // Fixtures share the template's node_modules, so keep the persistent + // cache inside the fixture instead of the default node_modules/.cache. + performance: { + buildCache: ${buildCache ? '{ cacheDirectory: "./.rspack-cache" }' : "false"}, + }, }); `; }; From 9ea6ee33bb422f1908938256c8003e73408e396f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:05:40 +0000 Subject: [PATCH 3/4] fix(worker): relay request abort into the worker; treat every worker exit as terminal - Each worker-side request gets its own AbortController; the Request the app receives is aborted when the parent releases it (relayed 'abort' message) or once its response has been consumed, matching the in-process contract. - Any worker exit is recorded as terminal regardless of pending requests, so a request sent to a worker that exited while idle rejects deterministically; close() is terminal too. - The classic build description rides on the ready message; the separate describe round-trip is gone. - Real-worker unit test (tests/server-build-worker.test.ts) against the built worker covers abort-on-release, in-flight abort relay, error mapping, idle exit, close(), and import failure; CI builds before unit tests. --- .github/workflows/e2e-tests.yml | 7 +- src/prerender-build.ts | 7 +- src/server-build-worker-client.ts | 168 +++++++++--------- src/server-build-worker-protocol.ts | 13 +- src/server-build-worker.ts | 32 +++- .../integration/spa-build-process-test.ts | 100 +++++++++++ tests/rsc-prerender.test.ts | 2 +- tests/server-build-worker.test.ts | 155 ++++++++++++++++ 8 files changed, 381 insertions(+), 103 deletions(-) create mode 100644 tests/server-build-worker.test.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bf20c0bd..51c0ee28 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -44,12 +44,13 @@ jobs: - name: Install dependencies run: pnpm install - - name: Run unit tests - run: pnpm test - + # Unit tests exercise the built server-build worker (dist/). - name: Build package run: pnpm build + - name: Run unit tests + run: pnpm test + - name: Run publint run: npx publint --errors-only diff --git a/src/prerender-build.ts b/src/prerender-build.ts index eb00ac3a..31735e0c 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -587,7 +587,12 @@ export const runReactRouterPrerenderBuild = async ( mode: 'classic', }); try { - const build: PrerenderServerBuild = await worker.describe(); + const build: PrerenderServerBuild | undefined = worker.description; + if (!build) { + throw new Error( + `[${PLUGIN_NAME}] Server build worker returned no build description` + ); + } const requestHandler = worker.handler; if (isPrerenderEnabled) { diff --git a/src/server-build-worker-client.ts b/src/server-build-worker-client.ts index 7aaba4d2..45ba6849 100644 --- a/src/server-build-worker-client.ts +++ b/src/server-build-worker-client.ts @@ -7,22 +7,25 @@ import type { ServerBuildWorkerResponse, } from './server-build-worker-protocol.js'; -const workerPath = fileURLToPath( +const defaultWorkerPath = fileURLToPath( new URL('./server-build-worker.js', import.meta.url) ); export type ServerBuildWorker = { /** Plain-data view of the classic server build (routes, assets, prerender). */ - describe(): Promise; + description: ServerBuildDescription | undefined; /** Runs the request against the server build in the worker. */ handler(request: Request): Promise; /** Terminates the worker, and with it any handle the server graph opened. */ close(): Promise; }; -type DistributiveOmit = T extends unknown - ? Omit - : never; +type Reply = Extract; + +type Pending = { + resolve: (reply: Reply) => void; + reject: (error: Error) => void; +}; const headerEntries = (headers: Headers): [string, string][] => { const entries: [string, string][] = []; @@ -30,9 +33,16 @@ const headerEntries = (headers: Headers): [string, string][] => { return entries; }; -type Pending = { - resolve: (message: ServerBuildWorkerResponse) => void; - reject: (error: Error) => void; +const toError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)); + +const replyError = (reply: Extract): Error => { + const error = new Error(reply.error.message); + error.name = reply.error.name ?? error.name; + if (reply.error.stack) { + error.stack = reply.error.stack; + } + return error; }; /** @@ -40,117 +50,102 @@ type Pending = { * Build-time rendering used to `import()` the bundle into the build process; * a module-scope handle in the app's server graph then kept `rsbuild build` * alive forever (#135). The worker is terminated by `close()`. + * + * The worker's `exit` is its final event, so any exit (including one between + * requests, e.g. the app calling `process.exit`) is terminal: outstanding and + * later requests reject instead of waiting for a reply that cannot come. */ export const startServerBuildWorker = async ( - data: ServerBuildWorkerData + data: ServerBuildWorkerData, + // Tests run from `src/` and point this at the built worker. + workerPath: string = defaultWorkerPath ): Promise => { const worker = new Worker(workerPath, { workerData: data }); const pending = new Map(); let nextId = 0; let failure: Error | undefined; - const failAll = (error: Error): void => { - failure = error; + const fail = (error: Error): void => { + failure ??= error; for (const { reject } of pending.values()) { - reject(error); + reject(failure); } pending.clear(); }; - worker.on('message', (message: ServerBuildWorkerResponse) => { - const entry = pending.get(message.id); - if (!entry) { - return; + const ready = new Promise( + (resolve, reject) => { + worker.on('message', (message: ServerBuildWorkerResponse) => { + if (message.type === 'ready') { + resolve(message.description); + return; + } + const entry = pending.get(message.id); + pending.delete(message.id); + entry?.resolve(message); + }); + worker.on('error', error => { + fail(toError(error)); + reject(failure); + }); + worker.on('exit', code => { + fail(new Error(`Server build worker exited with code ${code}`)); + reject(failure); + }); } - pending.delete(message.id); - entry.resolve(message); - }); - worker.on('error', error => - failAll(error instanceof Error ? error : new Error(String(error))) ); - worker.on('exit', code => { - if (pending.size > 0) { - failAll( - new Error( - `Server build worker exited with code ${code} while rendering` - ) - ); - } - }); const send = ( - request: DistributiveOmit, + request: ServerBuildWorkerRequest, transfer: ArrayBuffer[] = [] - ): Promise => - new Promise((resolve, reject) => { - if (failure) { - reject(failure); - return; - } - const id = nextId++; - pending.set(id, { resolve, reject }); - worker.postMessage({ ...request, id }, transfer); - }); - - const toError = (message: ServerBuildWorkerResponse): Error => { - if (message.ok) { - return new Error('Unexpected server build worker reply'); - } - const error = new Error(message.error.message); - error.name = message.error.name ?? error.name; - if (message.error.stack) { - error.stack = message.error.stack; - } - return error; + ): void => { + worker.postMessage(request, transfer); }; - // Wait for the bundle to be evaluated; import errors surface as worker - // 'error' events, which reject this pending entry. - await new Promise((resolve, reject) => { - pending.set(-1, { - resolve: message => (message.ok ? resolve() : reject(toError(message))), - reject, - }); - }); + // Import errors surface as worker 'error' events, an early exit as 'exit'. + const description = await ready; return { - async describe() { - const message = await send({ type: 'describe' }); - if (!message.ok) { - throw toError(message); - } - if (!('description' in message) || !message.description) { - throw new Error('Server build worker has no build description'); - } - return message.description; - }, + description, async handler(request) { + if (failure) { + throw failure; + } + const id = nextId++; const hasBody = request.method !== 'GET' && request.method !== 'HEAD'; const body = hasBody ? new Uint8Array(await request.arrayBuffer()) : undefined; - const message = await send( - { - type: 'request', - url: request.url, - method: request.method, - headers: headerEntries(request.headers), - body, - }, - body ? [body.buffer as ArrayBuffer] : [] - ); - if (!message.ok) { - throw toError(message); - } - if (!('response' in message)) { - throw new Error('Server build worker returned no response'); + // Relay the parent's release so the worker-side Request aborts too. + const onAbort = (): void => { + if (pending.has(id)) { + send({ type: 'abort', id }); + } + }; + request.signal.addEventListener('abort', onAbort, { once: true }); + const reply = await new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + send( + { + type: 'request', + id, + url: request.url, + method: request.method, + headers: headerEntries(request.headers), + body, + }, + body ? [body.buffer as ArrayBuffer] : [] + ); + }).finally(() => request.signal.removeEventListener('abort', onAbort)); + if (!reply.ok) { + throw replyError(reply); } const { status, statusText, headers, body: responseBody, - } = message.response; + } = reply.response; return new Response( status === 204 || status === 304 || status === 101 ? null @@ -159,6 +154,7 @@ export const startServerBuildWorker = async ( ); }, async close() { + fail(new Error('Server build worker was closed')); await worker.terminate(); }, }; diff --git a/src/server-build-worker-protocol.ts b/src/server-build-worker-protocol.ts index 51325171..927a1a28 100644 --- a/src/server-build-worker-protocol.ts +++ b/src/server-build-worker-protocol.ts @@ -6,7 +6,6 @@ export type ServerBuildWorkerData = { }; export type ServerBuildWorkerRequest = - | { id: number; type: 'describe' } | { id: number; type: 'request'; @@ -14,7 +13,9 @@ export type ServerBuildWorkerRequest = method: string; headers: [string, string][]; body?: Uint8Array; - }; + } + /** The parent released the request before a reply arrived. */ + | { id: number; type: 'abort' }; export type SerializedResponse = { status: number; @@ -30,10 +31,10 @@ export type SerializedError = { }; export type ServerBuildWorkerResponse = - | { id: number; ok: true; ready: true } - | { id: number; ok: true; description?: ServerBuildDescription } - | { id: number; ok: true; response: SerializedResponse } - | { id: number; ok: false; error: SerializedError }; + /** Sent once the bundle is evaluated; carries the classic build description. */ + | { type: 'ready'; description?: ServerBuildDescription } + | { type: 'reply'; id: number; ok: true; response: SerializedResponse } + | { type: 'reply'; id: number; ok: false; error: SerializedError }; /** * The parts of a classic React Router server build that build-time rendering diff --git a/src/server-build-worker.ts b/src/server-build-worker.ts index d33d1c10..e5482fde 100644 --- a/src/server-build-worker.ts +++ b/src/server-build-worker.ts @@ -128,22 +128,36 @@ if (mode === 'classic') { handler = resolveRscFetch(buildModule); } +// One AbortController per in-flight request, so the Request the app receives +// is aborted when the parent releases it (`createBuildRequestEffect`) or once +// its response has been consumed here, mirroring the in-process contract. +const controllers = new Map(); +const release = (id: number): void => { + controllers.get(id)?.abort(); + controllers.delete(id); +}; + port.on('message', async (message: ServerBuildWorkerRequest) => { + if (message.type === 'abort') { + release(message.id); + return; + } + const controller = new AbortController(); + controllers.set(message.id, controller); try { - if (message.type === 'describe') { - post({ id: message.id, ok: true, description }); - return; - } const response = await handler( new Request(message.url, { method: message.method, headers: message.headers, body: message.body as BodyInit | undefined, + signal: controller.signal, }) ); const body = new Uint8Array(await response.arrayBuffer()); + release(message.id); post( { + type: 'reply', id: message.id, ok: true, response: { @@ -156,8 +170,14 @@ port.on('message', async (message: ServerBuildWorkerRequest) => { [body.buffer as ArrayBuffer] ); } catch (error) { - post({ id: message.id, ok: false, error: serializeError(error) }); + release(message.id); + post({ + type: 'reply', + id: message.id, + ok: false, + error: serializeError(error), + }); } }); -post({ id: -1, ok: true, ready: true }); +post({ type: 'ready', description }); diff --git a/tests/react-router-framework/integration/spa-build-process-test.ts b/tests/react-router-framework/integration/spa-build-process-test.ts index cb0f3f58..54805719 100644 --- a/tests/react-router-framework/integration/spa-build-process-test.ts +++ b/tests/react-router-framework/integration/spa-build-process-test.ts @@ -128,6 +128,106 @@ test.describe("build process with a module-scope handle in the server graph (#13 }); }); +test.describe("server build worker lifecycle", () => { + test("aborts the Request the app receives once each render is released", async () => { + // In-process rendering aborted the request's signal after the handler + // settled (createBuildRequestEffect); the worker must relay that to the + // Request it constructs, or request-scoped cleanup never runs. + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: true, + prerender: ["/", "/other"], + }), + "rsbuild.config.ts": rsbuildConfigFile(), + "app/root.tsx": js` + import { appendFileSync } from "node:fs"; + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export function loader({ request }) { + request.signal.addEventListener("abort", () => { + appendFileSync("abort-log.txt", new URL(request.url).pathname + " "); + }); + return null; + } + + export default function App() { + return ( + + + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Home

; + } + `, + "app/routes/other.tsx": js` + export default function Other() { + return

Other

; + } + `, + }); + expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + const aborted = fs + .readFileSync(path.join(cwd, "abort-log.txt"), "utf8") + .trim() + .split(/\s+/); + expect(aborted).toContain("/"); + expect(aborted).toContain("/other"); + }); + + test("fails deterministically when the app exits the worker mid-build", async () => { + // The worker-side abort fires once the first response has been consumed; + // the app exiting there must fail the build with a clear error rather + // than leave it waiting. (The idle-exit case is covered by the direct + // worker test in tests/server-build-worker.test.ts.) + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: true, + prerender: { paths: ["/", "/other"], concurrency: 1 }, + }), + "rsbuild.config.ts": rsbuildConfigFile(), + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).pathname === "/") { + request.signal.addEventListener("abort", () => process.exit(0)); + } + return null; + } + + export default function App() { + return ( + + + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Home

; + } + `, + "app/routes/other.tsx": js` + export default function Other() { + return

Other

; + } + `, + }); + const result = build({ cwd, timeout: BUILD_TIMEOUT_MS }); + const stderr = result.stderr.toString("utf8"); + expect(result.signal, `build did not exit\n${stderr}`).toBeNull(); + expect(result.status).not.toBe(0); + expect(stderr).toContain("Server build worker exited with code 0"); + }); +}); + test.describe("ssr: false with performance.buildCache (#136)", () => { test("a warm build renders index.html against its own assets", async () => { const cwd = await createProject({ diff --git a/tests/rsc-prerender.test.ts b/tests/rsc-prerender.test.ts index c7f01d81..f16cab5d 100644 --- a/tests/rsc-prerender.test.ts +++ b/tests/rsc-prerender.test.ts @@ -19,7 +19,7 @@ import { // handler that always fails so the error reporting path is what's under test. rstest.mock('../src/server-build-worker-client', () => ({ startServerBuildWorker: async () => ({ - describe: async () => ({ routes: {}, assets: { routes: {} } }), + description: undefined, handler: async () => new Response(null, { status: 500 }), close: async () => {}, }), diff --git a/tests/server-build-worker.test.ts b/tests/server-build-worker.test.ts new file mode 100644 index 00000000..75643a7d --- /dev/null +++ b/tests/server-build-worker.test.ts @@ -0,0 +1,155 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it } from '@rstest/core'; +import { startServerBuildWorker } from '../src/server-build-worker-client'; + +// Real worker threads against the built worker entry: the protocol has two +// sides, and parent-only mocks cannot see whether the Request the app receives +// is aborted or whether an idle worker exit is remembered. +const builtWorkerPath = resolve(__dirname, '../dist/server-build-worker.js'); +if (!existsSync(builtWorkerPath)) { + throw new Error( + `${builtWorkerPath} is missing: run \`pnpm build\` before \`rstest run\`.` + ); +} + +// An RSC-shaped server build (`export default { fetch }`) is the smallest +// bundle the worker accepts; its routes exercise one lifecycle case each. +const serverBuildSource = ` +import { appendFileSync } from "node:fs"; + +export default { + async fetch(request) { + const url = new URL(request.url); + switch (url.pathname) { + case "/abort-log": + request.signal.addEventListener("abort", () => { + appendFileSync(url.searchParams.get("file"), "aborted\\n"); + }); + return new Response("logged"); + case "/wait-for-abort": + await new Promise(resolve => + request.signal.addEventListener("abort", resolve, { once: true }) + ); + return new Response("released", { status: 499 }); + case "/exit-soon": + setTimeout(() => process.exit(0), 20); + return new Response("bye"); + case "/throw": + throw new TypeError("boom"); + default: + return new Response("hello " + url.pathname, { + status: 201, + headers: { "x-echo": request.headers.get("x-in") ?? "" }, + }); + } + }, +}; +`; + +const settle = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe('server build worker', () => { + let directory: string; + let workers: Array<{ close(): Promise }> = []; + + const start = async (file = 'server.mjs') => { + directory ??= await mkdtemp(resolve(tmpdir(), 'rsbuild-rr-worker-')); + const serverBuildPath = resolve(directory, file); + await writeFile(serverBuildPath, serverBuildSource); + const worker = await startServerBuildWorker( + { serverBuildPath, mode: 'rsc' }, + builtWorkerPath + ); + workers.push(worker); + return worker; + }; + + afterEach(async () => { + await Promise.all(workers.map(worker => worker.close())); + workers = []; + if (directory) { + await rm(directory, { recursive: true, force: true }); + directory = undefined as unknown as string; + } + }); + + it('proxies status, headers and body both ways', async () => { + const worker = await start(); + const response = await worker.handler( + new Request('http://localhost/greet', { headers: { 'x-in': 'ping' } }) + ); + expect(response.status).toBe(201); + expect(response.headers.get('x-echo')).toBe('ping'); + expect(await response.text()).toBe('hello /greet'); + }); + + it("aborts the app's Request once its response has been consumed", async () => { + const worker = await start(); + const log = resolve(directory, 'abort.log'); + const response = await worker.handler( + new Request(`http://localhost/abort-log?file=${encodeURIComponent(log)}`) + ); + expect(await response.text()).toBe('logged'); + // The worker releases the request before replying, so the app's cleanup + // has already run by the time the parent sees the response. + expect(readFileSync(log, 'utf8')).toBe('aborted\n'); + }); + + it("relays the parent's abort to an in-flight request", async () => { + const worker = await start(); + const controller = new AbortController(); + const pending = worker.handler( + new Request('http://localhost/wait-for-abort', { + signal: controller.signal, + }) + ); + await settle(50); + controller.abort(); + const response = await pending; + expect(response.status).toBe(499); + expect(await response.text()).toBe('released'); + }); + + it('rethrows app errors with their message and name', async () => { + const worker = await start(); + await expect( + worker.handler(new Request('http://localhost/throw')) + ).rejects.toMatchObject({ name: 'TypeError', message: 'boom' }); + }); + + it('rejects requests sent after the worker exited while idle', async () => { + const worker = await start(); + expect( + await (await worker.handler(new Request('http://localhost/exit-soon'))).text() + ).toBe('bye'); + // Nothing is pending when the worker exits; the exit must still be final. + await settle(300); + await expect( + worker.handler(new Request('http://localhost/after')) + ).rejects.toThrow('Server build worker exited with code 0'); + await expect( + worker.handler(new Request('http://localhost/again')) + ).rejects.toThrow('Server build worker exited with code 0'); + }); + + it('rejects requests after close()', async () => { + const worker = await start(); + await worker.close(); + await expect( + worker.handler(new Request('http://localhost/after-close')) + ).rejects.toThrow('Server build worker was closed'); + }); + + it('fails to start when the bundle cannot be imported', async () => { + directory ??= await mkdtemp(resolve(tmpdir(), 'rsbuild-rr-worker-')); + await expect( + startServerBuildWorker( + { serverBuildPath: resolve(directory, 'missing.mjs'), mode: 'rsc' }, + builtWorkerPath + ) + ).rejects.toThrow(/Cannot find module|ERR_MODULE_NOT_FOUND/); + }); +}); From e2e635ec183ee4dc636f2f8f6682c40210b7875b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:41:27 +0000 Subject: [PATCH 4/4] refactor: simplify server build worker and its tests - Type describeClassicBuild against ServerBuild; drop BuildRouteLike and the as-unknown cast. Type wire bodies as Uint8Array so no BodyInit or transfer-list casts remain. Drop the unread basename field. - Share headerEntries via the protocol module; reuse normalizeEffectError in the client; inline the one-liner in server-build-resolution so the worker bundle no longer pulls in the Effect runtime (361 KB -> 3 KB shared chunk). - Write the manifest stamp only when its content changed (no spurious cache misses or node rebuilds if the cache dir is watched); stamp the base manifest only; remove the unreachable non-classic write. - Terminal-failure check moved inside the request executor; null body by byteLength; request.body as the has-body condition. - Tests: rely on the harness default rsbuild.config (rsbuildConfig.basic gained buildCache), createEditor for the root edit, shared expectBuildSucceeded, one lifecycle fixture builder; drop the existsSync guard that setup.ts mocks. - Remove dead resolveServerBuildModule re-export and PrerenderServerBuild alias. --- src/build-output-transforms.ts | 5 +- src/index.ts | 19 +- src/prerender-build.ts | 10 +- src/rsc-prerender.ts | 9 +- src/server-build-resolution.ts | 3 +- src/server-build-worker-client.ts | 48 ++-- src/server-build-worker-protocol.ts | 12 +- src/server-build-worker.ts | 103 +++---- src/server-utils.ts | 2 - .../integration/helpers/rsbuild-config.ts | 7 + .../integration/helpers/rsbuild.ts | 10 +- .../integration/spa-build-process-test.ts | 251 ++++++------------ tests/rsc-prerender.test.ts | 7 +- tests/server-build-worker.test.ts | 32 +-- 14 files changed, 207 insertions(+), 311 deletions(-) diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index fac4dd75..cb3379af 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -209,9 +209,8 @@ export const registerBuildOutputTransforms = ({ }; } - // Tie this module's cache identity to the manifest content: the - // virtual source never changes, so without this dependency Rspack's - // persistent cache serves a previous build's manifest (#136). + // Cache identity for a module whose source never changes (#136); + // see `serverManifestStampPath` in index.ts. if (existsSync(serverManifestStampPath)) { args.addDependency(serverManifestStampPath); } else { diff --git a/src/index.ts b/src/index.ts index e8047913..3c059655 100644 --- a/src/index.ts +++ b/src/index.ts @@ -541,14 +541,18 @@ export const pluginReactRouter = ( 'react-router', 'server-manifest.json' ); + // Bundle manifests derive from the base one, so the base is the stamp. + // Only rewrite on change: a bumped mtime would otherwise invalidate the + // module on every build and, if the cache dir is watched, rebuild node + // after every web rebuild in dev. const writeServerManifestStamp = (): void => { - fsExtra.outputFileSync( - serverManifestStampPath, - JSON.stringify({ - base: latestServerManifest, - bundles: latestServerManifestsByBundleId, - }) - ); + const stamp = JSON.stringify(latestServerManifest); + const previous = existsSync(serverManifestStampPath) + ? readFileSync(serverManifestStampPath, 'utf8') + : undefined; + if (stamp !== previous) { + fsExtra.outputFileSync(serverManifestStampPath, stamp); + } }; const routeByFilePath = new Map( @@ -752,7 +756,6 @@ export const pluginReactRouter = ( }; if (modePlan.kind !== 'classic') { - writeServerManifestStamp(); return; } diff --git a/src/prerender-build.ts b/src/prerender-build.ts index 31735e0c..c17dd865 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -31,8 +31,6 @@ import type { ServerBuildDescription } from './server-build-worker-protocol.js'; import type { PluginOptions, Route } from './types.js'; import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; -type PrerenderServerBuild = ServerBuildDescription; - type PrerenderBuildApi = Pick< RsbuildPluginAPI, 'logger' | 'getNormalizedConfig' @@ -328,7 +326,7 @@ const handleSpaMode = async ({ api, }: { handler: (request: Request) => Promise; - build: PrerenderServerBuild; + build: ServerBuildDescription; clientBuildDir: string; basename: string; api: PrerenderBuildApi; @@ -443,7 +441,7 @@ const createPrerenderPathEffect = ({ options, }: { path: string; - build: PrerenderServerBuild; + build: ServerBuildDescription; buildRoutes: ReturnType; requestHandler: (request: Request) => Promise; clientBuildDir: string; @@ -580,14 +578,12 @@ export const runReactRouterPrerenderBuild = async ( await mkdir(clientBuildDir, { recursive: true }); if (!ssr || isPrerenderEnabled) { - // The server bundle runs in a worker that is terminated afterwards, so a - // handle its module graph opens cannot keep the build alive (#135). const worker = await startServerBuildWorker({ serverBuildPath, mode: 'classic', }); try { - const build: PrerenderServerBuild | undefined = worker.description; + const build = worker.description; if (!build) { throw new Error( `[${PLUGIN_NAME}] Server build worker returned no build description` diff --git a/src/rsc-prerender.ts b/src/rsc-prerender.ts index dfcf35b5..93db35ce 100644 --- a/src/rsc-prerender.ts +++ b/src/rsc-prerender.ts @@ -27,8 +27,9 @@ import { runPluginEffect } from './effect-runtime.js'; * inline `__FLIGHT_DATA` scripts, served for client-side navigations * * Instead of an HTTP round-trip through a preview server, the RSC server - * bundle's default-exported `fetch` handler is invoked in-process, matching - * how classic mode prerenders through `createRequestHandler`. + * bundle's default-exported `fetch` handler is invoked directly (in the + * server build worker), matching how classic mode prerenders through + * `createRequestHandler`. */ export const SPA_FALLBACK_REQUEST_PATH: string = `/${SPA_FALLBACK_HTML_FILE}`; @@ -307,11 +308,9 @@ export const runReactRouterRscPrerenderBuild = async ( const clientBuildDir = resolve(buildDirectory, 'client'); await mkdir(clientBuildDir, { recursive: true }); - // The server bundle runs in a worker that is terminated afterwards, so a - // handle its module graph opens cannot keep the build alive (#135). const worker = await startServerBuildWorker({ serverBuildPath, mode: 'rsc' }); try { - const handler: RscRequestHandler = request => worker.handler(request); + const handler: RscRequestHandler = worker.handler; api.logger.info(`Prerender: ${prerenderRequests.length} path(s)...`); diff --git a/src/server-build-resolution.ts b/src/server-build-resolution.ts index 4d33b5ac..433a0f78 100644 --- a/src/server-build-resolution.ts +++ b/src/server-build-resolution.ts @@ -1,7 +1,6 @@ // Internal module: exposes ServerBuild resolution used by dev-runtime code. // External callers go through the Promise wrappers in server-utils.ts. import type { ServerBuild } from 'react-router'; -import { normalizeEffectError } from './effect-runtime.js'; const RESOLVABLE_BUILD_EXPORTS = new Set([ 'allowedActionOrigins', @@ -116,6 +115,6 @@ export async function resolveServerBuildModule( `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.` ); } catch (cause) { - throw normalizeEffectError(cause); + throw cause instanceof Error ? cause : new Error(String(cause)); } } diff --git a/src/server-build-worker-client.ts b/src/server-build-worker-client.ts index 45ba6849..b2d193ea 100644 --- a/src/server-build-worker-client.ts +++ b/src/server-build-worker-client.ts @@ -1,10 +1,12 @@ import { fileURLToPath } from 'node:url'; import { Worker } from 'node:worker_threads'; -import type { - ServerBuildDescription, - ServerBuildWorkerData, - ServerBuildWorkerRequest, - ServerBuildWorkerResponse, +import { normalizeEffectError } from './effect-runtime.js'; +import { + headerEntries, + type ServerBuildDescription, + type ServerBuildWorkerData, + type ServerBuildWorkerRequest, + type ServerBuildWorkerResponse, } from './server-build-worker-protocol.js'; const defaultWorkerPath = fileURLToPath( @@ -27,15 +29,6 @@ type Pending = { reject: (error: Error) => void; }; -const headerEntries = (headers: Headers): [string, string][] => { - const entries: [string, string][] = []; - headers.forEach((value, key) => entries.push([key, value])); - return entries; -}; - -const toError = (error: unknown): Error => - error instanceof Error ? error : new Error(String(error)); - const replyError = (reply: Extract): Error => { const error = new Error(reply.error.message); error.name = reply.error.name ?? error.name; @@ -85,7 +78,7 @@ export const startServerBuildWorker = async ( entry?.resolve(message); }); worker.on('error', error => { - fail(toError(error)); + fail(normalizeEffectError(error)); reject(failure); }); worker.on('exit', code => { @@ -108,12 +101,8 @@ export const startServerBuildWorker = async ( return { description, async handler(request) { - if (failure) { - throw failure; - } const id = nextId++; - const hasBody = request.method !== 'GET' && request.method !== 'HEAD'; - const body = hasBody + const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; // Relay the parent's release so the worker-side Request aborts too. @@ -122,9 +111,13 @@ export const startServerBuildWorker = async ( send({ type: 'abort', id }); } }; - request.signal.addEventListener('abort', onAbort, { once: true }); const reply = await new Promise((resolve, reject) => { + if (failure) { + reject(failure); + return; + } pending.set(id, { resolve, reject }); + request.signal.addEventListener('abort', onAbort, { once: true }); send( { type: 'request', @@ -134,7 +127,7 @@ export const startServerBuildWorker = async ( headers: headerEntries(request.headers), body, }, - body ? [body.buffer as ArrayBuffer] : [] + body ? [body.buffer] : [] ); }).finally(() => request.signal.removeEventListener('abort', onAbort)); if (!reply.ok) { @@ -146,12 +139,11 @@ export const startServerBuildWorker = async ( headers, body: responseBody, } = reply.response; - return new Response( - status === 204 || status === 304 || status === 101 - ? null - : (responseBody as unknown as BodyInit), - { status, statusText, headers } - ); + return new Response(responseBody.byteLength ? responseBody : null, { + status, + statusText, + headers, + }); }, async close() { fail(new Error('Server build worker was closed')); diff --git a/src/server-build-worker-protocol.ts b/src/server-build-worker-protocol.ts index 927a1a28..e72dc22c 100644 --- a/src/server-build-worker-protocol.ts +++ b/src/server-build-worker-protocol.ts @@ -1,5 +1,12 @@ // Messages between the build process and `server-build-worker`. +/** Headers as a structured-cloneable list (the DOM lib's Headers is not iterable here). */ +export const headerEntries = (headers: Headers): [string, string][] => { + const entries: [string, string][] = []; + headers.forEach((value, key) => entries.push([key, value])); + return entries; +}; + export type ServerBuildWorkerData = { serverBuildPath: string; mode: 'classic' | 'rsc'; @@ -12,7 +19,7 @@ export type ServerBuildWorkerRequest = url: string; method: string; headers: [string, string][]; - body?: Uint8Array; + body?: Uint8Array; } /** The parent released the request before a reply arrived. */ | { id: number; type: 'abort' }; @@ -21,7 +28,7 @@ export type SerializedResponse = { status: number; statusText: string; headers: [string, string][]; - body: Uint8Array; + body: Uint8Array; }; export type SerializedError = { @@ -41,7 +48,6 @@ export type ServerBuildWorkerResponse = * reads, as plain data: route module exports are reported by presence only. */ export type ServerBuildDescription = { - basename?: string; prerender?: string[]; routes: Record< string, diff --git a/src/server-build-worker.ts b/src/server-build-worker.ts index e5482fde..2581ffa4 100644 --- a/src/server-build-worker.ts +++ b/src/server-build-worker.ts @@ -1,30 +1,20 @@ -// Worker entry: evaluates a freshly built server bundle and serves requests to -// it for build-time rendering (SPA-mode `index.html`, prerendering). Running -// the bundle here instead of in the build process means any handle the app's -// server graph creates at module scope (a `BroadcastChannel`, a timer, a -// connection) dies with `worker.terminate()` and cannot keep `rsbuild build` -// alive (#135). The worker sets `IS_RR_BUILD_REQUEST` for its own module -// graph only. +// Worker entry: evaluates a built server bundle and serves requests to it for +// build-time rendering; see `startServerBuildWorker` for why this is a worker. +// `IS_RR_BUILD_REQUEST` is set for this module graph only. import { parentPort, workerData } from 'node:worker_threads'; import { pathToFileURL } from 'node:url'; -import { createRequestHandler } from 'react-router'; +import { createRequestHandler, type ServerBuild } from 'react-router'; +import { PLUGIN_NAME } from './constants.js'; import { resolveServerBuildModule } from './server-build-resolution.js'; -import type { - ServerBuildWorkerData, - ServerBuildWorkerRequest, - ServerBuildWorkerResponse, - ServerBuildDescription, +import { + headerEntries, + type ServerBuildWorkerData, + type ServerBuildWorkerRequest, + type ServerBuildWorkerResponse, + type ServerBuildDescription, + type SerializedError, } from './server-build-worker-protocol.js'; -type BuildRouteLike = { - id?: string; - parentId?: string; - path?: string; - index?: boolean; - caseSensitive?: boolean; - module?: Record; -}; - const port = parentPort; if (!port) { throw new Error('server-build-worker must run as a worker thread'); @@ -40,13 +30,7 @@ const post = ( port.postMessage(message, transfer); }; -const headerEntries = (headers: Headers): [string, string][] => { - const entries: [string, string][] = []; - headers.forEach((value, key) => entries.push([key, value])); - return entries; -}; - -const serializeError = (error: unknown) => { +const serializeError = (error: unknown): SerializedError => { const value = error as { message?: unknown; stack?: unknown; name?: unknown }; return { message: String(value?.message ?? error), @@ -55,37 +39,36 @@ const serializeError = (error: unknown) => { }; }; -const describeClassicBuild = (build: { - basename?: string; - prerender?: string[]; - routes?: Record; - assets?: { routes?: Record }; -}): ServerBuildDescription => ({ - basename: build.basename, +const describeClassicBuild = (build: ServerBuild): ServerBuildDescription => ({ prerender: build.prerender, routes: Object.fromEntries( - Object.entries(build.routes ?? {}).map(([id, route]) => [ - id, - { - id: route.id, - parentId: route.parentId, - path: route.path, - index: route.index, - caseSensitive: route.caseSensitive, - module: { - default: route.module?.default !== undefined, - ErrorBoundary: route.module?.ErrorBoundary !== undefined, - loader: route.module?.loader !== undefined, - }, - }, - ]) + Object.entries(build.routes).flatMap(([id, route]) => + route + ? [ + [ + id, + { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: { + default: route.module.default !== undefined, + ErrorBoundary: route.module.ErrorBoundary !== undefined, + loader: route.module.loader !== undefined, + }, + }, + ], + ] + : [] + ) ), assets: { routes: Object.fromEntries( - Object.entries(build.assets?.routes ?? {}).map(([id, route]) => [ - id, - { hasLoader: route.hasLoader }, - ]) + Object.entries(build.assets.routes).flatMap(([id, route]) => + route ? [[id, { hasLoader: route.hasLoader }]] : [] + ) ), }, }); @@ -104,7 +87,7 @@ const resolveRscFetch = ( : null; if (!fetch) { throw new Error( - `RSC server build ${JSON.stringify( + `[${PLUGIN_NAME}] RSC server build ${JSON.stringify( serverBuildPath )} must default-export an object with a fetch function.` ); @@ -120,9 +103,7 @@ if (mode === 'classic') { buildModule, `Server build ${JSON.stringify(serverBuildPath)}` ); - description = describeClassicBuild( - build as unknown as Parameters[0] - ); + description = describeClassicBuild(build); handler = createRequestHandler(build, 'production'); } else { handler = resolveRscFetch(buildModule); @@ -149,7 +130,7 @@ port.on('message', async (message: ServerBuildWorkerRequest) => { new Request(message.url, { method: message.method, headers: message.headers, - body: message.body as BodyInit | undefined, + body: message.body, signal: controller.signal, }) ); @@ -167,7 +148,7 @@ port.on('message', async (message: ServerBuildWorkerRequest) => { body, }, }, - [body.buffer as ArrayBuffer] + [body.buffer] ); } catch (error) { release(message.id); diff --git a/src/server-utils.ts b/src/server-utils.ts index 4f9f50ac..cd3be9dc 100644 --- a/src/server-utils.ts +++ b/src/server-utils.ts @@ -88,8 +88,6 @@ export function generateServerBuild( `; } -export { resolveServerBuildModule }; - export function resolveReactRouterServerBuild( buildModule: unknown ): Promise { diff --git a/tests/react-router-framework/integration/helpers/rsbuild-config.ts b/tests/react-router-framework/integration/helpers/rsbuild-config.ts index 88c55ec8..88ad7b11 100644 --- a/tests/react-router-framework/integration/helpers/rsbuild-config.ts +++ b/tests/react-router-framework/integration/helpers/rsbuild-config.ts @@ -14,6 +14,8 @@ type RsbuildConfigBuildArgs = { type RsbuildConfigBaseArgs = { templateName?: TemplateName; + /** Rspack persistent cache, kept inside the fixture (fixtures share the template's node_modules). */ + buildCache?: boolean; base?: string; defineNodeEnv?: boolean; envPrefixes?: string[]; @@ -155,6 +157,11 @@ export const rsbuildConfig = { ] : []), ]), + ...configSection("performance", [ + ...(args.buildCache + ? [`buildCache: { cacheDirectory: "./.rspack-cache" },`] + : []), + ]), ...configSection("source", [ ...(args.defineNodeEnv || args.envPrefixes ? [ diff --git a/tests/react-router-framework/integration/helpers/rsbuild.ts b/tests/react-router-framework/integration/helpers/rsbuild.ts index effcb236..36d579d0 100644 --- a/tests/react-router-framework/integration/helpers/rsbuild.ts +++ b/tests/react-router-framework/integration/helpers/rsbuild.ts @@ -231,6 +231,12 @@ const formatBuildFailure = (result: ReturnType) => { ].join("\n\n"); }; +/** Asserts a successful exit (a killed or hung build has `status: null`) and returns stdout. */ +export const expectBuildSucceeded = (result: ReturnType) => { + expect(result.status, formatBuildFailure(result)).toBe(0); + return result.stdout.toString("utf8"); +}; + export const reactRouterServe = async ({ cwd, port, @@ -514,7 +520,7 @@ export const test = base.extend({ let port = await getPort(); let cwd = await createProject(await files({ port })); let result = build({ cwd }); - expect(result.status, formatBuildFailure(result)).toBe(0); + expectBuildSucceeded(result); stop = await reactRouterServe({ cwd, port }); return { port, cwd }; }); @@ -527,7 +533,7 @@ export const test = base.extend({ let port = await getPort(); let cwd = await createProject(await files({ port }), template); let result = build({ cwd }); - expect(result.status, formatBuildFailure(result)).toBe(0); + expectBuildSucceeded(result); stop = await rsbuildPreview({ cwd, port }); return { port, cwd }; }); diff --git a/tests/react-router-framework/integration/spa-build-process-test.ts b/tests/react-router-framework/integration/spa-build-process-test.ts index 54805719..aae239ac 100644 --- a/tests/react-router-framework/integration/spa-build-process-test.ts +++ b/tests/react-router-framework/integration/spa-build-process-test.ts @@ -3,11 +3,18 @@ import path from "node:path"; import { test, expect } from "@playwright/test"; import { js } from "./helpers/create-fixture.js"; -import { build, createProject, reactRouterConfig } from "./helpers/rsbuild.js"; +import { + build, + createEditor, + createProject, + expectBuildSucceeded, + reactRouterConfig, + rsbuildConfig, +} from "./helpers/rsbuild.js"; // Build-time rendering (SPA-mode `index.html`, prerendering) evaluates the -// freshly built server bundle. These tests pin down two properties of that -// step that only show up in real builds: +// freshly built server bundle. These tests pin down properties of that step +// that only show up in real builds: // - #135: the build process must exit even when the app's server graph opens // a ref'd handle at module scope (the bundle runs in a terminated worker). // - #136: with Rspack's persistent cache, a warm build must render against @@ -16,81 +23,35 @@ import { build, createProject, reactRouterConfig } from "./helpers/rsbuild.js"; // Generous: a hung build never exits, so any finite bound distinguishes. const BUILD_TIMEOUT_MS = 180_000; -const rsbuildConfigFile = ({ - rsc = false, - buildCache = false, -}: { rsc?: boolean; buildCache?: boolean } = {}) => { - const plugin = rsc ? "pluginReactRouterRSC" : "pluginReactRouter"; - return js` - import { defineConfig } from "@rsbuild/core"; - import { pluginReact } from "@rsbuild/plugin-react"; - import { ${plugin} } from "rsbuild-plugin-react-router"; - - export default defineConfig({ - plugins: [pluginReact(), ${plugin}()], - // Fixtures share the template's node_modules, so keep the persistent - // cache inside the fixture instead of the default node_modules/.cache. - performance: { - buildCache: ${buildCache ? '{ cacheDirectory: "./.rspack-cache" }' : "false"}, - }, - }); - `; -}; - // Node backs BroadcastChannel with a ref'd MessagePort, and it has been a // global since v18, so `typeof BroadcastChannel !== "undefined"` guards pass -// at build time too. A common SPA pattern (cross-tab sign-out sync). -const moduleScopeHandleFiles = ({ rsc = false } = {}) => ({ - "app/auth-channel.ts": js` - export const channel = new BroadcastChannel("app-signout"); - `, - "app/root.tsx": js` - import { Links, Meta, Outlet, ScrollRestoration${rsc ? "" : ", Scripts"} } from "react-router"; - import "./auth-channel"; - - export default function App() { - return ( - - - - - - - - - ${rsc ? "" : ""} - - - ); - } - `, - "app/routes/_index.tsx": js` - export default function Index() { - return

Home

; - } - `, -}); - -const expectBuildExited = (result: ReturnType) => { - const stderr = result.stderr.toString("utf8"); - expect( - result.signal, - `build did not exit within ${BUILD_TIMEOUT_MS}ms\n${stderr}`, - ).toBeNull(); - expect(result.status, stderr).toBe(0); - return result.stdout.toString("utf8"); +// at build time too. A common SPA pattern (cross-tab sign-out sync). Root is +// the only route whose module scope reaches the SPA server bundle. +const withModuleScopeHandle = async (cwd: string) => { + fs.writeFileSync( + path.join(cwd, "app/auth-channel.ts"), + 'export const channel = new BroadcastChannel("app-signout");\n', + ); + await createEditor(cwd)( + "app/root.tsx", + (contents) => `import "./auth-channel";\n${contents}`, + ); }; +const indexHtml = (cwd: string) => + fs.readFileSync(path.join(cwd, "build/client/index.html"), "utf8"); + test.describe("build process with a module-scope handle in the server graph (#135)", () => { test("ssr: false exits after generating index.html", async () => { const cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false }), - "rsbuild.config.ts": rsbuildConfigFile(), - ...moduleScopeHandleFiles(), }); - const stdout = expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + await withModuleScopeHandle(cwd); + const stdout = expectBuildSucceeded( + build({ cwd, timeout: BUILD_TIMEOUT_MS }), + ); expect(stdout).toContain("Removed server build"); - expect(fs.existsSync(path.join(cwd, "build/client/index.html"))).toBe(true); + expect(indexHtml(cwd)).toContain("Home"); + await withModuleScopeHandle(cwd); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect(indexHtml(cwd)).toContain("Welcome to React Router"); }); test("RSC prerender exits after writing the prerendered pages", async () => { @@ -116,19 +74,44 @@ test.describe("build process with a module-scope handle in the server graph (#13 ssr: false, prerender: ["/"], }), - "rsbuild.config.ts": rsbuildConfigFile({ rsc: true }), - ...moduleScopeHandleFiles({ rsc: true }), }, "rsc-framework", ); - expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); - expect( - fs.readFileSync(path.join(cwd, "build/client/index.html"), "utf8"), - ).toContain("

Home

"); + await withModuleScopeHandle(cwd); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect(indexHtml(cwd)).toContain("Welcome to React Router"); }); }); test.describe("server build worker lifecycle", () => { + // Two prerendered routes whose root loader runs `loaderBody` per request. + const lifecycleFiles = (loaderBody: string) => ({ + "app/root.tsx": js` + import { appendFileSync } from "node:fs"; + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export function loader({ request }) { + const pathname = new URL(request.url).pathname; + ${loaderBody} + return null; + } + + export default function App() { + return ( + + + + + ); + } + `, + "app/routes/other.tsx": js` + export default function Other() { + return

Other

; + } + `, + }); + test("aborts the Request the app receives once each render is released", async () => { // In-process rendering aborted the request's signal after the handler // settled (createBuildRequestEffect); the worker must relay that to the @@ -138,39 +121,13 @@ test.describe("server build worker lifecycle", () => { ssr: true, prerender: ["/", "/other"], }), - "rsbuild.config.ts": rsbuildConfigFile(), - "app/root.tsx": js` - import { appendFileSync } from "node:fs"; - import { Links, Meta, Outlet, Scripts } from "react-router"; - - export function loader({ request }) { - request.signal.addEventListener("abort", () => { - appendFileSync("abort-log.txt", new URL(request.url).pathname + " "); - }); - return null; - } - - export default function App() { - return ( - - - - - ); - } - `, - "app/routes/_index.tsx": js` - export default function Index() { - return

Home

; - } - `, - "app/routes/other.tsx": js` - export default function Other() { - return

Other

; - } - `, + ...lifecycleFiles(` + request.signal.addEventListener("abort", () => { + appendFileSync("abort-log.txt", pathname + " "); + }); + `), }); - expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); const aborted = fs .readFileSync(path.join(cwd, "abort-log.txt"), "utf8") .trim() @@ -189,36 +146,11 @@ test.describe("server build worker lifecycle", () => { ssr: true, prerender: { paths: ["/", "/other"], concurrency: 1 }, }), - "rsbuild.config.ts": rsbuildConfigFile(), - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router"; - - export function loader({ request }) { - if (new URL(request.url).pathname === "/") { - request.signal.addEventListener("abort", () => process.exit(0)); - } - return null; - } - - export default function App() { - return ( - - - - - ); + ...lifecycleFiles(` + if (pathname === "/") { + request.signal.addEventListener("abort", () => process.exit(0)); } - `, - "app/routes/_index.tsx": js` - export default function Index() { - return

Home

; - } - `, - "app/routes/other.tsx": js` - export default function Other() { - return

Other

; - } - `, + `), }); const result = build({ cwd, timeout: BUILD_TIMEOUT_MS }); const stderr = result.stderr.toString("utf8"); @@ -232,46 +164,33 @@ test.describe("ssr: false with performance.buildCache (#136)", () => { test("a warm build renders index.html against its own assets", async () => { const cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ ssr: false }), - "rsbuild.config.ts": rsbuildConfigFile({ buildCache: true }), - "app/routes/_index.tsx": js` - export default function Index() { - return

Home

; - } - `, + "rsbuild.config.ts": await rsbuildConfig.basic({ buildCache: true }), }); const referencedScripts = () => { - const html = fs.readFileSync( - path.join(cwd, "build/client/index.html"), - "utf8", - ); - const urls = [...html.matchAll(/["']\/(static\/js\/[^"']+\.js)["']/g)].map( - (match) => match[1], - ); + const urls = [ + ...indexHtml(cwd).matchAll(/["']\/(static\/js\/[^"']+\.js)["']/g), + ].map((match) => match[1]); expect(urls.length).toBeGreaterThan(0); return [...new Set(urls)]; }; - const emitted = (url: string) => - fs.existsSync(path.join(cwd, "build/client", url)); + const missing = (urls: string[]) => + urls.filter((url) => !fs.existsSync(path.join(cwd, "build/client", url))); // Cold build: fills the persistent cache. - expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); const coldScripts = referencedScripts(); - expect(coldScripts.filter((url) => !emitted(url))).toEqual([]); + expect(missing(coldScripts)).toEqual([]); // Change the root route so its (and the manifest's) content hash moves. - const rootPath = path.join(cwd, "app/root.tsx"); - fs.writeFileSync( - rootPath, - fs - .readFileSync(rootPath, "utf8") - .replace('', ''), + await createEditor(cwd)("app/root.tsx", (contents) => + contents.replace('', ''), ); fs.rmSync(path.join(cwd, "build"), { recursive: true, force: true }); // Warm build: the server-manifest module must not be served from cache. - expectBuildExited(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); const warmScripts = referencedScripts(); expect(warmScripts).not.toEqual(coldScripts); - expect(warmScripts.filter((url) => !emitted(url))).toEqual([]); + expect(missing(warmScripts)).toEqual([]); }); }); diff --git a/tests/rsc-prerender.test.ts b/tests/rsc-prerender.test.ts index f16cab5d..63d22114 100644 --- a/tests/rsc-prerender.test.ts +++ b/tests/rsc-prerender.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { createLogger } from '@rsbuild/core'; @@ -19,7 +19,6 @@ import { // handler that always fails so the error reporting path is what's under test. rstest.mock('../src/server-build-worker-client', () => ({ startServerBuildWorker: async () => ({ - description: undefined, handler: async () => new Response(null, { status: 500 }), close: async () => {}, }), @@ -175,10 +174,6 @@ describe('runReactRouterRscPrerenderBuild', () => { ); try { - const serverDirectory = resolve(buildDirectory, 'server'); - await mkdir(serverDirectory); - await writeFile(resolve(serverDirectory, 'index.js'), ''); - await expect( runReactRouterRscPrerenderBuild({ api: { logger: createLogger({ level: 'silent' }) }, diff --git a/tests/server-build-worker.test.ts b/tests/server-build-worker.test.ts index 75643a7d..0c49a451 100644 --- a/tests/server-build-worker.test.ts +++ b/tests/server-build-worker.test.ts @@ -1,19 +1,15 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; -import { afterEach, describe, expect, it } from '@rstest/core'; +import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; import { startServerBuildWorker } from '../src/server-build-worker-client'; // Real worker threads against the built worker entry: the protocol has two // sides, and parent-only mocks cannot see whether the Request the app receives // is aborted or whether an idle worker exit is remembered. +// Missing (run `pnpm build`) surfaces as the Worker's own module-not-found. const builtWorkerPath = resolve(__dirname, '../dist/server-build-worker.js'); -if (!existsSync(builtWorkerPath)) { - throw new Error( - `${builtWorkerPath} is missing: run \`pnpm build\` before \`rstest run\`.` - ); -} // An RSC-shaped server build (`export default { fetch }`) is the smallest // bundle the worker accepts; its routes exercise one lifecycle case each. @@ -55,9 +51,8 @@ describe('server build worker', () => { let directory: string; let workers: Array<{ close(): Promise }> = []; - const start = async (file = 'server.mjs') => { - directory ??= await mkdtemp(resolve(tmpdir(), 'rsbuild-rr-worker-')); - const serverBuildPath = resolve(directory, file); + const start = async () => { + const serverBuildPath = resolve(directory, 'server.mjs'); await writeFile(serverBuildPath, serverBuildSource); const worker = await startServerBuildWorker( { serverBuildPath, mode: 'rsc' }, @@ -67,13 +62,14 @@ describe('server build worker', () => { return worker; }; + beforeEach(async () => { + directory = await mkdtemp(resolve(tmpdir(), 'rsbuild-rr-worker-')); + }); + afterEach(async () => { await Promise.all(workers.map(worker => worker.close())); workers = []; - if (directory) { - await rm(directory, { recursive: true, force: true }); - directory = undefined as unknown as string; - } + await rm(directory, { recursive: true, force: true }); }); it('proxies status, headers and body both ways', async () => { @@ -122,9 +118,10 @@ describe('server build worker', () => { it('rejects requests sent after the worker exited while idle', async () => { const worker = await start(); - expect( - await (await worker.handler(new Request('http://localhost/exit-soon'))).text() - ).toBe('bye'); + const response = await worker.handler( + new Request('http://localhost/exit-soon') + ); + expect(await response.text()).toBe('bye'); // Nothing is pending when the worker exits; the exit must still be final. await settle(300); await expect( @@ -144,7 +141,6 @@ describe('server build worker', () => { }); it('fails to start when the bundle cannot be imported', async () => { - directory ??= await mkdtemp(resolve(tmpdir(), 'rsbuild-rr-worker-')); await expect( startServerBuildWorker( { serverBuildPath: resolve(directory, 'missing.mjs'), mode: 'rsc' },