diff --git a/.changeset/federation-async-startup.md b/.changeset/federation-async-startup.md new file mode 100644 index 00000000..5f37747b --- /dev/null +++ b/.changeset/federation-async-startup.md @@ -0,0 +1,30 @@ +--- +'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 (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 + 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: + 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/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..7d0ac6d5 100644 --- a/src/environment-output.ts +++ b/src/environment-output.ts @@ -1,5 +1,9 @@ import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core'; -import { ensureFederationAsyncStartup } from './federation.js'; +import { + enforceAsyncOnlyServerSplitChunks, + ensureFederationAsyncStartup, + isolateFederationContainerRuntime, +} from './federation.js'; /** * Rspack `output` policy for the web and node environments, in two tiers: @@ -70,6 +74,11 @@ export const registerReactRouterEnvironmentOutput = ({ rspack: rspackConfig => { if (federation) { ensureFederationAsyncStartup(rspackConfig); + if (name === 'web') { + isolateFederationContainerRuntime(rspackConfig); + } else { + enforceAsyncOnlyServerSplitChunks(rspackConfig); + } } if (name === 'node') { diff --git a/src/federation.ts b/src/federation.ts index 37ac5552..ce87ec42 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; } @@ -36,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/index.ts b/src/index.ts index 2fd0eaa7..6491e5b3 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,31 @@ 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), + }, + // `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` + ); + } + if (modePlan.kind === 'classic' && useRouteModuleTransformLoader) { api.modifyEnvironmentConfig( async (config, { name, mergeEnvironmentConfig }) => { diff --git a/src/manifest.ts b/src/manifest.ts index efeda03b..335bb8a5 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -179,6 +179,79 @@ 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` 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: RscScriptAssetCompilation +): string[] => { + const unsupported = new Set(); + for (const chunk of compilation.chunks) { + 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]; +}; + 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..5eb79168 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,48 @@ describe('manifest', () => { } }); + 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: [ + 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=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 () => { 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..1449eaab 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,101 @@ 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 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'); + 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..e49173d7 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,35 @@ 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"], + })] } }, + }, }, }); `, + // 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 { 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 +223,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 +255,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 +272,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 +289,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..7394f5de --- /dev/null +++ b/tests/react-router-framework/integration/rsc-output-filename-test.ts @@ -0,0 +1,83 @@ +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 = [ + { + // 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}/, + }, + { + // 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/, + }, +]; + +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" + ] }