From 4973b4edd5e2f5d3b3fa62fa3f2b55188652ba0b Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 17:38:35 +0000
Subject: [PATCH 1/3] fix(federation): correct startup and export propagation
through MF async startup (#132)
Browser: React Router imports each browser route-module entry synchronously
and reads its exports; under MF asyncStartup the entry's startup is a promise
and Rspack's module-library export snapshotted it (`startup.default` ->
undefined), so hydration never ran. Route-module entries (and entry.client)
are now made async via a post-SWC transform, so Rspack emits
`(await startup).default` and importers wait for evaluation. Each federation
container also gets its own runtime chunk: sharing the app's single runtime
made importing the container run the app entries' share consumes before the
host initialized the share scope, yielding a second React.
Server: @module-federation/node replaces Rspack's readFileVm loader with one
that tracks loaded chunks privately, so Rsbuild's initial split chunks on a
multi-entry server build never satisfied __webpack_require__.O and the
awaited server build resolved to undefined. Server code splitting is now
async-only under federation. asyncStartup stays enforced on every compiler;
shared dependencies stay non-eager.
RSC: manifest alignment rebases relative references from an empty browser
assetPrefix; filename validation moves to the emitted web output (entry
files and client chunks, function filenames, tools.rspack overrides).
Tests: federation fixture is a real pass (awaited valid Node ServerBuild,
SSR of the remote over HTTP, hydration, container from another origin,
lazy JS/CSS via 'auto', CORS); real-config suite asserts asyncStartup on
both compilers, non-eager shared, isolated container runtime, async-only
server split chunks, and node-only environment selection (compiler closed);
RSC output-filename acceptance builds; RSC browser test uses the shared
observer with no .js exemption and a styled initial client component; dead
jsDistPath inputs and the rsbuild-inspect subprocess step removed; owned
tests included in the tests typecheck.
Example: CORS scoped to the remote's asset handlers.
---
.changeset/federation-async-startup.md | 27 ++++++
README.md | 22 ++++-
.../epic-stack-remote/server/index.ts | 31 ++++--
src/environment-output.ts | 18 +++-
src/federation.ts | 81 +++++++++++++---
src/index.ts | 61 ++++++++----
src/manifest.ts | 20 ++++
src/rsc-virtual-modules.ts | 16 +++-
tests/environment-selection.test.ts | 56 -----------
tests/index.test.ts | 35 -------
tests/manifest.test.ts | 16 ++++
tests/output-config.integration.test.ts | 83 ++++++++++++++--
.../integration/asset-prefix-auto-test.ts | 21 +---
.../integration/federation-test.ts | 95 +++++++++++--------
.../integration/rsc-asset-prefix-auto-test.ts | 45 +++++----
.../integration/rsc-output-filename-test.ts | 80 ++++++++++++++++
tests/rsc-support.test.ts | 19 +++-
tsconfig.tests.json | 5 +-
18 files changed, 500 insertions(+), 231 deletions(-)
create mode 100644 .changeset/federation-async-startup.md
delete mode 100644 tests/environment-selection.test.ts
create mode 100644 tests/react-router-framework/integration/rsc-output-filename-test.ts
diff --git a/.changeset/federation-async-startup.md b/.changeset/federation-async-startup.md
new file mode 100644
index 00000000..dcfa3818
--- /dev/null
+++ b/.changeset/federation-async-startup.md
@@ -0,0 +1,27 @@
+---
+'rsbuild-plugin-react-router': patch
+---
+
+Make Module Federation work through async startup (#132), and tighten RSC
+asset handling.
+
+- Federation (browser): route-module entries are made async so Rspack awaits
+ the Module Federation startup before exporting (`(await startup).default`);
+ React Router's synchronous `import * as route from ".../root.js"` and
+ `import()` of split route chunks now see real exports and hydration
+ proceeds. Each federation container gets its own runtime chunk, so importing
+ a container no longer runs the remote app's own share-scope consumes before
+ the host initializes the share scope (which produced a second React).
+- Federation (server): server code splitting is async-only, so the CommonJS
+ server build has no initial chunk dependencies. `@module-federation/node`
+ replaces Rspack's chunk loader with one that tracks loaded chunks privately,
+ which left Rspack's startup gate unsatisfied and made the awaited server
+ build resolve to `undefined`. `experiments.asyncStartup` stays enforced on
+ every compiler and shared dependencies stay non-eager.
+- RSC: the manifest prefix alignment also rebases relative references produced
+ by an empty browser `assetPrefix` (absolute and protocol-relative URLs are
+ left alone). RSC filename validation now checks the emitted web output
+ (entry files and client chunks, including function filenames and
+ `tools.rspack` overrides) instead of the raw `output.filename.js` string.
+- Federation example: CORS is scoped to the remote's asset handlers instead of
+ the whole application.
diff --git a/README.md b/README.md
index f47106bd..3b591e88 100644
--- a/README.md
+++ b/README.md
@@ -103,9 +103,17 @@ pluginReactRouter({
| `federation` | `false` | Enables the plugin's experimental Module Federation integration. |
When `federation` is enabled, configure the Module Federation plugin with
-`experiments.asyncStartup: true`. The dev server resolves async server build
+`experiments.asyncStartup: true` on every compiler (the plugin enforces it) and
+keep shared dependencies non-eager. The dev server resolves async server build
exports automatically; production custom servers or adapters should resolve
-async exports before passing the build to React Router's request handler.
+async exports before passing the build to React Router's request handler
+(`resolveReactRouterServerBuild`). Give remote containers an explicit
+`filename` (for example `static/js/remote.js`); every other browser chunk keeps
+Rsbuild's content hash. Under the hood the plugin gives each container its own
+runtime chunk (so importing a container does not start the app's own share
+consumes), makes browser route-module entries async so their exports resolve
+through the async startup, and keeps server code splitting async-only so the
+`@module-federation/node` chunk loader can satisfy the server build's startup.
### React Router Config
@@ -365,6 +373,16 @@ content-hashed filenames, and `output.filename`, `output.filenameHash`,
`output.distPath`, and `tools.rspack` output settings you configure govern
the emitted files and the manifest URLs that reference them.
+Supported browser filename forms differ by mode:
+
+- **Classic mode** accepts any scheme, including `.mjs`/`.cjs` and query-hash
+ names such as `[name].js?v=[contenthash:8]`; the browser manifest classifies
+ emitted assets by pathname and keeps the full reference.
+- **RSC mode** requires every browser JavaScript asset to be named `*.js`
+ (hashes are fine, e.g. `[contenthash:8]-[name].js`): rspack's RSC manifest
+ only records `.js` files, so the build fails with a clear error for query
+ hashes or other extensions.
+
## Custom Server Setup
The plugin supports two ways to handle server-side rendering:
diff --git a/examples/federation/epic-stack-remote/server/index.ts b/examples/federation/epic-stack-remote/server/index.ts
index 0c20983a..35daab57 100644
--- a/examples/federation/epic-stack-remote/server/index.ts
+++ b/examples/federation/epic-stack-remote/server/index.ts
@@ -82,29 +82,40 @@ export async function createApp(devServer?: any) {
app.disable('x-powered-by')
// The host loads this remote's container and the ES modules it imports
- // cross-origin (`remoteType: 'import'`), which requires CORS on every asset
- // response. Set it before any static handler.
- app.use((_req, res, next) => {
- res.setHeader('Access-Control-Allow-Origin', '*')
- next()
- })
+ // cross-origin (`remoteType: 'import'`), which requires CORS on the asset
+ // responses (container, runtime, chunks, manifest). Scope it to the asset
+ // handlers; documents, data requests, and resource routes are unaffected.
+ const allowCrossOriginAssets: Parameters[1] = {
+ maxAge: '1h',
+ setHeaders: (res) => res.setHeader('Access-Control-Allow-Origin', '*'),
+ }
if (IS_DEV) {
// use rsbuild dev server
if (devServer) {
+ app.use((req, res, next) => {
+ if (req.path.startsWith('/static/') || req.path.endsWith('mf-manifest.json')) {
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ }
+ next()
+ })
app.use(devServer.middlewares)
}
} else {
// Remix fingerprints its assets so we can cache forever.
app.use(
'/assets',
- express.static('build/client/assets', { immutable: true, maxAge: '1y' }),
+ express.static('build/client/assets', {
+ ...allowCrossOriginAssets,
+ immutable: true,
+ maxAge: '1y',
+ }),
)
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
- app.use(express.static('build/client', { maxAge: '1h' }))
- app.use('/server', express.static('build/server', { maxAge: '1h' }))
+ app.use(express.static('build/client', allowCrossOriginAssets))
+ app.use('/server', express.static('build/server', allowCrossOriginAssets))
// The host's Node federation runtime resolves this remote's server chunks
// against the node compiler's publicPath (`REMOTE_ASSET_PREFIX`), i.e.
// `/static/js/async/.js`, not against the container URL.
@@ -112,7 +123,7 @@ export async function createApp(devServer?: any) {
// content-hashed, so the two trees do not collide.
app.use(
'/static/js/async',
- express.static('build/server/static/js/async', { maxAge: '1h' }),
+ express.static('build/server/static/js/async', allowCrossOriginAssets),
)
}
diff --git a/src/environment-output.ts b/src/environment-output.ts
index 9e20b8fc..522c4549 100644
--- a/src/environment-output.ts
+++ b/src/environment-output.ts
@@ -1,5 +1,8 @@
import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core';
-import { ensureFederationAsyncStartup } from './federation.js';
+import {
+ ensureFederationAsyncStartup,
+ isolateFederationContainerRuntime,
+} from './federation.js';
/**
* Rspack `output` policy for the web and node environments, in two tiers:
@@ -65,11 +68,24 @@ export const registerReactRouterEnvironmentOutput = ({
return config;
}
+ if (federation && name === 'node' && config.splitChunks !== false) {
+ // `@module-federation/node` replaces Rspack's `readFileVm` chunk
+ // loader with one that tracks loaded chunks privately, so the initial
+ // chunks Rsbuild splits off a multi-entry server build (`chunks:
+ // 'all'`) never satisfy Rspack's startup gate and the async startup
+ // resolves to `undefined` instead of the server build's exports.
+ // Keep server code splitting to async chunks only (#132).
+ config.splitChunks = { ...config.splitChunks, chunks: 'async' };
+ }
+
return mergeEnvironmentConfig(config, {
tools: {
rspack: rspackConfig => {
if (federation) {
ensureFederationAsyncStartup(rspackConfig);
+ if (name === 'web') {
+ isolateFederationContainerRuntime(rspackConfig);
+ }
}
if (name === 'node') {
diff --git a/src/federation.ts b/src/federation.ts
index 37ac5552..9dc6c827 100644
--- a/src/federation.ts
+++ b/src/federation.ts
@@ -1,9 +1,73 @@
import type { Rspack } from '@rsbuild/core';
+type ModuleFederationPluginOptionsLike = {
+ name?: string;
+ experiments?: { asyncStartup?: boolean };
+};
+
type ModuleFederationPluginLike = {
name?: string;
- _options?: { experiments?: { asyncStartup?: boolean } };
- options?: { experiments?: { asyncStartup?: boolean } };
+ _options?: ModuleFederationPluginOptionsLike;
+ options?: ModuleFederationPluginOptionsLike;
+};
+
+const getModuleFederationOptions = (
+ plugin: unknown
+): ModuleFederationPluginOptionsLike | undefined => {
+ if (!plugin || typeof plugin !== 'object') {
+ return undefined;
+ }
+ const federationPlugin = plugin as ModuleFederationPluginLike;
+ if (
+ federationPlugin.name !== 'ModuleFederationPlugin' &&
+ federationPlugin.name !== 'RspackModuleFederationPlugin'
+ ) {
+ return undefined;
+ }
+ return federationPlugin._options ?? federationPlugin.options;
+};
+
+/**
+ * The Module Federation container name(s) configured on this compiler, i.e.
+ * the entry names of the remote containers it emits.
+ */
+export const getFederationContainerNames = (
+ rspackConfig: Rspack.Configuration | undefined
+): string[] =>
+ (rspackConfig?.plugins ?? [])
+ .map(getModuleFederationOptions)
+ .map(options => options?.name)
+ .filter((name): name is string => typeof name === 'string');
+
+/**
+ * Classic mode shares one runtime chunk across every browser entry so route
+ * module entries share a module registry. A federation container must not
+ * share it: importing the container would run the app entries' async startup
+ * (share-scope consumes) before the host has initialized the share scope,
+ * yielding duplicate singletons (a second React). Give containers their own
+ * runtime chunk.
+ */
+export const isolateFederationContainerRuntime = (
+ rspackConfig: Rspack.Configuration | undefined
+): void => {
+ const containers = new Set(getFederationContainerNames(rspackConfig));
+ if (!rspackConfig || containers.size === 0) {
+ return;
+ }
+ const current = rspackConfig.optimization?.runtimeChunk;
+ const appRuntimeName =
+ typeof current === 'object' && typeof current?.name === 'string'
+ ? current.name
+ : 'runtime';
+ rspackConfig.optimization = {
+ ...rspackConfig.optimization,
+ runtimeChunk: {
+ name: (entrypoint: { name: string }) =>
+ containers.has(entrypoint.name)
+ ? `runtime-${entrypoint.name}`
+ : appRuntimeName,
+ },
+ };
};
export const ensureFederationAsyncStartup = (
@@ -14,18 +78,7 @@ export const ensureFederationAsyncStartup = (
}
for (const plugin of rspackConfig.plugins) {
- if (!plugin || typeof plugin !== 'object') {
- continue;
- }
- const federationPlugin = plugin as ModuleFederationPluginLike;
- if (
- federationPlugin.name !== 'ModuleFederationPlugin' &&
- federationPlugin.name !== 'RspackModuleFederationPlugin'
- ) {
- continue;
- }
-
- const pluginOptions = federationPlugin._options ?? federationPlugin.options;
+ const pluginOptions = getModuleFederationOptions(plugin);
if (!pluginOptions) {
continue;
}
diff --git a/src/index.ts b/src/index.ts
index 2fd0eaa7..4f2f36c4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,6 +26,7 @@ import {
type ResolvedReactRouterConfig,
} from './react-router-config.js';
import {
+ collectUnsupportedRscScriptAssets,
configRoutesToRouteManifest,
createReactRouterManifestStats,
type ReactRouterManifestForDev as ReactRouterManifest,
@@ -773,6 +774,27 @@ export const pluginReactRouter = (
stats?.compilation,
manifestChunkNames
);
+ if (isRscMode && stats) {
+ // Rspack's RSC manifest only records browser scripts whose emitted
+ // name ends in ".js" (entry files and client-reference chunks
+ // alike); anything else silently disappears from `entryJsFiles` and
+ // the client manifest, and the server cannot bootstrap or preload
+ // it. Check the emitted output, which is what the manifest saw, so
+ // function filenames and `tools.rspack` overrides are covered too.
+ const unsupported = collectUnsupportedRscScriptAssets(
+ stats.compilation
+ );
+ if (unsupported.length > 0) {
+ throw new Error(
+ `[${PLUGIN_NAME}] RSC mode requires every browser JavaScript asset to be named "*.js" (no query, no other extension): rspack's RSC manifest omits ${unsupported
+ .slice(0, 5)
+ .map(asset => JSON.stringify(asset))
+ .join(
+ ', '
+ )}${unsupported.length > 5 ? ` and ${unsupported.length - 5} more` : ''}. Adjust web \`output.filename.js\` / \`chunkFilename\`.`
+ );
+ }
+ }
}
if (pluginOptions.federation && ssr) {
const serverBuildDir = resolve(buildDirectory, 'server');
@@ -848,22 +870,6 @@ export const pluginReactRouter = (
api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => {
const webConfig = config.environments?.web;
- const webJsFilename =
- webConfig?.output?.filename?.js ?? config.output?.filename?.js;
- // Rspack's RSC manifest only records browser entry files named `*.js`
- // (`entryJsFiles`), and the server renders its bootstrap scripts from
- // that list. Reject filename schemes it would silently drop up front.
- if (
- isRscMode &&
- typeof webJsFilename === 'string' &&
- !/\.js$/.test(webJsFilename)
- ) {
- throw new Error(
- `[${PLUGIN_NAME}] RSC mode requires web \`output.filename.js\` to end in ".js" (got ${JSON.stringify(
- webJsFilename
- )}): rspack's RSC manifest omits entry files with a query or another extension, so the server could not render bootstrap scripts.`
- );
- }
const assetPrefix = resolveEffectiveAssetPrefix(
{ dev: webConfig?.dev, output: webConfig?.output, isBuild },
{ dev: config.dev, output: config.output }
@@ -1044,6 +1050,29 @@ export const pluginReactRouter = (
webOutput: modePlan.webOutput,
});
+ if (pluginOptions.federation && modePlan.kind === 'classic') {
+ // Module Federation's async startup makes every entry's startup a
+ // promise. React Router imports each browser route-module entry
+ // synchronously (`import * as route0 from ".../root.js"`) and reads its
+ // exports right away, and `import()`s split route chunks the same way.
+ // Making those entry modules async (top-level await) turns Rspack's
+ // module-library export into `(await startup).default`, so importers
+ // wait for the awaited startup instead of reading a snapshot of the
+ // promise (#132). Runs after SWC so it applies to the final module code.
+ const browserEntryModules = new Set([
+ finalEntryClientPath,
+ ...routeByFilePath.keys(),
+ ]);
+ api.transform(
+ {
+ environments: ['web'],
+ order: 'post',
+ test: (resourcePath: string) => browserEntryModules.has(resourcePath),
+ },
+ ({ code }) => `${code}\nawait Promise.resolve();\n`
+ );
+ }
+
if (modePlan.kind === 'classic' && useRouteModuleTransformLoader) {
api.modifyEnvironmentConfig(
async (config, { name, mergeEnvironmentConfig }) => {
diff --git a/src/manifest.ts b/src/manifest.ts
index efeda03b..ef4fad56 100644
--- a/src/manifest.ts
+++ b/src/manifest.ts
@@ -179,6 +179,26 @@ export const isManifestJsAsset = (asset: string): boolean =>
export const isManifestCssAsset = (asset: string): boolean =>
/\.css(?:\?.*)?$/.test(asset);
+/**
+ * Browser JavaScript assets rspack's RSC manifest would drop: it only records
+ * chunk files whose emitted name ends in ".js", so `.mjs`/`.cjs` names and
+ * query-hash names (`[name].js?v=...`) vanish from `entryJsFiles` and the
+ * client manifest.
+ */
+export const collectUnsupportedRscScriptAssets = (compilation: {
+ chunks: Iterable<{ files?: Iterable }>;
+}): string[] => {
+ const unsupported = new Set();
+ for (const chunk of compilation.chunks) {
+ for (const file of chunk.files ?? []) {
+ if (isManifestJsAsset(file) && !file.endsWith('.js')) {
+ unsupported.add(file);
+ }
+ }
+ }
+ return [...unsupported];
+};
+
const collectManifestFilesByName = (
items: ReactRouterManifestStatsLookup,
names: ReadonlySet | undefined,
diff --git a/src/rsc-virtual-modules.ts b/src/rsc-virtual-modules.ts
index 64a04a4d..3b1bdbcf 100644
--- a/src/rsc-virtual-modules.ts
+++ b/src/rsc-virtual-modules.ts
@@ -127,11 +127,19 @@ export const createReactRouterRscVirtualModules = ({
'virtual/react-router/unstable_rsc/manifest-prefix': `const manifest = __webpack_require__.rscM;
const serverPrefix = ${JSON.stringify(serverPublicPath)};
const appliedPrefix = manifest?.moduleLoading?.prefix;
-if (appliedPrefix && appliedPrefix !== serverPrefix) {
+// An empty applied prefix (web \`assetPrefix: ''\`) yields relative references
+// such as "static/js/index.js"; those are rebased too, while absolute and
+// protocol-relative URLs are left alone.
+const isAbsoluteUrl = url => /^(?:[a-z][a-z\\d+.-]*:|\\/\\/|\\/)/i.test(url);
+if (typeof appliedPrefix === "string" && appliedPrefix !== serverPrefix) {
const rewrite = url =>
- typeof url === "string" && url.startsWith(appliedPrefix)
- ? serverPrefix + url.slice(appliedPrefix.length)
- : url;
+ typeof url !== "string"
+ ? url
+ : appliedPrefix !== "" && url.startsWith(appliedPrefix)
+ ? serverPrefix + url.slice(appliedPrefix.length)
+ : appliedPrefix === "" && !isAbsoluteUrl(url)
+ ? serverPrefix + url
+ : url;
const rewriteAll = list => {
if (Array.isArray(list)) for (let i = 0; i < list.length; i++) list[i] = rewrite(list[i]);
};
diff --git a/tests/environment-selection.test.ts b/tests/environment-selection.test.ts
deleted file mode 100644
index c8b79428..00000000
--- a/tests/environment-selection.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import * as fs from 'node:fs';
-import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
-import { join } from 'node:path';
-import { createLogger, createRsbuild } from '@rsbuild/core';
-import { pluginReact } from '@rsbuild/plugin-react';
-import { afterEach, expect, it } from '@rstest/core';
-import { pluginReactRouter } from '../src';
-
-// Real Rsbuild with the build narrowed to one environment (`rsbuild build
-// --environment node`). The plugin must not ask Rsbuild for the `web`
-// environment's normalized config in that case: `getNormalizedConfig({
-// environment })` throws for environments filtered out of the build.
-
-let fixtureRoot: string | undefined;
-const repositoryRoot = process.cwd();
-
-afterEach(() => {
- process.chdir(repositoryRoot);
- if (fixtureRoot) {
- rmSync(fixtureRoot, { recursive: true, force: true });
- fixtureRoot = undefined;
- }
-});
-
-it('creates the compiler when only the node environment is selected', async () => {
- const temporaryFixtures = join(repositoryRoot, 'tests/.tmp-dev-runtime');
- mkdirSync(temporaryFixtures, { recursive: true });
- fixtureRoot = mkdtempSync(join(temporaryFixtures, 'env-'));
- cpSync(join(repositoryRoot, 'tests/fixtures/dev-runtime'), fixtureRoot, {
- recursive: true,
- });
- (fs.existsSync as { mockRestore?: () => void }).mockRestore?.();
- // The plugin resolves the app directory from the working directory.
- process.chdir(fixtureRoot);
-
- const rsbuild = await createRsbuild({
- cwd: fixtureRoot,
- environment: ['node'],
- rsbuildConfig: {
- root: fixtureRoot,
- customLogger: createLogger({ level: 'silent' }),
- output: { assetPrefix: 'https://cdn.example.com/app/' },
- plugins: [pluginReactRouter({ lazyCompilation: false }), pluginReact()],
- },
- });
-
- // `createCompiler` runs `onBeforeCreateCompiler`, where the prefix lookup
- // happens.
- const compiler = await rsbuild.createCompiler();
- const names =
- 'compilers' in compiler
- ? compiler.compilers.map(child => child.name)
- : [compiler.name];
- expect(names).toEqual(['node']);
- expect(rsbuild.getNormalizedConfig().environments.web).toBeUndefined();
-}, 60_000);
diff --git a/tests/index.test.ts b/tests/index.test.ts
index 8c1c0cad..b9da8126 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -444,41 +444,6 @@ describe('pluginReactRouter', () => {
).toBeUndefined();
});
- it('rejects web filename schemes the rspack RSC manifest would drop', async () => {
- const rsbuild = await createStubRsbuild({
- action: 'build',
- rsbuildConfig: {
- environments: {
- web: { output: { filename: { js: '[name].js?v=[contenthash:8]' } } },
- },
- },
- });
-
- rsbuild.addPlugins([pluginReactRouter({ rsc: true })]);
-
- await expect(rsbuild.unwrapConfig()).rejects.toThrow(
- /RSC mode requires web `output.filename.js` to end in "\.js"/
- );
- });
-
- it('accepts hashed .js web filenames in RSC mode', async () => {
- const rsbuild = await createStubRsbuild({
- action: 'build',
- rsbuildConfig: {
- environments: {
- web: { output: { filename: { js: '[contenthash:8]-[name].js' } } },
- },
- },
- });
-
- rsbuild.addPlugins([pluginReactRouter({ rsc: true })]);
- const config = await rsbuild.unwrapConfig();
-
- expect(config.environments.web.output.filename.js).toBe(
- '[contenthash:8]-[name].js'
- );
- });
-
it('shrinks classic production browser output', async () => {
const rsbuild = await createStubRsbuild({
action: 'build',
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
index 94513ab6..1de1034e 100644
--- a/tests/manifest.test.ts
+++ b/tests/manifest.test.ts
@@ -9,6 +9,7 @@ import {
generateReactRouterManifestForDev,
getReactRouterManifestForDev,
getReactRouterManifestChunkNames,
+ collectUnsupportedRscScriptAssets,
isManifestCssAsset,
isManifestJsAsset,
} from '../src/manifest';
@@ -615,6 +616,21 @@ describe('manifest', () => {
}
});
+ it('lists browser scripts the rspack RSC manifest would drop', () => {
+ const compilation = {
+ chunks: [
+ { files: new Set(['static/js/client-index.js?v=abc12345', 'static/css/index.css']) },
+ { files: new Set(['static/js/async/757.mjs', 'static/js/async/757.js']) },
+ { files: new Set(['static/js/ok.abc123.js', 'static/js/ok.js.map']) },
+ { files: undefined },
+ ],
+ };
+ expect(collectUnsupportedRscScriptAssets(compilation)).toEqual([
+ 'static/js/client-index.js?v=abc12345',
+ 'static/js/async/757.mjs',
+ ]);
+ });
+
it('fails the build instead of inventing a module path when a chunk has no script', async () => {
const { root, appDir } = createTempApp(`
export default function Page() { return null; }
diff --git a/tests/output-config.integration.test.ts b/tests/output-config.integration.test.ts
index 710e0d88..69507fe7 100644
--- a/tests/output-config.integration.test.ts
+++ b/tests/output-config.integration.test.ts
@@ -10,7 +10,7 @@ import {
} from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { afterAll, beforeAll, describe, expect, it } from '@rstest/core';
-import { pluginReactRouter, pluginReactRouterRSC } from '../src';
+import { pluginReactRouter, pluginReactRouterRSC } from '../src/index.js';
// Output precedence against real Rsbuild: `inspectConfig` runs the full
// pipeline (config normalization, environment hooks, `modifyRspackConfig`,
@@ -37,12 +37,14 @@ afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true });
});
-const inspect = async (
+const create = (
plugin: RsbuildPlugin,
- rsbuildConfig: RsbuildConfig = {}
-): Promise> => {
- const rsbuild = await createRsbuild({
+ rsbuildConfig: RsbuildConfig = {},
+ environment?: string[]
+) =>
+ createRsbuild({
cwd: fixtureRoot,
+ environment,
rsbuildConfig: {
root: fixtureRoot,
customLogger: createLogger({ level: 'silent' }),
@@ -50,6 +52,12 @@ const inspect = async (
plugins: [plugin, pluginReact(), ...(rsbuildConfig.plugins ?? [])],
},
});
+
+const inspect = async (
+ plugin: RsbuildPlugin,
+ rsbuildConfig: RsbuildConfig = {}
+): Promise> => {
+ const rsbuild = await create(plugin, rsbuildConfig);
const { origin } = await rsbuild.inspectConfig({ mode: 'production' });
return Object.fromEntries(
origin.bundlerConfigs.map(config => [config.name, config])
@@ -133,9 +141,23 @@ describe('final Rspack output configuration (real Rsbuild)', () => {
expect(output(web).publicPath).toBe('auto');
});
- it('configures CommonJS server output and federation chunk loading', async () => {
+ // https://github.com/rstackjs/rsbuild-plugin-react-router/issues/132
+ it('configures CommonJS server output and Module Federation invariants', async () => {
+ const federationPlugin = (options: Record) => ({
+ name: 'ModuleFederationPlugin',
+ _options: options,
+ apply() {},
+ });
+ const webPlugin = federationPlugin({ name: 'host', shared: { react: {} } });
+ const nodePlugin = federationPlugin({ name: 'host', shared: { react: {} } });
const { web, node } = await inspect(
- pluginReactRouter({ serverOutput: 'commonjs', federation: true })
+ pluginReactRouter({ serverOutput: 'commonjs', federation: true }),
+ {
+ environments: {
+ web: { tools: { rspack: { plugins: [webPlugin] } } },
+ node: { tools: { rspack: { plugins: [nodePlugin] } } },
+ },
+ }
);
expect(output(web).chunkLoading).toBe('import');
@@ -147,8 +169,55 @@ describe('final Rspack output configuration (real Rsbuild)', () => {
library: { type: 'commonjs2' },
});
expect(node.target).toBe('async-node');
+
+ // Async startup is mandatory on every compiler; sharing stays as declared
+ // (non-eager).
+ expect(webPlugin._options.experiments).toEqual({ asyncStartup: true });
+ expect(nodePlugin._options.experiments).toEqual({ asyncStartup: true });
+ expect(webPlugin._options.shared).toEqual({ react: {} });
+
+ // The container gets its own runtime chunk; app entries keep sharing one.
+ const runtimeChunk = web.optimization?.runtimeChunk as {
+ name: (entrypoint: { name: string }) => string;
+ };
+ expect(runtimeChunk.name({ name: 'host' })).toBe('runtime-host');
+ expect(runtimeChunk.name({ name: 'entry.client' })).toBe('runtime');
+ expect(runtimeChunk.name({ name: 'root' })).toBe('runtime');
+
+ // The server build has no initial chunk dependencies for the async
+ // startup gate; server code splitting stays async-only.
+ expect(node.optimization?.splitChunks).toMatchObject({ chunks: 'async' });
+ });
+
+ it('keeps the shared browser runtime chunk without federation', async () => {
+ const { web, node } = await inspect(pluginReactRouter());
+ expect(web.optimization?.runtimeChunk).toBe('single');
+ expect(node.optimization?.splitChunks).toMatchObject({ chunks: 'all' });
});
+ // `getNormalizedConfig({ environment: 'web' })` throws when the build is
+ // narrowed to other environments; `onBeforeCreateCompiler` must not call it.
+ it('creates the compiler when only the node environment is selected', async () => {
+ const rsbuild = await create(
+ pluginReactRouter({ lazyCompilation: false }),
+ { output: { assetPrefix: 'https://cdn.example.com/app/' } },
+ ['node']
+ );
+ const compiler = await rsbuild.createCompiler();
+ try {
+ const names =
+ 'compilers' in compiler
+ ? compiler.compilers.map(child => child.name)
+ : [compiler.name];
+ expect(names).toEqual(['node']);
+ expect(rsbuild.getNormalizedConfig().environments.web).toBeUndefined();
+ } finally {
+ await new Promise((resolve, reject) =>
+ compiler.close(error => (error ? reject(error) : resolve()))
+ );
+ }
+ }, 60_000);
+
it('configures RSC browser output', async () => {
const { web } = await inspect(pluginReactRouterRSC());
diff --git a/tests/react-router-framework/integration/asset-prefix-auto-test.ts b/tests/react-router-framework/integration/asset-prefix-auto-test.ts
index 1d693bfe..01dc6011 100644
--- a/tests/react-router-framework/integration/asset-prefix-auto-test.ts
+++ b/tests/react-router-framework/integration/asset-prefix-auto-test.ts
@@ -1,5 +1,4 @@
-import { readdirSync, readFileSync } from "node:fs";
-import { spawnSync } from "node:child_process";
+import { readdirSync } from "node:fs";
import path from "node:path";
import getPort from "get-port";
import { test, expect } from "@playwright/test";
@@ -11,7 +10,6 @@ import {
customDev,
reactRouterConfig,
} from "./helpers/rsbuild.js";
-import { rsbuildBin } from "./helpers/rsbuild-adapter.js";
import { observeAssetResponses } from "./helpers/asset-responses.js";
// https://github.com/rstackjs/rsbuild-plugin-react-router/issues/130
@@ -27,7 +25,8 @@ import { observeAssetResponses } from "./helpers/asset-responses.js";
// origin return a real 404 for an emitted asset. The relocation scenario is
// the negative control: its build-time root prefix is `/`, so the old
// hard-coded mechanism resolves async CSS to the page origin and fails, while
-// automatic resolution follows the script to the asset origin.
+// automatic resolution follows the script to the asset origin. (The final
+// compiler `publicPath: 'auto'` is asserted by the real-config unit suite.)
const ASYNC_CSS_COLOR = "rgb(255, 0, 0)";
@@ -198,20 +197,6 @@ for (const scenario of scenarios) {
const emittedCss = files.find((file) => /^static\/css\/async\/.*\.css$/.test(file));
expect(emittedCss, "an async stylesheet was emitted").toBeDefined();
- await test.step("the final web compiler config keeps publicPath 'auto'", () => {
- const inspect = spawnSync(process.argv[0], [rsbuildBin, "inspect", "--mode", "production"], {
- cwd,
- env: { ...process.env, NODE_ENV: "production" },
- });
- expect(inspect.status, inspect.stderr.toString()).toBe(0);
- const webConfig = readFileSync(
- path.join(cwd, "build/.rsbuild/rspack.config.web.mjs"),
- "utf8",
- );
- // Complementary evidence only; the browser steps below are decisive.
- expect.soft(webConfig).toMatch(/publicPath: 'auto'/);
- });
-
await test.step("the page origin cannot serve an emitted asset", async () => {
const wrongOrigin = await request.get(`http://localhost:${port}/${emittedCss}`);
expect(wrongOrigin.status()).toBe(404);
diff --git a/tests/react-router-framework/integration/federation-test.ts b/tests/react-router-framework/integration/federation-test.ts
index 9193adc1..ef60eeb4 100644
--- a/tests/react-router-framework/integration/federation-test.ts
+++ b/tests/react-router-framework/integration/federation-test.ts
@@ -10,23 +10,25 @@ import {
} from "./helpers/rsbuild.js";
import { observeAssetResponses } from "./helpers/asset-responses.js";
-// Module Federation with `federation: true`, a minimal host and remote.
+// Module Federation with `federation: true`, a minimal host and remote (#132).
//
-// The remote is BUILT with root `output.assetPrefix` pointing at origin A
-// (`/remote/v1/`) and the browser compiler on `'auto'`. The host's browser
-// loads the container from origin B under a different sub-path (`/remote/v2/`).
-// Origin A serves the same client build but refuses async stylesheets, so a
-// browser runtime that had the build-time prefix baked in (the plugin's old
-// forced `publicPath`) fails the lazy import's CSS, whereas automatic
-// resolution follows the loaded runtime to B.
+// Every ModuleFederationPlugin keeps `experiments.asyncStartup: true` (the
+// plugin enforces it) and every shared dependency stays non-eager. The remote
+// is BUILT with root `output.assetPrefix` pointing at origin A (`/remote/v1/`)
+// and the browser compiler on `'auto'`. The host's Node consumer fetches the
+// container and an exposed module's server chunk from origin A over HTTP (it
+// has no filesystem access to the remote build); the host's browser loads the
+// container from origin B under a different sub-path (`/remote/v2/`). Origin A
+// refuses async stylesheets, so a browser runtime with the build-time prefix
+// baked in (the plugin's old forced `publicPath`) fails the lazy import's CSS,
+// whereas automatic resolution follows the loaded runtime to B.
//
-// Covered: direct ESM container on another origin + sub-path; an exposed
-// component with a further lazy JS/CSS dependency; relocated automatic browser
-// runtime with a negative control; CORS on the remote's asset responses.
-// Not covered here: a Node federation consumer (the CommonJS `asyncStartup`
-// server build currently resolves to `undefined` with @module-federation/node
-// 2.7.44, independent of this plugin's output changes) and manifest-based
-// (`mf-manifest.json`) remote loading.
+// Covered: awaited valid Node ServerBuild through async startup; SSR of the
+// remote; browser hydration through async startup (route-module entries are
+// made async so their exports resolve); direct ESM container on another
+// origin + sub-path; exposed component with a further lazy JS/CSS dependency;
+// relocated automatic browser runtime; CORS on the remote's asset responses.
+// Not covered: manifest-based (`mf-manifest.json`) remote loading.
const DETAILS_COLOR = "rgb(0, 0, 255)";
@@ -50,7 +52,6 @@ const remoteFiles = (rootAssetPrefix: string) => ({
const common = {
name: "remote",
exposes: { "./Widget": "./app/federation/widget.tsx" },
- shared: ${SHARED},
shareStrategy: "loaded-first",
experiments: { asyncStartup: true },
dts: false,
@@ -69,11 +70,12 @@ const remoteFiles = (rootAssetPrefix: string) => ({
web: {
// The browser runtime follows the script it was loaded from.
output: { assetPrefix: "auto" },
- tools: { rspack: { plugins: [new ModuleFederationPlugin({ ...common, library: { type: "module" } })] } },
+ tools: { rspack: { plugins: [new ModuleFederationPlugin({ ...common, shared: ${SHARED}, library: { type: "module" } })] } },
},
node: {
tools: { rspack: { plugins: [new ModuleFederationPlugin({
...common,
+ shared: ${SHARED},
library: { type: "commonjs-module" },
runtimePlugins: ["@module-federation/node/runtimePlugin"],
})] } },
@@ -127,6 +129,7 @@ const remoteFiles = (rootAssetPrefix: string) => ({
a.get("/", (_req, res) => res.end("remote"));
a.use(process.env.SERVER_MOUNT + "/static/css/async", (_req, res) => res.status(404).end("blocked"));
a.use(process.env.SERVER_MOUNT, express.static("build/client", { index: false }));
+ a.use(process.env.SERVER_MOUNT + "/static/js/async", express.static("build/server/static/js/async"));
a.listen(Number(process.env.PORT), () => console.log("remote A on " + process.env.PORT));
const b = express();
@@ -136,8 +139,17 @@ const remoteFiles = (rootAssetPrefix: string) => ({
`,
});
-const hostFiles = (remoteWebEntry: string) => ({
+const hostFiles = (remoteWebEntry: string, remoteNodeEntry: string) => ({
"react-router.config.ts": reactRouterConfig({}),
+ "app/root.tsx": js`
+ import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
+ export default function App() {
+ return (
+
+
+ );
+ }
+ `,
"rsbuild.config.ts": `
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
import { defineConfig } from "@rsbuild/core";
@@ -161,23 +173,28 @@ const hostFiles = (remoteWebEntry: string) => ({
remotes: { remote: ${JSON.stringify(remoteWebEntry)} },
})] } },
},
- // The remote is consumed in the browser only; keep the specifier out
- // of the server bundle.
- node: { output: { externals: ["remote/Widget"] } },
+ node: {
+ tools: { rspack: { plugins: [new ModuleFederationPlugin({
+ name: "host",
+ shared: ${SHARED},
+ shareStrategy: "loaded-first",
+ experiments: { asyncStartup: true },
+ dts: false,
+ remotes: { remote: ${JSON.stringify(`remote@${remoteNodeEntry}`)} },
+ runtimePlugins: ["@module-federation/node/runtimePlugin"],
+ })] } },
+ },
},
});
`,
"app/routes/_index.tsx": js`
- import { lazy, Suspense, useEffect, useState } from "react";
- const Widget = lazy(() => import("remote/Widget"));
+ import Widget from "remote/Widget";
export default function Index() {
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
return (
<>
host
- {mounted ? loading remote
}> : null}
+
>
);
}
@@ -199,19 +216,6 @@ const hostFiles = (remoteWebEntry: string) => ({
});
test.describe("Module Federation: remote consumed by a host on other origins", () => {
- // Pre-existing (reproduces identically with the plugin built from `main`):
- // in production, the host's browser entry never reaches hydration once
- // `federation: true` forces MF `experiments.asyncStartup` (no React fibers
- // attach, the remote container is never requested, no console errors), and a
- // Node consumer's CommonJS async server build resolves to `undefined`. The
- // Epic Stack federation host fails to boot on `main` for the same reasons.
- // Keep this fixture as the reproduction; lift the fixme once the plugin's
- // federation startup integration is fixed.
- test.fixme(
- true,
- "federation: true production startup never hydrates the host (pre-existing; see comment)",
- );
-
let hostPort: number;
let remoteAPort: number;
let remoteBPort: number;
@@ -244,7 +248,9 @@ test.describe("Module Federation: remote consumed by a host on other origins", (
);
// Separate project directory: the host cannot read the remote build.
- const hostCwd = await createProject(hostFiles(`${remoteBBase}static/js/remote.js`));
+ const hostCwd = await createProject(
+ hostFiles(`${remoteBBase}static/js/remote.js`, `${remoteABase}static/static/js/remote.js`),
+ );
const hostBuild = build({ cwd: hostCwd });
expect(hostBuild.status, hostBuild.stderr.toString()).toBe(0);
stops.push(
@@ -259,7 +265,7 @@ test.describe("Module Federation: remote consumed by a host on other origins", (
for (const stop of stops.reverse()) await stop();
});
- test("loads a remote component and its lazy JS/CSS from the origin the container came from", async ({
+ test("renders on the server, hydrates, and lazy-loads a remote component across origins", async ({
page,
request,
}) => {
@@ -276,6 +282,13 @@ test.describe("Module Federation: remote consumed by a host on other origins", (
expect(blocked.status()).toBe(404);
});
+ await test.step("the Node consumer renders the remote over HTTP (isolated filesystem)", async () => {
+ const response = await request.get(`http://localhost:${hostPort}/`);
+ expect(response.status()).toBe(200);
+ const html = await response.text();
+ expect(html).toContain("remote widget");
+ });
+
const observed = observeAssetResponses(page);
await page.goto(`http://localhost:${hostPort}/`, { waitUntil: "networkidle" });
await expect(page.locator("[data-widget-label]")).toHaveText("remote widget");
diff --git a/tests/react-router-framework/integration/rsc-asset-prefix-auto-test.ts b/tests/react-router-framework/integration/rsc-asset-prefix-auto-test.ts
index b851d94f..b0d33618 100644
--- a/tests/react-router-framework/integration/rsc-asset-prefix-auto-test.ts
+++ b/tests/react-router-framework/integration/rsc-asset-prefix-auto-test.ts
@@ -1,5 +1,5 @@
import getPort from "get-port";
-import { test, expect, type Page } from "@playwright/test";
+import { test, expect } from "@playwright/test";
import { css, js } from "./helpers/create-fixture.js";
import {
@@ -8,6 +8,7 @@ import {
customDev,
reactRouterConfig,
} from "./helpers/rsbuild.js";
+import { observeAssetResponses } from "./helpers/asset-responses.js";
// RSC framework mode with the browser compiler on `'auto'` and the server's
// initial asset URLs on a root prefix (#130). Everything the server renders
@@ -17,6 +18,7 @@ import {
// reachable at the configured location; the page origin 404s `/static/*`.
const ROUTE_CSS_COLOR = "rgb(0, 128, 0)";
+const COUNTER_CSS_COLOR = "rgb(0, 0, 255)";
const ASYNC_CSS_COLOR = "rgb(255, 0, 0)";
const appFiles = {
@@ -38,16 +40,25 @@ const appFiles = {
return async css
;
}
`,
+ // The initially rendered client component carries its own stylesheet: its
+ // URL comes from the client manifest's `cssFiles`, a third server-emitted
+ // reference alongside bootstrap scripts and route `entryCssFiles`.
+ "app/components/counter.css": css`
+ .counter {
+ color: ${COUNTER_CSS_COLOR};
+ }
+ `,
"app/components/counter.tsx": js`
"use client";
import { lazy, Suspense, useState } from "react";
+ import "./counter.css";
const AsyncComponent = lazy(() => import("./async-component"));
export function Counter() {
const [count, setCount] = useState(0);
return (
<>
-
+
{count > 0 ? (
loading}>
@@ -92,20 +103,6 @@ const appFiles = {
`,
};
-async function collectAssetRequests(page: Page) {
- const requests: string[] = [];
- const failures: string[] = [];
- const errors: Error[] = [];
- page.on("request", (request) => {
- if (/\.(?:m?js|css)(?:\?|$)/.test(request.url())) requests.push(request.url());
- });
- page.on("response", (response) => {
- if (response.status() >= 400) failures.push(`${response.status()} ${response.url()}`);
- });
- page.on("pageerror", (error) => errors.push(error));
- return { requests, failures, errors };
-}
-
test.describe("RSC: web assetPrefix 'auto' with assets on a CDN sub-path", () => {
let port: number;
let cdnPort: number;
@@ -176,22 +173,24 @@ test.describe("RSC: web assetPrefix 'auto' with assets on a CDN sub-path", () =>
expect(html).not.toMatch(/(?:src|href)="\/static\//);
});
- test("route CSS applies before hydration and async CSS loads from the CDN after", async ({
+ test("route and client-reference CSS apply before hydration; async CSS loads from the CDN after", async ({
page,
}) => {
- const { requests, failures, errors } = await collectAssetRequests(page);
-
// Before hydration: block scripts so only server-rendered markup and
- // stylesheets are in play.
+ // stylesheets are in play. Intentional aborts are not HTTP failures, so
+ // they are not recorded by the observer used in the second phase.
await page.route("**/*.js", (route) => route.abort());
await page.goto(`http://localhost:${port}/`);
await expect(page.locator("[data-home]")).toHaveCSS("color", ROUTE_CSS_COLOR);
+ await expect(page.locator("[data-inc]")).toHaveCSS("color", COUNTER_CSS_COLOR);
await page.unroute("**/*.js");
// After hydration: interaction works and the async stylesheet is fetched
// from the CDN by the browser runtime's automatic public path.
+ const observed = observeAssetResponses(page);
await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" });
await expect(page.locator("[data-home]")).toHaveCSS("color", ROUTE_CSS_COLOR);
+ await expect(page.locator("[data-inc]")).toHaveCSS("color", COUNTER_CSS_COLOR);
const cssResponse = page.waitForResponse((response) =>
/\/static\/css\/async\//.test(response.url()),
);
@@ -201,10 +200,10 @@ test.describe("RSC: web assetPrefix 'auto' with assets on a CDN sub-path", () =>
expect(asyncCss.url().startsWith(`${assetBase}static/css/async/`), asyncCss.url()).toBe(true);
await expect(page.locator("[data-async]")).toHaveCSS("color", ASYNC_CSS_COLOR);
- for (const url of requests) {
+ for (const url of observed.requests) {
expect(url.startsWith(assetBase), `asset request ${url}`).toBe(true);
}
- expect(failures.filter((f) => !/\.js$/.test(f))).toEqual([]);
- expect(errors).toEqual([]);
+ expect(observed.failures).toEqual([]);
+ expect(observed.pageErrors).toEqual([]);
});
});
diff --git a/tests/react-router-framework/integration/rsc-output-filename-test.ts b/tests/react-router-framework/integration/rsc-output-filename-test.ts
new file mode 100644
index 00000000..9e184d00
--- /dev/null
+++ b/tests/react-router-framework/integration/rsc-output-filename-test.ts
@@ -0,0 +1,80 @@
+import { test, expect } from "@playwright/test";
+
+import { js } from "./helpers/create-fixture.js";
+import { build, createProject, reactRouterConfig } from "./helpers/rsbuild.js";
+
+// RSC framework mode reads the browser bootstrap scripts and client-reference
+// chunks from the rspack RSC manifest, which only records chunk files whose
+// emitted name ends in ".js". The plugin validates the *emitted* web output,
+// so function filenames and `tools.rspack` overrides are covered, not just a
+// string `output.filename.js`.
+
+const appFiles = {
+ "react-router.config.ts": reactRouterConfig({}),
+ "app/components/counter.tsx": js`
+ "use client";
+ import { useState } from "react";
+ export function Counter() {
+ const [count, setCount] = useState(0);
+ return ;
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import { Counter } from "../components/counter";
+ export default function Index() {
+ return <>Home
>;
+ }
+ `,
+};
+
+const rsbuildConfigFile = (webConfig: string) => `
+ import { defineConfig } from "@rsbuild/core";
+ import { pluginReact } from "@rsbuild/plugin-react";
+ import { pluginReactRouterRSC } from "rsbuild-plugin-react-router";
+
+ export default defineConfig({
+ plugins: [pluginReact(), pluginReactRouterRSC({ customServer: true })],
+ environments: { web: ${webConfig} },
+ });
+`;
+
+const cases = [
+ {
+ name: "query-hash entry filename",
+ webConfig: `{ output: { filename: { js: "client-[name].js?v=[contenthash:8]" } } }`,
+ dropped: /client-index\.js\?v=[a-f0-9]{8}/,
+ },
+ {
+ name: ".mjs async chunkFilename through tools.rspack",
+ webConfig: `{ tools: { rspack: (config) => { config.output.chunkFilename = "static/js/async/[name].mjs"; } } }`,
+ dropped: /static\/js\/async\/[^"]+\.mjs/,
+ },
+];
+
+for (const testCase of cases) {
+ test(`RSC build rejects browser scripts the RSC manifest would drop: ${testCase.name}`, async () => {
+ const cwd = await createProject(
+ { ...appFiles, "rsbuild.config.ts": rsbuildConfigFile(testCase.webConfig) },
+ "rsc-framework",
+ );
+ const result = build({ cwd });
+ const output = result.stdout.toString() + result.stderr.toString();
+ expect(result.status, output).not.toBe(0);
+ expect(output).toMatch(/RSC mode requires every browser JavaScript asset to be named "\*\.js"/);
+ expect(output).toMatch(testCase.dropped);
+ });
+}
+
+test("RSC build accepts hashed .js browser filenames", async () => {
+ const cwd = await createProject(
+ {
+ ...appFiles,
+ "rsbuild.config.ts": rsbuildConfigFile(
+ `{ output: { filename: { js: "[contenthash:8]-[name].js" } } }`,
+ ),
+ },
+ "rsc-framework",
+ );
+ const result = build({ cwd });
+ expect(result.status, result.stderr.toString()).toBe(0);
+});
diff --git a/tests/rsc-support.test.ts b/tests/rsc-support.test.ts
index b075a738..d4ab16bd 100644
--- a/tests/rsc-support.test.ts
+++ b/tests/rsc-support.test.ts
@@ -50,7 +50,6 @@ describe('RSC support helpers', () => {
basename: '/',
buildDirectory: '/repo/build',
isBuild: false,
- jsDistPath: 'custom/js',
outputClientPath: '/repo/build/client',
publicPath: '/assets',
routeDiscovery: { mode: 'initial' },
@@ -123,6 +122,22 @@ describe('RSC support helpers', () => {
// Idempotent: a second evaluation must not re-prefix.
evaluate(swapped.rscM);
expect(swapped.rscM.entryJsFiles).toEqual(['/assets/static/js/index.abc123.js']);
+ // Empty browser prefix (web `assetPrefix: ''`): rspack emits relative
+ // references, which are rebased onto the server prefix; absolute and
+ // protocol-relative URLs are untouched.
+ const relative = evaluate({
+ entryJsFiles: ['static/js/index.abc123.js', 'https://other.example/x.js', '//cdn.example/y.js'],
+ entryCssFiles: { 'root.tsx': ['static/css/root.css'] },
+ clientManifest: {},
+ moduleLoading: { prefix: '' },
+ });
+ expect(relative.bootstrap).toEqual([
+ '/assets/static/js/index.abc123.js',
+ 'https://other.example/x.js',
+ '//cdn.example/y.js',
+ ]);
+ expect(relative.rscM.entryCssFiles['root.tsx']).toEqual(['/assets/static/css/root.css']);
+ expect(relative.rscM.moduleLoading.prefix).toBe('/assets/');
// No entry script recorded (rspack drops non-`.js` names): fail loudly
// rather than render a document that cannot hydrate.
expect(() =>
@@ -157,7 +172,6 @@ describe('RSC support helpers', () => {
basename: '/',
buildDirectory: '/repo/build',
isBuild: true,
- jsDistPath: 'static/js',
outputClientPath: '/repo/build/client',
publicPath: '/',
routeDiscovery,
@@ -184,7 +198,6 @@ describe('RSC support helpers', () => {
basename: '/',
buildDirectory: '/repo/build',
isBuild: true,
- jsDistPath: 'static/js',
outputClientPath: '/repo/build/client',
publicPath: '/',
routeDiscovery: { mode: 'initial' },
diff --git a/tsconfig.tests.json b/tsconfig.tests.json
index e33e8959..21df8385 100644
--- a/tsconfig.tests.json
+++ b/tsconfig.tests.json
@@ -5,5 +5,8 @@
"noEmit": true,
"rootDir": "."
},
- "include": ["tests/react-router-framework-*.test.ts"]
+ "include": [
+ "tests/react-router-framework-*.test.ts",
+ "tests/output-config.integration.test.ts"
+ ]
}
From ac44e3fea151c4ef094d24125efbf4b3b6b2b29e Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 17:54:50 +0000
Subject: [PATCH 2/3] fix(federation): keep empty client route modules
parseable as ESM
A route with only server exports compiles to an empty browser module; the
async-entry transform now appends `export {}` alongside the top-level await
so Rspack parses it as an ES module. Covered by a server-only route in the
federation fixture; both Epic Stack federation apps build again.
---
src/index.ts | 4 +++-
.../react-router-framework/integration/federation-test.ts | 7 +++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/index.ts b/src/index.ts
index 4f2f36c4..6491e5b3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1069,7 +1069,9 @@ export const pluginReactRouter = (
order: 'post',
test: (resourcePath: string) => browserEntryModules.has(resourcePath),
},
- ({ code }) => `${code}\nawait Promise.resolve();\n`
+ // `export {}` keeps an otherwise-empty client module (a route with only
+ // server exports) parsed as ESM, which top-level await requires.
+ ({ code }) => `${code}\nexport {};\nawait Promise.resolve();\n`
);
}
diff --git a/tests/react-router-framework/integration/federation-test.ts b/tests/react-router-framework/integration/federation-test.ts
index ef60eeb4..e49173d7 100644
--- a/tests/react-router-framework/integration/federation-test.ts
+++ b/tests/react-router-framework/integration/federation-test.ts
@@ -187,6 +187,13 @@ const hostFiles = (remoteWebEntry: string, remoteNodeEntry: string) => ({
},
});
`,
+ // A route with only server exports compiles to an empty browser module; the
+ // async-entry transform must still produce a valid ES module for it.
+ "app/routes/api.ts": js`
+ export async function loader() {
+ return Response.json({ ok: true });
+ }
+ `,
"app/routes/_index.tsx": js`
import Widget from "remote/Widget";
From 0286ef1d29a41e5649f0e5f24047880c170ffa7b Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 22:05:00 +0000
Subject: [PATCH 3/3] fix(federation,rsc): enforce async-only server splitting
at the final boundary; classify RSC scripts by chunk metadata
- enforceAsyncOnlyServerSplitChunks runs in the final tools.rspack callback
(after user overrides) and sets chunks: 'async' on the global filter and on
every cache group that declares one (Rsbuild's single-vendor preset adds an
enforced chunks: 'all' group that Rspack prefers over the global filter);
a disabled splitChunks stays disabled. The environment-config-stage
assignment is removed. Real-config tests cover a late tools.rspack
override, the single-vendor preset, and splitChunks: false.
- collectUnsupportedRscScriptAssets identifies JavaScript-emitting chunks from
compilation metadata (javascript content hash / modules) and derives each
chunk's emitted script name from its own filename template (entry or
async; string or function) via compilation.getPath, then applies the
plain-.js rule -- so scripts under .txt or no extension are reported and
CSS-only chunks are ignored. Integration cases now use a filename function
for the entry and a .txt async chunkFilename via tools.rspack.
---
.changeset/federation-async-startup.md | 11 ++-
src/environment-output.ts | 13 +---
src/federation.ts | 26 +++++++
src/manifest.ts | 73 ++++++++++++++++---
tests/manifest.test.ts | 41 +++++++++--
tests/output-config.integration.test.ts | 46 ++++++++++++
.../integration/rsc-output-filename-test.ts | 13 ++--
7 files changed, 187 insertions(+), 36 deletions(-)
diff --git a/.changeset/federation-async-startup.md b/.changeset/federation-async-startup.md
index dcfa3818..5f37747b 100644
--- a/.changeset/federation-async-startup.md
+++ b/.changeset/federation-async-startup.md
@@ -12,7 +12,9 @@ asset handling.
proceeds. Each federation container gets its own runtime chunk, so importing
a container no longer runs the remote app's own share-scope consumes before
the host initializes the share scope (which produced a second React).
-- Federation (server): server code splitting is async-only, so the CommonJS
+- Federation (server): server code splitting is async-only (enforced at the
+ final `tools.rspack` boundary, including cache groups such as Rsbuild's
+ `single-vendor` preset; a disabled `splitChunks` is kept), so the CommonJS
server build has no initial chunk dependencies. `@module-federation/node`
replaces Rspack's chunk loader with one that tracks loaded chunks privately,
which left Rspack's startup gate unsatisfied and made the awaited server
@@ -20,8 +22,9 @@ asset handling.
every compiler and shared dependencies stay non-eager.
- RSC: the manifest prefix alignment also rebases relative references produced
by an empty browser `assetPrefix` (absolute and protocol-relative URLs are
- left alone). RSC filename validation now checks the emitted web output
- (entry files and client chunks, including function filenames and
- `tools.rspack` overrides) instead of the raw `output.filename.js` string.
+ left alone). RSC filename validation now checks the emitted web output:
+ JavaScript-emitting chunks are identified from compilation metadata and
+ their emitted script names (entry or async template, including function
+ templates and `tools.rspack` overrides) must end in `.js`.
- Federation example: CORS is scoped to the remote's asset handlers instead of
the whole application.
diff --git a/src/environment-output.ts b/src/environment-output.ts
index 522c4549..7d0ac6d5 100644
--- a/src/environment-output.ts
+++ b/src/environment-output.ts
@@ -1,5 +1,6 @@
import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core';
import {
+ enforceAsyncOnlyServerSplitChunks,
ensureFederationAsyncStartup,
isolateFederationContainerRuntime,
} from './federation.js';
@@ -68,16 +69,6 @@ export const registerReactRouterEnvironmentOutput = ({
return config;
}
- if (federation && name === 'node' && config.splitChunks !== false) {
- // `@module-federation/node` replaces Rspack's `readFileVm` chunk
- // loader with one that tracks loaded chunks privately, so the initial
- // chunks Rsbuild splits off a multi-entry server build (`chunks:
- // 'all'`) never satisfy Rspack's startup gate and the async startup
- // resolves to `undefined` instead of the server build's exports.
- // Keep server code splitting to async chunks only (#132).
- config.splitChunks = { ...config.splitChunks, chunks: 'async' };
- }
-
return mergeEnvironmentConfig(config, {
tools: {
rspack: rspackConfig => {
@@ -85,6 +76,8 @@ export const registerReactRouterEnvironmentOutput = ({
ensureFederationAsyncStartup(rspackConfig);
if (name === 'web') {
isolateFederationContainerRuntime(rspackConfig);
+ } else {
+ enforceAsyncOnlyServerSplitChunks(rspackConfig);
}
}
diff --git a/src/federation.ts b/src/federation.ts
index 9dc6c827..ce87ec42 100644
--- a/src/federation.ts
+++ b/src/federation.ts
@@ -89,3 +89,29 @@ export const ensureFederationAsyncStartup = (
};
}
};
+
+/**
+ * `@module-federation/node` replaces Rspack's `readFileVm` chunk loader with one
+ * that tracks loaded chunks privately, so initial chunks split off a
+ * multi-entry server build never satisfy Rspack's startup gate
+ * (`__webpack_require__.O`) and the async startup resolves to `undefined`
+ * instead of the server build's exports. Keep server code splitting to async
+ * chunks only. Runs at the final `tools.rspack` boundary so a user
+ * `optimization.splitChunks` override or a preset whose cache group selects
+ * `chunks: 'all'` (e.g. Rsbuild's `single-vendor`, `enforce: true`) cannot
+ * reintroduce initial chunk dependencies. A disabled `splitChunks` is kept.
+ */
+export const enforceAsyncOnlyServerSplitChunks = (
+ rspackConfig: Rspack.Configuration | undefined
+): void => {
+ const splitChunks = rspackConfig?.optimization?.splitChunks;
+ if (!splitChunks) {
+ return;
+ }
+ splitChunks.chunks = 'async';
+ for (const group of Object.values(splitChunks.cacheGroups ?? {})) {
+ if (group && typeof group === 'object' && 'chunks' in group) {
+ group.chunks = 'async';
+ }
+ }
+};
diff --git a/src/manifest.ts b/src/manifest.ts
index ef4fad56..335bb8a5 100644
--- a/src/manifest.ts
+++ b/src/manifest.ts
@@ -179,21 +179,74 @@ export const isManifestJsAsset = (asset: string): boolean =>
export const isManifestCssAsset = (asset: string): boolean =>
/\.css(?:\?.*)?$/.test(asset);
+/**
+ * The minimal compilation surface for `collectUnsupportedRscScriptAssets`.
+ * Chunks are classified as JavaScript-emitting from compilation metadata (their
+ * `javascript` content hash / modules), never from a filename.
+ */
+export type RscScriptAssetCompilation = {
+ chunks: Iterable;
+ chunkGraph: {
+ getChunkModulesIterableBySourceType(
+ chunk: RscScriptAssetChunk,
+ sourceType: string
+ ): Iterable;
+ };
+ outputOptions: {
+ filename?: unknown;
+ chunkFilename?: unknown;
+ };
+ getPath(filename: string, data: Record): string;
+};
+
+export type RscScriptAssetChunk = {
+ contentHash?: Record;
+ canBeInitial(): boolean;
+};
+
+const hasSome = (iterable: Iterable): boolean => {
+ for (const _ of iterable) {
+ return true;
+ }
+ return false;
+};
+
/**
* Browser JavaScript assets rspack's RSC manifest would drop: it only records
- * chunk files whose emitted name ends in ".js", so `.mjs`/`.cjs` names and
- * query-hash names (`[name].js?v=...`) vanish from `entryJsFiles` and the
- * client manifest.
+ * chunk files whose emitted name ends in ".js", so `.mjs` names, query-hash
+ * names (`[name].js?v=...`), or any other extension vanish from
+ * `entryJsFiles` and the client manifest. The emitted script name is derived
+ * from the chunk's own filename template (entry or async) the same way rspack
+ * emits it, so function templates and `tools.rspack` overrides are covered.
*/
-export const collectUnsupportedRscScriptAssets = (compilation: {
- chunks: Iterable<{ files?: Iterable }>;
-}): string[] => {
+export const collectUnsupportedRscScriptAssets = (
+ compilation: RscScriptAssetCompilation
+): string[] => {
const unsupported = new Set();
for (const chunk of compilation.chunks) {
- for (const file of chunk.files ?? []) {
- if (isManifestJsAsset(file) && !file.endsWith('.js')) {
- unsupported.add(file);
- }
+ const emitsJavaScript =
+ chunk.contentHash?.javascript !== undefined ||
+ hasSome(
+ compilation.chunkGraph.getChunkModulesIterableBySourceType(
+ chunk,
+ 'javascript'
+ )
+ );
+ if (!emitsJavaScript) {
+ continue;
+ }
+ const pathData = { chunk, contentHashType: 'javascript' };
+ const template = chunk.canBeInitial()
+ ? compilation.outputOptions.filename
+ : compilation.outputOptions.chunkFilename;
+ const resolvedTemplate =
+ typeof template === 'function' ? template(pathData) : template;
+ if (typeof resolvedTemplate !== 'string') {
+ continue;
+ }
+ const file = compilation.getPath(resolvedTemplate, pathData);
+ if (!file.endsWith('.js')) {
+ unsupported.add(file);
}
}
return [...unsupported];
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
index 1de1034e..5eb79168 100644
--- a/tests/manifest.test.ts
+++ b/tests/manifest.test.ts
@@ -616,19 +616,46 @@ describe('manifest', () => {
}
});
- it('lists browser scripts the rspack RSC manifest would drop', () => {
+ it('lists browser scripts the rspack RSC manifest would drop, by chunk metadata', () => {
+ const chunk = (
+ name: string,
+ { js = true, initial = true }: { js?: boolean; initial?: boolean } = {}
+ ) => ({
+ name,
+ contentHash: js ? { javascript: 'abc123' } : { 'css/mini-extract': 'def' },
+ canBeInitial: () => initial,
+ });
const compilation = {
chunks: [
- { files: new Set(['static/js/client-index.js?v=abc12345', 'static/css/index.css']) },
- { files: new Set(['static/js/async/757.mjs', 'static/js/async/757.js']) },
- { files: new Set(['static/js/ok.abc123.js', 'static/js/ok.js.map']) },
- { files: undefined },
+ chunk('client-index'),
+ chunk('styles-only', { js: false }),
+ chunk('757', { initial: false }),
+ chunk('758', { initial: false }),
],
+ chunkGraph: { getChunkModulesIterableBySourceType: () => [] },
+ outputOptions: {
+ // Entry template as a function (Rsbuild passes user functions through).
+ filename: (data: { chunk: { name: string } }) =>
+ `static/js/${data.chunk.name}.js?v=[contenthash:8]`,
+ chunkFilename: 'static/js/async/[name].txt',
+ },
+ getPath: (template: string, data: { chunk: { name: string } }) =>
+ template.replace('[name]', data.chunk.name).replace('[contenthash:8]', 'deadbeef'),
};
expect(collectUnsupportedRscScriptAssets(compilation)).toEqual([
- 'static/js/client-index.js?v=abc12345',
- 'static/js/async/757.mjs',
+ 'static/js/client-index.js?v=deadbeef',
+ 'static/js/async/757.txt',
+ 'static/js/async/758.txt',
]);
+
+ // Plain ".js" everywhere (hashed or not) is fine; an extension-less script
+ // is not JavaScript-looking at all and is still reported.
+ expect(
+ collectUnsupportedRscScriptAssets({
+ ...compilation,
+ outputOptions: { filename: '[contenthash:8]-[name].js', chunkFilename: 'async/[name]' },
+ })
+ ).toEqual(['async/757', 'async/758']);
});
it('fails the build instead of inventing a module path when a chunk has no script', async () => {
diff --git a/tests/output-config.integration.test.ts b/tests/output-config.integration.test.ts
index 69507fe7..1449eaab 100644
--- a/tests/output-config.integration.test.ts
+++ b/tests/output-config.integration.test.ts
@@ -189,6 +189,52 @@ describe('final Rspack output configuration (real Rsbuild)', () => {
expect(node.optimization?.splitChunks).toMatchObject({ chunks: 'async' });
});
+ it('keeps federation server splitting async-only past late overrides and presets', async () => {
+ const { node: overridden } = await inspect(
+ pluginReactRouter({ serverOutput: 'commonjs', federation: true }),
+ {
+ environments: {
+ node: {
+ // A user `tools.rspack` function runs after the plugin's defaults.
+ tools: {
+ rspack: config => {
+ config.optimization!.splitChunks = {
+ ...(config.optimization!.splitChunks as object),
+ chunks: 'all',
+ };
+ },
+ },
+ },
+ },
+ }
+ );
+ expect(overridden.optimization?.splitChunks).toMatchObject({ chunks: 'async' });
+
+ // Rsbuild's `single-vendor` preset adds an enforced cache group with
+ // `chunks: 'all'`, which Rspack prefers over the global filter.
+ const { node: preset } = await inspect(
+ pluginReactRouter({ serverOutput: 'commonjs', federation: true }),
+ { environments: { node: { splitChunks: { preset: 'single-vendor' } } } }
+ );
+ const splitChunks = preset.optimization?.splitChunks as {
+ chunks: unknown;
+ cacheGroups: Record;
+ };
+ expect(splitChunks.chunks).toBe('async');
+ const groups = Object.values(splitChunks.cacheGroups);
+ expect(groups.length).toBeGreaterThan(0);
+ for (const group of groups) {
+ if ('chunks' in group) expect(group.chunks).toBe('async');
+ }
+
+ // An explicitly disabled splitChunks stays disabled.
+ const { node: disabled } = await inspect(
+ pluginReactRouter({ serverOutput: 'commonjs', federation: true }),
+ { environments: { node: { splitChunks: false } } }
+ );
+ expect(disabled.optimization?.splitChunks).toBe(false);
+ });
+
it('keeps the shared browser runtime chunk without federation', async () => {
const { web, node } = await inspect(pluginReactRouter());
expect(web.optimization?.runtimeChunk).toBe('single');
diff --git a/tests/react-router-framework/integration/rsc-output-filename-test.ts b/tests/react-router-framework/integration/rsc-output-filename-test.ts
index 9e184d00..7394f5de 100644
--- a/tests/react-router-framework/integration/rsc-output-filename-test.ts
+++ b/tests/react-router-framework/integration/rsc-output-filename-test.ts
@@ -40,14 +40,17 @@ const rsbuildConfigFile = (webConfig: string) => `
const cases = [
{
- name: "query-hash entry filename",
- webConfig: `{ output: { filename: { js: "client-[name].js?v=[contenthash:8]" } } }`,
+ // A function filename: only the emitted output can reveal what it returns.
+ name: "query-hash entry filename returned by a filename function",
+ webConfig: `{ output: { filename: { js: (pathData) => "client-" + pathData.chunk.name + ".js?v=[contenthash:8]" } } }`,
dropped: /client-index\.js\?v=[a-f0-9]{8}/,
},
{
- name: ".mjs async chunkFilename through tools.rspack",
- webConfig: `{ tools: { rspack: (config) => { config.output.chunkFilename = "static/js/async/[name].mjs"; } } }`,
- dropped: /static\/js\/async\/[^"]+\.mjs/,
+ // A valid ".js" entry keeps `entryJsFiles` non-empty; the client-reference
+ // chunks are what disappear, under an extension no classifier would guess.
+ name: "unfamiliar async chunkFilename extension through tools.rspack",
+ webConfig: `{ tools: { rspack: (config) => { config.output.chunkFilename = "static/js/async/[name].txt"; } } }`,
+ dropped: /static\/js\/async\/[^"]+\.txt/,
},
];