rsbuild-plugin-react-router 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,9 +103,17 @@ pluginReactRouter({
103
103
  | `federation` | `false` | Enables the plugin's experimental Module Federation integration. |
104
104
 
105
105
  When `federation` is enabled, configure the Module Federation plugin with
106
- `experiments.asyncStartup: true`. The dev server resolves async server build
106
+ `experiments.asyncStartup: true` on every compiler (the plugin enforces it) and
107
+ keep shared dependencies non-eager. The dev server resolves async server build
107
108
  exports automatically; production custom servers or adapters should resolve
108
- async exports before passing the build to React Router's request handler.
109
+ async exports before passing the build to React Router's request handler
110
+ (`resolveReactRouterServerBuild`). Give remote containers an explicit
111
+ `filename` (for example `static/js/remote.js`); every other browser chunk keeps
112
+ Rsbuild's content hash. Under the hood the plugin gives each container its own
113
+ runtime chunk (so importing a container does not start the app's own share
114
+ consumes), makes browser route-module entries async so their exports resolve
115
+ through the async startup, and keeps server code splitting async-only so the
116
+ `@module-federation/node` chunk loader can satisfy the server build's startup.
109
117
 
110
118
  ### React Router Config
111
119
 
@@ -365,6 +373,16 @@ content-hashed filenames, and `output.filename`, `output.filenameHash`,
365
373
  `output.distPath`, and `tools.rspack` output settings you configure govern
366
374
  the emitted files and the manifest URLs that reference them.
367
375
 
376
+ Supported browser filename forms differ by mode:
377
+
378
+ - **Classic mode** accepts any scheme, including `.mjs`/`.cjs` and query-hash
379
+ names such as `[name].js?v=[contenthash:8]`; the browser manifest classifies
380
+ emitted assets by pathname and keeps the full reference.
381
+ - **RSC mode** requires every browser JavaScript asset to be named `*.js`
382
+ (hashes are fine, e.g. `[contenthash:8]-[name].js`): rspack's RSC manifest
383
+ only records `.js` files, so the build fails with a clear error for query
384
+ hashes or other extensions.
385
+
368
386
  ## Custom Server Setup
369
387
 
370
388
  The plugin supports two ways to handle server-side rendering:
@@ -1,11 +1,13 @@
1
1
  import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
2
2
  import type { ServerBuild } from 'react-router';
3
- import { type DevGraphChanges, type DevGraphIdentity, type DevRuntimeStats, type ReactRouterDevBuildPlan, type ReactRouterDevManifestSet } from './dev-runtime-artifacts.js';
3
+ import { type DevCompilationIdentity, type DevGraphChanges, type DevGraphIdentity, type DevRuntimeStats, type ReactRouterDevBuildPlan, type ReactRouterDevManifestSet } from './dev-runtime-artifacts.js';
4
4
  export type { DevGraphChanges, DevGraphIdentity, ReactRouterDevManifest, } from './dev-runtime-artifacts.js';
5
5
  export type ReactRouterDevRuntime = {
6
6
  beginAttempt: () => void;
7
7
  captureWeb: (compilation: Rspack.Compilation, manifestsByEntryName: ReactRouterDevManifestSet) => void;
8
8
  finishAttempt: (stats: DevRuntimeStats, changes: DevGraphChanges, identity: DevGraphIdentity) => Promise<'committed' | 'ignored' | 'retry-node'>;
9
+ /** Node identity actually retained by the last successful generation. */
10
+ getCommittedNodeIdentity: () => DevCompilationIdentity | undefined;
9
11
  failAttempt: (error: Error) => void;
10
12
  load: (entryName?: string) => Promise<ServerBuild>;
11
13
  close: (error?: Error) => void;
@@ -0,0 +1,5 @@
1
+ /** Tracks unsignaled node edits across compiler retries within one dev session. */
2
+ export declare const createDevHdrIntentTracker: () => {
3
+ capture(compilation: object, hasRelevantChanges: boolean): void;
4
+ signalCommitted(compilation: object, signal: () => void): void;
5
+ };
@@ -1,2 +1,28 @@
1
1
  import type { Rspack } from '@rsbuild/core';
2
+ /**
3
+ * The Module Federation container name(s) configured on this compiler, i.e.
4
+ * the entry names of the remote containers it emits.
5
+ */
6
+ export declare const getFederationContainerNames: (rspackConfig: Rspack.Configuration | undefined) => string[];
7
+ /**
8
+ * Classic mode shares one runtime chunk across every browser entry so route
9
+ * module entries share a module registry. A federation container must not
10
+ * share it: importing the container would run the app entries' async startup
11
+ * (share-scope consumes) before the host has initialized the share scope,
12
+ * yielding duplicate singletons (a second React). Give containers their own
13
+ * runtime chunk.
14
+ */
15
+ export declare const isolateFederationContainerRuntime: (rspackConfig: Rspack.Configuration | undefined) => void;
2
16
  export declare const ensureFederationAsyncStartup: (rspackConfig: Rspack.Configuration | undefined) => void;
17
+ /**
18
+ * `@module-federation/node` replaces Rspack's `readFileVm` chunk loader with one
19
+ * that tracks loaded chunks privately, so initial chunks split off a
20
+ * multi-entry server build never satisfy Rspack's startup gate
21
+ * (`__webpack_require__.O`) and the async startup resolves to `undefined`
22
+ * instead of the server build's exports. Keep server code splitting to async
23
+ * chunks only. Runs at the final `tools.rspack` boundary so a user
24
+ * `optimization.splitChunks` override or a preset whose cache group selects
25
+ * `chunks: 'all'` (e.g. Rsbuild's `single-vendor`, `enforce: true`) cannot
26
+ * reintroduce initial chunk dependencies. A disabled `splitChunks` is kept.
27
+ */
28
+ export declare const enforceAsyncOnlyServerSplitChunks: (rspackConfig: Rspack.Configuration | undefined) => void;
package/dist/index.cjs CHANGED
@@ -617,15 +617,29 @@ const resolveEntryWithTemplate = ({ appDirectory, entryName, templateName, templ
617
617
  hasServerApp,
618
618
  serverAppPath
619
619
  };
620
+ }, getModuleFederationOptions = (plugin)=>{
621
+ if (plugin && 'object' == typeof plugin && ('ModuleFederationPlugin' === plugin.name || 'RspackModuleFederationPlugin' === plugin.name)) return plugin._options ?? plugin.options;
622
+ }, getFederationContainerNames = (rspackConfig)=>(rspackConfig?.plugins ?? []).map(getModuleFederationOptions).map((options)=>options?.name).filter((name)=>'string' == typeof name), isolateFederationContainerRuntime = (rspackConfig)=>{
623
+ let containers = new Set(getFederationContainerNames(rspackConfig));
624
+ if (!rspackConfig || 0 === containers.size) return;
625
+ let current = rspackConfig.optimization?.runtimeChunk, appRuntimeName = 'object' == typeof current && 'string' == typeof current?.name ? current.name : 'runtime';
626
+ rspackConfig.optimization = {
627
+ ...rspackConfig.optimization,
628
+ runtimeChunk: {
629
+ name: (entrypoint)=>containers.has(entrypoint.name) ? `runtime-${entrypoint.name}` : appRuntimeName
630
+ }
631
+ };
620
632
  }, ensureFederationAsyncStartup = (rspackConfig)=>{
621
633
  if (rspackConfig?.plugins?.length) for (let plugin of rspackConfig.plugins){
622
- if (!plugin || 'object' != typeof plugin || 'ModuleFederationPlugin' !== plugin.name && 'RspackModuleFederationPlugin' !== plugin.name) continue;
623
- let pluginOptions = plugin._options ?? plugin.options;
634
+ let pluginOptions = getModuleFederationOptions(plugin);
624
635
  pluginOptions && (pluginOptions.experiments = {
625
636
  ...pluginOptions.experiments,
626
637
  asyncStartup: !0
627
638
  });
628
639
  }
640
+ }, enforceAsyncOnlyServerSplitChunks = (rspackConfig)=>{
641
+ let splitChunks = rspackConfig?.optimization?.splitChunks;
642
+ if (splitChunks) for (let group of (splitChunks.chunks = 'async', Object.values(splitChunks.cacheGroups ?? {})))group && 'object' == typeof group && 'chunks' in group && (group.chunks = 'async');
629
643
  }, registerReactRouterEnvironmentOutput = ({ api, federation, resolvedServerOutput, webOutput })=>{
630
644
  let nodeChunkLoading = 'module' === resolvedServerOutput ? 'import' : federation ? 'async-node' : 'require';
631
645
  api.modifyRspackConfig((rspackConfig, { environment, mergeConfig })=>'web' === environment.name ? mergeConfig(rspackConfig, {
@@ -649,7 +663,7 @@ const resolveEntryWithTemplate = ({ appDirectory, entryName, templateName, templ
649
663
  }) : rspackConfig), api.modifyEnvironmentConfig(async (config, { name, mergeEnvironmentConfig })=>'web' !== name && 'node' !== name ? config : mergeEnvironmentConfig(config, {
650
664
  tools: {
651
665
  rspack: (rspackConfig)=>{
652
- if (federation && ensureFederationAsyncStartup(rspackConfig), 'node' === name) {
666
+ if (federation && (ensureFederationAsyncStartup(rspackConfig), 'web' === name ? isolateFederationContainerRuntime(rspackConfig) : enforceAsyncOnlyServerSplitChunks(rspackConfig)), 'node' === name) {
653
667
  let output = rspackConfig.output;
654
668
  if (output) {
655
669
  let library = output.library, libraryOptions = library && 'object' == typeof library && !Array.isArray(library) ? library : {};
@@ -1470,7 +1484,7 @@ const getCurrentVersion = ()=>moduleVersion, setCurrentVersion = (version)=>{
1470
1484
  }), findLast = null, zip = null, Iterable_zipWith = null, intersperse = null, Iterable_containsWith = (isEquivalent)=>dual(2, (self, a)=>{
1471
1485
  for (let i of self)if (isEquivalent(a, i)) return !0;
1472
1486
  return !1;
1473
- }), Iterable_equivalence = null, Iterable_contains = null, chunksOf = null, groupWith = null, group = null, groupBy = null, constEmpty = {
1487
+ }), Iterable_equivalence = null, Iterable_contains = null, chunksOf = null, groupWith = null, Iterable_group = null, groupBy = null, constEmpty = {
1474
1488
  [Symbol.iterator]: ()=>constEmptyIterator
1475
1489
  }, constEmptyIterator = {
1476
1490
  next: ()=>({
@@ -11816,7 +11830,25 @@ const createReactRouterManifestOptions = ({ routeChunks, routeModuleAnalysis })=
11816
11830
  routeModuleAnalysis
11817
11831
  } : {}
11818
11832
  };
11819
- }, isManifestJsAsset = (asset)=>/(?<!\.hot-update)\.[cm]?js(?:\?.*)?$/.test(asset), isManifestCssAsset = (asset)=>/\.css(?:\?.*)?$/.test(asset), collectManifestFilesByName = (items, names, getFiles)=>{
11833
+ }, isManifestJsAsset = (asset)=>/(?<!\.hot-update)\.[cm]?js(?:\?.*)?$/.test(asset), isManifestCssAsset = (asset)=>/\.css(?:\?.*)?$/.test(asset), hasSome = (iterable)=>{
11834
+ for (let _ of iterable)return !0;
11835
+ return !1;
11836
+ }, collectUnsupportedRscScriptAssets = (compilation)=>{
11837
+ let unsupported = new Set();
11838
+ for (let chunk of compilation.chunks){
11839
+ if (!(chunk.contentHash?.javascript !== void 0 || hasSome(compilation.chunkGraph.getChunkModulesIterableBySourceType(chunk, "javascript")))) continue;
11840
+ let pathData = {
11841
+ chunk,
11842
+ contentHashType: "javascript"
11843
+ }, template = chunk.canBeInitial() ? compilation.outputOptions.filename : compilation.outputOptions.chunkFilename, resolvedTemplate = 'function' == typeof template ? template(pathData) : template;
11844
+ if ('string' != typeof resolvedTemplate) continue;
11845
+ let file = compilation.getPath(resolvedTemplate, pathData);
11846
+ file.endsWith('.js') || unsupported.add(file);
11847
+ }
11848
+ return [
11849
+ ...unsupported
11850
+ ];
11851
+ }, collectManifestFilesByName = (items, names, getFiles)=>{
11820
11852
  let filesByName = {};
11821
11853
  if (!names) {
11822
11854
  for (let [name, item] of items)null != item && (filesByName[name] = getFiles(name, item));
@@ -14758,11 +14790,19 @@ export {
14758
14790
  'virtual/react-router/unstable_rsc/manifest-prefix': `const manifest = __webpack_require__.rscM;
14759
14791
  const serverPrefix = ${JSON.stringify(serverPublicPath)};
14760
14792
  const appliedPrefix = manifest?.moduleLoading?.prefix;
14761
- if (appliedPrefix && appliedPrefix !== serverPrefix) {
14793
+ // An empty applied prefix (web \`assetPrefix: ''\`) yields relative references
14794
+ // such as "static/js/index.js"; those are rebased too, while absolute and
14795
+ // protocol-relative URLs are left alone.
14796
+ const isAbsoluteUrl = url => /^(?:[a-z][a-z\\d+.-]*:|\\/\\/|\\/)/i.test(url);
14797
+ if (typeof appliedPrefix === "string" && appliedPrefix !== serverPrefix) {
14762
14798
  const rewrite = url =>
14763
- typeof url === "string" && url.startsWith(appliedPrefix)
14764
- ? serverPrefix + url.slice(appliedPrefix.length)
14765
- : url;
14799
+ typeof url !== "string"
14800
+ ? url
14801
+ : appliedPrefix !== "" && url.startsWith(appliedPrefix)
14802
+ ? serverPrefix + url.slice(appliedPrefix.length)
14803
+ : appliedPrefix === "" && !isAbsoluteUrl(url)
14804
+ ? serverPrefix + url
14805
+ : url;
14766
14806
  const rewriteAll = list => {
14767
14807
  if (Array.isArray(list)) for (let i = 0; i < list.length; i++) list[i] = rewrite(list[i]);
14768
14808
  };
@@ -15206,6 +15246,7 @@ export default entryJsFiles;
15206
15246
  return rejectAttempt(attemptId, normalizeEffectError(cause), !0), 'ignored';
15207
15247
  }
15208
15248
  },
15249
+ getCommittedNodeIdentity: ()=>'ready' === state.kind ? state.committed.nodeIdentity : void 0,
15209
15250
  failAttempt (error) {
15210
15251
  let attemptId = getCurrentAttemptId();
15211
15252
  null !== attemptId && rejectAttempt(attemptId, error, !1);
@@ -15239,6 +15280,17 @@ export default entryJsFiles;
15239
15280
  }, loadReactRouterServerBuild = (server, entryName)=>{
15240
15281
  let runtime = Reflect.get(server, DEV_RUNTIME_KEY);
15241
15282
  return runtime ? runtime.load(entryName) : Promise.reject(Error('[rsbuild-plugin-react-router] This Rsbuild development server is not registered with the React Router plugin. Add pluginReactRouter() before calling loadReactRouterServerBuild().'));
15283
+ }, createDevHdrIntentTracker = ()=>{
15284
+ let latestRelevantEditRevision = 0, signaledRevision = 0, revisionByCompilation = new WeakMap();
15285
+ return {
15286
+ capture (compilation, hasRelevantChanges) {
15287
+ hasRelevantChanges && (latestRelevantEditRevision += 1), revisionByCompilation.set(compilation, latestRelevantEditRevision);
15288
+ },
15289
+ signalCommitted (compilation, signal) {
15290
+ let compilationRevision = revisionByCompilation.get(compilation);
15291
+ void 0 === compilationRevision || compilationRevision <= signaledRevision || (signal(), signaledRevision = Math.max(signaledRevision, compilationRevision));
15292
+ }
15293
+ };
15242
15294
  }, createDevRuntimeSessionManager = (closeBinding)=>{
15243
15295
  let state = {
15244
15296
  status: 'idle'
@@ -15344,12 +15396,15 @@ export default entryJsFiles;
15344
15396
  scheduledCssAssetOwnershipReload && (clearTimeout(scheduledCssAssetOwnershipReload), scheduledCssAssetOwnershipReload = void 0), reloadAfterCssAssetOwnershipRemoval = !1;
15345
15397
  let pair = binding.compilers;
15346
15398
  pair && resetDevCompilerPair(pair), binding.compilers = void 0, binding.runtime.close(error), unregisterReactRouterDevRuntime(binding.server, binding.runtime);
15347
- }, sessions = createDevRuntimeSessionManager(closeBinding), compilationIdentities = createCompilationIdentityTracker(), { getCompilationIdentity } = compilationIdentities, hdrSignaledNodeIdentity = new WeakMap(), finishRuntimeAttempt = async (binding, pair, stats, changes, identity1)=>{
15399
+ }, sessions = createDevRuntimeSessionManager(closeBinding), compilationIdentities = createCompilationIdentityTracker(), { getCompilationIdentity } = compilationIdentities, hdrIntentsByPair = new WeakMap(), finishRuntimeAttempt = async (binding, pair, stats, changes, identity1)=>{
15348
15400
  try {
15349
15401
  let result = await binding.runtime.finishAttempt(stats, changes, identity1);
15350
15402
  if (sessions.getActiveBinding()?.id !== binding.id) return;
15351
15403
  if ('retry-node' === result) return void pair.node.watching?.invalidate();
15352
- 'committed' === result && changes.node.known && void 0 !== identity1.node && hdrSignaledNodeIdentity.get(pair) !== identity1.node && Array.from(changes.node.files).some((file)=>!file.includes('.react-router/hdr-revision.mjs') && !/\.css(?:\.[cm]?[jt]s)?$/.test(file)) && (hdrSignaledNodeIdentity.set(pair, identity1.node), onNodeRebuildCommitted?.());
15404
+ let nodeCompilation = getEnvironmentStats(stats, 'node')?.compilation;
15405
+ 'committed' === result && nodeCompilation && identity1.node === binding.runtime.getCommittedNodeIdentity() && hdrIntentsByPair.get(pair)?.signalCommitted(nodeCompilation, ()=>{
15406
+ onNodeRebuildCommitted?.();
15407
+ });
15353
15408
  } catch (cause) {
15354
15409
  sessions.getActiveBinding()?.id === binding.id && binding.runtime.failAttempt(normalizeEffectError(cause));
15355
15410
  }
@@ -15436,6 +15491,8 @@ export default entryJsFiles;
15436
15491
  node
15437
15492
  });
15438
15493
  binding.compilers = pair;
15494
+ let hdrIntents = createDevHdrIntentTracker();
15495
+ hdrIntentsByPair.set(pair, hdrIntents);
15439
15496
  let sessionId = binding.id, runtime = binding.runtime, failCurrentAttempt = (side, error)=>{
15440
15497
  sessions.getActiveBinding()?.id === sessionId && ('web' === side ? clearDevCompilerStart(pair, 'latestWebStart') : clearDevCompilerStart(pair, 'latestNodeStart'), runtime.failAttempt(error));
15441
15498
  }, beginCompilerAttempt = (side)=>{
@@ -15457,10 +15514,12 @@ export default entryJsFiles;
15457
15514
  identity: getCompilationIdentity(compilation)
15458
15515
  }, reloadAfterCssAssetOwnershipRemoval && (reloadAfterCssAssetOwnershipRemoval = !1, scheduleCssAssetOwnershipReload()));
15459
15516
  }), node.hooks.thisCompilation.tap(`${PLUGIN_NAME}:dev-node-web-compilation`, (compilation)=>{
15460
- sessions.getActiveBinding()?.id === sessionId && (pair.latestNodeStart = {
15517
+ if (sessions.getActiveBinding()?.id !== sessionId) return;
15518
+ let changes = snapshotDevChangedFiles(pair.node);
15519
+ hdrIntents.capture(compilation, changes.known && Array.from(changes.files).some((file)=>!file.includes('.react-router/hdr-revision.mjs') && !/\.css(?:\.[cm]?[jt]s)?$/.test(file))), pair.latestNodeStart = {
15461
15520
  status: 'started',
15462
15521
  identity: getCompilationIdentity(compilation)
15463
- }, pair.currentAttemptIdentity && compilationIdentities.setAttemptIdentityForCompilation(compilation, pair.currentAttemptIdentity), pair.latestCompletedWebIdentity && compilationIdentities.setWebIdentityForNodeCompilation(compilation, pair.latestCompletedWebIdentity));
15522
+ }, pair.currentAttemptIdentity && compilationIdentities.setAttemptIdentityForCompilation(compilation, pair.currentAttemptIdentity), pair.latestCompletedWebIdentity && compilationIdentities.setWebIdentityForNodeCompilation(compilation, pair.latestCompletedWebIdentity);
15464
15523
  });
15465
15524
  let settleCompilation = (stats)=>{
15466
15525
  sessions.getActiveBinding()?.id === sessionId && (pair.settledCompilations.add(stats.compilation), flushSettledAttempt(binding, pair));
@@ -16084,8 +16143,12 @@ export default entryJsFiles;
16084
16143
  initialRouteTopology: routeTopology.initialRouteTopology,
16085
16144
  onRouteTopologyChange: pluginOptions.onRouteTopologyChange
16086
16145
  });
16087
- api.onAfterEnvironmentCompile(({ stats, environment })=>{
16088
- if ('web' === environment.name && (clientStats = createReactRouterManifestStats(stats?.compilation, manifestChunkNames)), pluginOptions.federation && ssr) {
16146
+ if (api.onAfterEnvironmentCompile(({ stats, environment })=>{
16147
+ if ('web' === environment.name && (clientStats = createReactRouterManifestStats(stats?.compilation, manifestChunkNames), isRscMode && stats)) {
16148
+ let unsupported = collectUnsupportedRscScriptAssets(stats.compilation);
16149
+ if (unsupported.length > 0) throw 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\`.`);
16150
+ }
16151
+ if (pluginOptions.federation && ssr) {
16089
16152
  let serverBuildDir = (0, external_pathe_namespaceObject.resolve)(buildDirectory, 'server'), clientBuildDir = (0, external_pathe_namespaceObject.resolve)(buildDirectory, 'client');
16090
16153
  if ((0, external_node_fs_namespaceObject.existsSync)(serverBuildDir)) {
16091
16154
  let ssrDir = (0, external_pathe_namespaceObject.resolve)(clientBuildDir, 'static');
@@ -16128,9 +16191,7 @@ export default entryJsFiles;
16128
16191
  prerenderPaths: modePlan.prerenderPaths,
16129
16192
  basename
16130
16193
  })))), api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig })=>{
16131
- let publicPath, webConfig = config.environments?.web, webJsFilename = webConfig?.output?.filename?.js ?? config.output?.filename?.js;
16132
- if (isRscMode && 'string' == typeof webJsFilename && !/\.js$/.test(webJsFilename)) throw 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.`);
16133
- let vmodPlugin = (publicPath = resolveEffectiveAssetPrefix({
16194
+ let publicPath, webConfig = config.environments?.web, vmodPlugin = (publicPath = resolveEffectiveAssetPrefix({
16134
16195
  dev: webConfig?.dev,
16135
16196
  output: webConfig?.output,
16136
16197
  isBuild
@@ -16257,7 +16318,20 @@ export default entryJsFiles;
16257
16318
  federation: pluginOptions.federation,
16258
16319
  resolvedServerOutput,
16259
16320
  webOutput: modePlan.webOutput
16260
- }), 'classic' === modePlan.kind && useRouteModuleTransformLoader && api.modifyEnvironmentConfig(async (config, { name, mergeEnvironmentConfig })=>'web' !== name && 'node' !== name ? config : mergeEnvironmentConfig(config, {
16321
+ }), pluginOptions.federation && 'classic' === modePlan.kind) {
16322
+ let browserEntryModules = new Set([
16323
+ finalEntryClientPath,
16324
+ ...routeByFilePath.keys()
16325
+ ]);
16326
+ api.transform({
16327
+ environments: [
16328
+ 'web'
16329
+ ],
16330
+ order: 'post',
16331
+ test: (resourcePath)=>browserEntryModules.has(resourcePath)
16332
+ }, ({ code })=>`${code}\nexport {};\nawait Promise.resolve();\n`);
16333
+ }
16334
+ 'classic' === modePlan.kind && useRouteModuleTransformLoader && api.modifyEnvironmentConfig(async (config, { name, mergeEnvironmentConfig })=>'web' !== name && 'node' !== name ? config : mergeEnvironmentConfig(config, {
16261
16335
  tools: {
16262
16336
  rspack: (rspackConfig)=>{
16263
16337
  let environmentDevHmrEnabled = 'web' === name && !isBuild && void 0 !== devHmrRefreshRuntimePath && 'development' === config.mode && config.dev?.hmr !== !1 && isRspackSwcReactRefreshEnabled(rspackConfig);
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@ let getAvailableCpuCount = ()=>'function' == typeof availableParallelism ? avail
38
38
  }, resolveEntryWithTemplate = ({ appDirectory, entryName, templateName, templatesDirectory })=>{
39
39
  let userEntryPath = findEntryFile(external_pathe_resolve(appDirectory, entryName));
40
40
  return existsSync(userEntryPath) ? userEntryPath : external_pathe_resolve(templatesDirectory, templateName);
41
+ }, getModuleFederationOptions = (plugin)=>{
42
+ if (plugin && 'object' == typeof plugin && ('ModuleFederationPlugin' === plugin.name || 'RspackModuleFederationPlugin' === plugin.name)) return plugin._options ?? plugin.options;
41
43
  }, Function_dual = function(arity, body) {
42
44
  if ("function" == typeof arity) return function() {
43
45
  return arity(arguments) ? body.apply(this, arguments) : (self)=>body(self, ...arguments);
@@ -7356,7 +7358,10 @@ let createReactRouterManifestOptions = ({ routeChunks, routeModuleAnalysis })=>{
7356
7358
  routeModuleAnalysis
7357
7359
  } : {}
7358
7360
  };
7359
- }, isManifestJsAsset = (asset)=>/(?<!\.hot-update)\.[cm]?js(?:\?.*)?$/.test(asset), isManifestCssAsset = (asset)=>/\.css(?:\?.*)?$/.test(asset), collectManifestFilesByName = (items, names, getFiles)=>{
7361
+ }, isManifestJsAsset = (asset)=>/(?<!\.hot-update)\.[cm]?js(?:\?.*)?$/.test(asset), isManifestCssAsset = (asset)=>/\.css(?:\?.*)?$/.test(asset), hasSome = (iterable)=>{
7362
+ for (let _ of iterable)return !0;
7363
+ return !1;
7364
+ }, collectManifestFilesByName = (items, names, getFiles)=>{
7360
7365
  let filesByName = {};
7361
7366
  if (!names) {
7362
7367
  for (let [name, item] of items)null != item && (filesByName[name] = getFiles(name, item));
@@ -9134,12 +9139,15 @@ export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRoute
9134
9139
  setWebIdentityForNodeCompilation (compilation, identity) {
9135
9140
  webIdentityByNodeCompilation.set(compilation, identity);
9136
9141
  }
9137
- }), { getCompilationIdentity } = compilationIdentities, hdrSignaledNodeIdentity = new WeakMap(), finishRuntimeAttempt = async (binding, pair, stats, changes, identity)=>{
9142
+ }), { getCompilationIdentity } = compilationIdentities, hdrIntentsByPair = new WeakMap(), finishRuntimeAttempt = async (binding, pair, stats, changes, identity)=>{
9138
9143
  try {
9139
9144
  let result = await binding.runtime.finishAttempt(stats, changes, identity);
9140
9145
  if (sessions.getActiveBinding()?.id !== binding.id) return;
9141
9146
  if ('retry-node' === result) return void pair.node.watching?.invalidate();
9142
- 'committed' === result && changes.node.known && void 0 !== identity.node && hdrSignaledNodeIdentity.get(pair) !== identity.node && Array.from(changes.node.files).some((file)=>!file.includes('.react-router/hdr-revision.mjs') && !/\.css(?:\.[cm]?[jt]s)?$/.test(file)) && (hdrSignaledNodeIdentity.set(pair, identity.node), onNodeRebuildCommitted?.());
9147
+ let nodeCompilation = getEnvironmentStats(stats, 'node')?.compilation;
9148
+ 'committed' === result && nodeCompilation && identity.node === binding.runtime.getCommittedNodeIdentity() && hdrIntentsByPair.get(pair)?.signalCommitted(nodeCompilation, ()=>{
9149
+ onNodeRebuildCommitted?.();
9150
+ });
9143
9151
  } catch (cause) {
9144
9152
  sessions.getActiveBinding()?.id === binding.id && binding.runtime.failAttempt(normalizeEffectError(cause));
9145
9153
  }
@@ -9329,6 +9337,7 @@ export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRoute
9329
9337
  return rejectAttempt(attemptId, normalizeEffectError(cause), !0), 'ignored';
9330
9338
  }
9331
9339
  },
9340
+ getCommittedNodeIdentity: ()=>'ready' === state.kind ? state.committed.nodeIdentity : void 0,
9332
9341
  failAttempt (error) {
9333
9342
  let attemptId = getCurrentAttemptId();
9334
9343
  null !== attemptId && rejectAttempt(attemptId, error, !1);
@@ -9394,6 +9403,7 @@ export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRoute
9394
9403
  !(!binding || !pair || hasPendingCompilation(pair)) && (pair.pendingAttempt = void 0, pair.currentAttemptIdentity = Symbol(), binding.runtime.beginAttempt());
9395
9404
  }
9396
9405
  }), api.onAfterCreateCompiler(({ compiler })=>{
9406
+ let latestRelevantEditRevision, signaledRevision, revisionByCompilation;
9397
9407
  if (!('compilers' in compiler)) return void rejectUnsupportedCompiler('Rsbuild did not create a multi-compiler');
9398
9408
  let web = compiler.compilers.find((item)=>'web' === item.name), node = compiler.compilers.find((item)=>'node' === item.name);
9399
9409
  if (!web || !node) return void rejectUnsupportedCompiler('the web or node compiler was missing');
@@ -9408,6 +9418,16 @@ export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRoute
9408
9418
  node
9409
9419
  });
9410
9420
  binding.compilers = pair;
9421
+ let hdrIntents = (latestRelevantEditRevision = 0, signaledRevision = 0, revisionByCompilation = new WeakMap(), {
9422
+ capture (compilation, hasRelevantChanges) {
9423
+ hasRelevantChanges && (latestRelevantEditRevision += 1), revisionByCompilation.set(compilation, latestRelevantEditRevision);
9424
+ },
9425
+ signalCommitted (compilation, signal) {
9426
+ let compilationRevision = revisionByCompilation.get(compilation);
9427
+ void 0 === compilationRevision || compilationRevision <= signaledRevision || (signal(), signaledRevision = Math.max(signaledRevision, compilationRevision));
9428
+ }
9429
+ });
9430
+ hdrIntentsByPair.set(pair, hdrIntents);
9411
9431
  let sessionId = binding.id, runtime = binding.runtime, failCurrentAttempt = (side, error)=>{
9412
9432
  sessions.getActiveBinding()?.id === sessionId && ('web' === side ? clearDevCompilerStart(pair, 'latestWebStart') : clearDevCompilerStart(pair, 'latestNodeStart'), runtime.failAttempt(error));
9413
9433
  }, beginCompilerAttempt = (side)=>{
@@ -9434,10 +9454,12 @@ export function EnsureClientRouteModuleForHMR___() { return ___EnsureClientRoute
9434
9454
  identity: getCompilationIdentity(compilation)
9435
9455
  }, reloadAfterCssAssetOwnershipRemoval && (reloadAfterCssAssetOwnershipRemoval = !1, scheduleCssAssetOwnershipReload()));
9436
9456
  }), node.hooks.thisCompilation.tap(`${PLUGIN_NAME}:dev-node-web-compilation`, (compilation)=>{
9437
- sessions.getActiveBinding()?.id === sessionId && (pair.latestNodeStart = {
9457
+ if (sessions.getActiveBinding()?.id !== sessionId) return;
9458
+ let changes = snapshotDevChangedFiles(pair.node);
9459
+ hdrIntents.capture(compilation, changes.known && Array.from(changes.files).some((file)=>!file.includes('.react-router/hdr-revision.mjs') && !/\.css(?:\.[cm]?[jt]s)?$/.test(file))), pair.latestNodeStart = {
9438
9460
  status: 'started',
9439
9461
  identity: getCompilationIdentity(compilation)
9440
- }, pair.currentAttemptIdentity && compilationIdentities.setAttemptIdentityForCompilation(compilation, pair.currentAttemptIdentity), pair.latestCompletedWebIdentity && compilationIdentities.setWebIdentityForNodeCompilation(compilation, pair.latestCompletedWebIdentity));
9462
+ }, pair.currentAttemptIdentity && compilationIdentities.setAttemptIdentityForCompilation(compilation, pair.currentAttemptIdentity), pair.latestCompletedWebIdentity && compilationIdentities.setWebIdentityForNodeCompilation(compilation, pair.latestCompletedWebIdentity);
9441
9463
  });
9442
9464
  let settleCompilation = (stats)=>{
9443
9465
  sessions.getActiveBinding()?.id === sessionId && (pair.settledCompilations.add(stats.compilation), flushSettledAttempt(binding, pair));
@@ -9623,11 +9645,19 @@ export default [`;
9623
9645
  'virtual/react-router/unstable_rsc/manifest-prefix': `const manifest = __webpack_require__.rscM;
9624
9646
  const serverPrefix = ${JSON.stringify(serverPublicPath)};
9625
9647
  const appliedPrefix = manifest?.moduleLoading?.prefix;
9626
- if (appliedPrefix && appliedPrefix !== serverPrefix) {
9648
+ // An empty applied prefix (web \`assetPrefix: ''\`) yields relative references
9649
+ // such as "static/js/index.js"; those are rebased too, while absolute and
9650
+ // protocol-relative URLs are left alone.
9651
+ const isAbsoluteUrl = url => /^(?:[a-z][a-z\\d+.-]*:|\\/\\/|\\/)/i.test(url);
9652
+ if (typeof appliedPrefix === "string" && appliedPrefix !== serverPrefix) {
9627
9653
  const rewrite = url =>
9628
- typeof url === "string" && url.startsWith(appliedPrefix)
9629
- ? serverPrefix + url.slice(appliedPrefix.length)
9630
- : url;
9654
+ typeof url !== "string"
9655
+ ? url
9656
+ : appliedPrefix !== "" && url.startsWith(appliedPrefix)
9657
+ ? serverPrefix + url.slice(appliedPrefix.length)
9658
+ : appliedPrefix === "" && !isAbsoluteUrl(url)
9659
+ ? serverPrefix + url
9660
+ : url;
9631
9661
  const rewriteAll = list => {
9632
9662
  if (Array.isArray(list)) for (let i = 0; i < list.length; i++) list[i] = rewrite(list[i]);
9633
9663
  };
@@ -10907,8 +10937,27 @@ if (typeof window !== 'undefined' && import.meta.webpackHot) {
10907
10937
  initialRouteTopology: routeTopology.initialRouteTopology,
10908
10938
  onRouteTopologyChange: pluginOptions.onRouteTopologyChange
10909
10939
  });
10910
- api.onAfterEnvironmentCompile(({ stats, environment })=>{
10911
- if ('web' === environment.name && (clientStats = createReactRouterManifestStats(stats?.compilation, manifestChunkNames)), pluginOptions.federation && ssr) {
10940
+ if (api.onAfterEnvironmentCompile(({ stats, environment })=>{
10941
+ if ('web' === environment.name && (clientStats = createReactRouterManifestStats(stats?.compilation, manifestChunkNames), isRscMode && stats)) {
10942
+ let unsupported = ((compilation)=>{
10943
+ let unsupported = new Set();
10944
+ for (let chunk of compilation.chunks){
10945
+ if (!(chunk.contentHash?.javascript !== void 0 || hasSome(compilation.chunkGraph.getChunkModulesIterableBySourceType(chunk, "javascript")))) continue;
10946
+ let pathData = {
10947
+ chunk,
10948
+ contentHashType: "javascript"
10949
+ }, template = chunk.canBeInitial() ? compilation.outputOptions.filename : compilation.outputOptions.chunkFilename, resolvedTemplate = 'function' == typeof template ? template(pathData) : template;
10950
+ if ('string' != typeof resolvedTemplate) continue;
10951
+ let file = compilation.getPath(resolvedTemplate, pathData);
10952
+ file.endsWith('.js') || unsupported.add(file);
10953
+ }
10954
+ return [
10955
+ ...unsupported
10956
+ ];
10957
+ })(stats.compilation);
10958
+ if (unsupported.length > 0) throw 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\`.`);
10959
+ }
10960
+ if (pluginOptions.federation && ssr) {
10912
10961
  let serverBuildDir = external_pathe_resolve(buildDirectory, 'server'), clientBuildDir = external_pathe_resolve(buildDirectory, 'client');
10913
10962
  if (existsSync(serverBuildDir)) {
10914
10963
  let ssrDir = external_pathe_resolve(clientBuildDir, 'static');
@@ -10952,9 +11001,7 @@ if (typeof window !== 'undefined' && import.meta.webpackHot) {
10952
11001
  basename
10953
11002
  })))), api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig })=>{
10954
11003
  var existing;
10955
- let publicPath, webConfig = config.environments?.web, webJsFilename = webConfig?.output?.filename?.js ?? config.output?.filename?.js;
10956
- if (isRscMode && 'string' == typeof webJsFilename && !/\.js$/.test(webJsFilename)) throw 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.`);
10957
- let vmodPlugin = (publicPath = resolveEffectiveAssetPrefix({
11004
+ let publicPath, webConfig = config.environments?.web, vmodPlugin = (publicPath = resolveEffectiveAssetPrefix({
10958
11005
  dev: webConfig?.dev,
10959
11006
  output: webConfig?.output,
10960
11007
  isBuild
@@ -11140,16 +11187,31 @@ if (typeof window !== 'undefined' && import.meta.webpackHot) {
11140
11187
  }) : rspackConfig), api.modifyEnvironmentConfig(async (config, { name, mergeEnvironmentConfig })=>'web' !== name && 'node' !== name ? config : mergeEnvironmentConfig(config, {
11141
11188
  tools: {
11142
11189
  rspack: (rspackConfig)=>{
11143
- if (federation && ((rspackConfig)=>{
11190
+ if (federation) {
11144
11191
  if (rspackConfig?.plugins?.length) for (let plugin of rspackConfig.plugins){
11145
- if (!plugin || 'object' != typeof plugin || 'ModuleFederationPlugin' !== plugin.name && 'RspackModuleFederationPlugin' !== plugin.name) continue;
11146
- let pluginOptions = plugin._options ?? plugin.options;
11192
+ let pluginOptions = getModuleFederationOptions(plugin);
11147
11193
  pluginOptions && (pluginOptions.experiments = {
11148
11194
  ...pluginOptions.experiments,
11149
11195
  asyncStartup: !0
11150
11196
  });
11151
11197
  }
11152
- })(rspackConfig), 'node' === name) {
11198
+ if ('web' === name) ((rspackConfig)=>{
11199
+ let containers = new Set((rspackConfig?.plugins ?? []).map(getModuleFederationOptions).map((options)=>options?.name).filter((name)=>'string' == typeof name));
11200
+ if (!rspackConfig || 0 === containers.size) return;
11201
+ let current = rspackConfig.optimization?.runtimeChunk, appRuntimeName = 'object' == typeof current && 'string' == typeof current?.name ? current.name : 'runtime';
11202
+ rspackConfig.optimization = {
11203
+ ...rspackConfig.optimization,
11204
+ runtimeChunk: {
11205
+ name: (entrypoint)=>containers.has(entrypoint.name) ? `runtime-${entrypoint.name}` : appRuntimeName
11206
+ }
11207
+ };
11208
+ })(rspackConfig);
11209
+ else {
11210
+ let splitChunks = rspackConfig?.optimization?.splitChunks;
11211
+ if (splitChunks) for (let group of (splitChunks.chunks = 'async', Object.values(splitChunks.cacheGroups ?? {})))group && 'object' == typeof group && 'chunks' in group && (group.chunks = 'async');
11212
+ }
11213
+ }
11214
+ if ('node' === name) {
11153
11215
  let output = rspackConfig.output;
11154
11216
  if (output) {
11155
11217
  let library = output.library, libraryOptions = library && 'object' == typeof library && !Array.isArray(library) ? library : {};
@@ -11171,7 +11233,20 @@ if (typeof window !== 'undefined' && import.meta.webpackHot) {
11171
11233
  federation: pluginOptions.federation,
11172
11234
  resolvedServerOutput,
11173
11235
  webOutput: modePlan.webOutput
11174
- }), 'classic' === modePlan.kind && useRouteModuleTransformLoader && api.modifyEnvironmentConfig(async (config, { name, mergeEnvironmentConfig })=>'web' !== name && 'node' !== name ? config : mergeEnvironmentConfig(config, {
11236
+ }), pluginOptions.federation && 'classic' === modePlan.kind) {
11237
+ let browserEntryModules = new Set([
11238
+ finalEntryClientPath,
11239
+ ...routeByFilePath.keys()
11240
+ ]);
11241
+ api.transform({
11242
+ environments: [
11243
+ 'web'
11244
+ ],
11245
+ order: 'post',
11246
+ test: (resourcePath)=>browserEntryModules.has(resourcePath)
11247
+ }, ({ code })=>`${code}\nexport {};\nawait Promise.resolve();\n`);
11248
+ }
11249
+ 'classic' === modePlan.kind && useRouteModuleTransformLoader && api.modifyEnvironmentConfig(async (config, { name, mergeEnvironmentConfig })=>'web' !== name && 'node' !== name ? config : mergeEnvironmentConfig(config, {
11175
11250
  tools: {
11176
11251
  rspack: (rspackConfig)=>{
11177
11252
  let environmentDevHmrEnabled = 'web' === name && !isBuild && void 0 !== devHmrRefreshRuntimePath && 'development' === config.mode && config.dev?.hmr !== !1 && isRspackSwcReactRefreshEnabled(rspackConfig);
@@ -66,6 +66,35 @@ type ReactRouterManifestStatsCompilation = {
66
66
  };
67
67
  export declare const isManifestJsAsset: (asset: string) => boolean;
68
68
  export declare const isManifestCssAsset: (asset: string) => boolean;
69
+ /**
70
+ * The minimal compilation surface for `collectUnsupportedRscScriptAssets`.
71
+ * Chunks are classified as JavaScript-emitting from compilation metadata (their
72
+ * `javascript` content hash / modules), never from a filename.
73
+ */
74
+ export type RscScriptAssetCompilation = {
75
+ chunks: Iterable<RscScriptAssetChunk>;
76
+ chunkGraph: {
77
+ getChunkModulesIterableBySourceType(chunk: RscScriptAssetChunk, sourceType: string): Iterable<unknown>;
78
+ };
79
+ outputOptions: {
80
+ filename?: unknown;
81
+ chunkFilename?: unknown;
82
+ };
83
+ getPath(filename: string, data: Record<string, unknown>): string;
84
+ };
85
+ export type RscScriptAssetChunk = {
86
+ contentHash?: Record<string, string>;
87
+ canBeInitial(): boolean;
88
+ };
89
+ /**
90
+ * Browser JavaScript assets rspack's RSC manifest would drop: it only records
91
+ * chunk files whose emitted name ends in ".js", so `.mjs` names, query-hash
92
+ * names (`[name].js?v=...`), or any other extension vanish from
93
+ * `entryJsFiles` and the client manifest. The emitted script name is derived
94
+ * from the chunk's own filename template (entry or async) the same way rspack
95
+ * emits it, so function templates and `tools.rspack` overrides are covered.
96
+ */
97
+ export declare const collectUnsupportedRscScriptAssets: (compilation: RscScriptAssetCompilation) => string[];
69
98
  export declare const createReactRouterManifestStats: (compilation: ReactRouterManifestStatsCompilation | undefined, chunkNames?: ReadonlySet<string>) => ReactRouterManifestStats | undefined;
70
99
  export type RouteManifestModuleExports = Record<string, readonly string[]>;
71
100
  export type ReactRouterManifestGenerationResult = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rsbuild-plugin-react-router",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "React Router plugin for Rsbuild",
5
5
  "repository": {
6
6
  "type": "git",
@@ -61,6 +61,8 @@ export type ReactRouterDevRuntime = {
61
61
  changes: DevGraphChanges,
62
62
  identity: DevGraphIdentity
63
63
  ) => Promise<'committed' | 'ignored' | 'retry-node'>;
64
+ /** Node identity actually retained by the last successful generation. */
65
+ getCommittedNodeIdentity: () => DevCompilationIdentity | undefined;
64
66
  failAttempt: (error: Error) => void;
65
67
  load: (entryName?: string) => Promise<ServerBuild>;
66
68
  close: (error?: Error) => void;
@@ -615,6 +617,10 @@ export const createReactRouterDevRuntime = ({
615
617
  }
616
618
  },
617
619
 
620
+ getCommittedNodeIdentity() {
621
+ return state.kind === 'ready' ? state.committed.nodeIdentity : undefined;
622
+ },
623
+
618
624
  failAttempt(error): void {
619
625
  const attemptId = getCurrentAttemptId();
620
626
  if (attemptId !== null) {
@@ -0,0 +1,27 @@
1
+ /** Tracks unsignaled node edits across compiler retries within one dev session. */
2
+ export const createDevHdrIntentTracker = () => {
3
+ let latestRelevantEditRevision = 0;
4
+ let signaledRevision = 0;
5
+ const revisionByCompilation = new WeakMap<object, number>();
6
+
7
+ return {
8
+ capture(compilation: object, hasRelevantChanges: boolean): void {
9
+ if (hasRelevantChanges) {
10
+ latestRelevantEditRevision += 1;
11
+ }
12
+ revisionByCompilation.set(compilation, latestRelevantEditRevision);
13
+ },
14
+
15
+ signalCommitted(compilation: object, signal: () => void): void {
16
+ const compilationRevision = revisionByCompilation.get(compilation);
17
+ if (
18
+ compilationRevision === undefined ||
19
+ compilationRevision <= signaledRevision
20
+ ) {
21
+ return;
22
+ }
23
+ signal();
24
+ signaledRevision = Math.max(signaledRevision, compilationRevision);
25
+ },
26
+ };
27
+ };
@@ -19,6 +19,7 @@ import {
19
19
  registerReactRouterDevRuntime,
20
20
  unregisterReactRouterDevRuntime,
21
21
  } from './dev-generation.js';
22
+ import { createDevHdrIntentTracker } from './dev-hdr-intent.js';
22
23
  import { DEV_MANIFEST_UPDATE_EVENT } from './dev-hmr.js';
23
24
  import {
24
25
  getEnvironmentStats,
@@ -140,12 +141,10 @@ export const createReactRouterDevRuntimeController = ({
140
141
  const compilationIdentities = createCompilationIdentityTracker();
141
142
  const { getCompilationIdentity } = compilationIdentities;
142
143
 
143
- // Web-only commits reuse the node compiler's stale `modifiedFiles`
144
- // snapshot, and every HDR bump itself triggers a web rebuild — so signal
145
- // once per node compilation identity or the bump loop self-sustains.
146
- const hdrSignaledNodeIdentity = new WeakMap<
144
+ // Pending node-edit intent until a coherent commit retains that compilation.
145
+ const hdrIntentsByPair = new WeakMap<
147
146
  DevCompilerPair,
148
- NonNullable<DevGraphIdentity['node']>
147
+ ReturnType<typeof createDevHdrIntentTracker>
149
148
  >();
150
149
 
151
150
  const finishRuntimeAttempt = async (
@@ -168,17 +167,15 @@ export const createReactRouterDevRuntimeController = ({
168
167
  pair.node.watching?.invalidate();
169
168
  return;
170
169
  }
170
+ const nodeCompilation = getEnvironmentStats(stats, 'node')?.compilation;
171
171
  if (
172
172
  result === 'committed' &&
173
- changes.node.known &&
174
- identity.node !== undefined &&
175
- hdrSignaledNodeIdentity.get(pair) !== identity.node &&
176
- Array.from(changes.node.files).some(
177
- file => !isHdrRevisionFile(file) && !isCssSourceFile(file)
178
- )
173
+ nodeCompilation &&
174
+ identity.node === binding.runtime.getCommittedNodeIdentity()
179
175
  ) {
180
- hdrSignaledNodeIdentity.set(pair, identity.node);
181
- onNodeRebuildCommitted?.();
176
+ hdrIntentsByPair.get(pair)?.signalCommitted(nodeCompilation, () => {
177
+ onNodeRebuildCommitted?.();
178
+ });
182
179
  }
183
180
  } catch (cause) {
184
181
  if (sessions.getActiveBinding()?.id === binding.id) {
@@ -344,6 +341,8 @@ export const createReactRouterDevRuntimeController = ({
344
341
  }
345
342
  const pair: DevCompilerPair = createDevCompilerPair({ web, node });
346
343
  binding.compilers = pair;
344
+ const hdrIntents = createDevHdrIntentTracker();
345
+ hdrIntentsByPair.set(pair, hdrIntents);
347
346
  const sessionId = binding.id;
348
347
  const runtime = binding.runtime;
349
348
  const failCurrentAttempt = (side: 'web' | 'node', error: Error): void => {
@@ -427,6 +426,14 @@ export const createReactRouterDevRuntimeController = ({
427
426
  if (sessions.getActiveBinding()?.id !== sessionId) {
428
427
  return;
429
428
  }
429
+ const changes = snapshotDevChangedFiles(pair.node);
430
+ hdrIntents.capture(
431
+ compilation,
432
+ changes.known &&
433
+ Array.from(changes.files).some(
434
+ file => !isHdrRevisionFile(file) && !isCssSourceFile(file)
435
+ )
436
+ );
430
437
  pair.latestNodeStart = {
431
438
  status: 'started',
432
439
  identity: getCompilationIdentity(compilation),
@@ -1,5 +1,9 @@
1
1
  import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core';
2
- import { ensureFederationAsyncStartup } from './federation.js';
2
+ import {
3
+ enforceAsyncOnlyServerSplitChunks,
4
+ ensureFederationAsyncStartup,
5
+ isolateFederationContainerRuntime,
6
+ } from './federation.js';
3
7
 
4
8
  /**
5
9
  * Rspack `output` policy for the web and node environments, in two tiers:
@@ -70,6 +74,11 @@ export const registerReactRouterEnvironmentOutput = ({
70
74
  rspack: rspackConfig => {
71
75
  if (federation) {
72
76
  ensureFederationAsyncStartup(rspackConfig);
77
+ if (name === 'web') {
78
+ isolateFederationContainerRuntime(rspackConfig);
79
+ } else {
80
+ enforceAsyncOnlyServerSplitChunks(rspackConfig);
81
+ }
73
82
  }
74
83
 
75
84
  if (name === 'node') {
package/src/federation.ts CHANGED
@@ -1,9 +1,73 @@
1
1
  import type { Rspack } from '@rsbuild/core';
2
2
 
3
+ type ModuleFederationPluginOptionsLike = {
4
+ name?: string;
5
+ experiments?: { asyncStartup?: boolean };
6
+ };
7
+
3
8
  type ModuleFederationPluginLike = {
4
9
  name?: string;
5
- _options?: { experiments?: { asyncStartup?: boolean } };
6
- options?: { experiments?: { asyncStartup?: boolean } };
10
+ _options?: ModuleFederationPluginOptionsLike;
11
+ options?: ModuleFederationPluginOptionsLike;
12
+ };
13
+
14
+ const getModuleFederationOptions = (
15
+ plugin: unknown
16
+ ): ModuleFederationPluginOptionsLike | undefined => {
17
+ if (!plugin || typeof plugin !== 'object') {
18
+ return undefined;
19
+ }
20
+ const federationPlugin = plugin as ModuleFederationPluginLike;
21
+ if (
22
+ federationPlugin.name !== 'ModuleFederationPlugin' &&
23
+ federationPlugin.name !== 'RspackModuleFederationPlugin'
24
+ ) {
25
+ return undefined;
26
+ }
27
+ return federationPlugin._options ?? federationPlugin.options;
28
+ };
29
+
30
+ /**
31
+ * The Module Federation container name(s) configured on this compiler, i.e.
32
+ * the entry names of the remote containers it emits.
33
+ */
34
+ export const getFederationContainerNames = (
35
+ rspackConfig: Rspack.Configuration | undefined
36
+ ): string[] =>
37
+ (rspackConfig?.plugins ?? [])
38
+ .map(getModuleFederationOptions)
39
+ .map(options => options?.name)
40
+ .filter((name): name is string => typeof name === 'string');
41
+
42
+ /**
43
+ * Classic mode shares one runtime chunk across every browser entry so route
44
+ * module entries share a module registry. A federation container must not
45
+ * share it: importing the container would run the app entries' async startup
46
+ * (share-scope consumes) before the host has initialized the share scope,
47
+ * yielding duplicate singletons (a second React). Give containers their own
48
+ * runtime chunk.
49
+ */
50
+ export const isolateFederationContainerRuntime = (
51
+ rspackConfig: Rspack.Configuration | undefined
52
+ ): void => {
53
+ const containers = new Set(getFederationContainerNames(rspackConfig));
54
+ if (!rspackConfig || containers.size === 0) {
55
+ return;
56
+ }
57
+ const current = rspackConfig.optimization?.runtimeChunk;
58
+ const appRuntimeName =
59
+ typeof current === 'object' && typeof current?.name === 'string'
60
+ ? current.name
61
+ : 'runtime';
62
+ rspackConfig.optimization = {
63
+ ...rspackConfig.optimization,
64
+ runtimeChunk: {
65
+ name: (entrypoint: { name: string }) =>
66
+ containers.has(entrypoint.name)
67
+ ? `runtime-${entrypoint.name}`
68
+ : appRuntimeName,
69
+ },
70
+ };
7
71
  };
8
72
 
9
73
  export const ensureFederationAsyncStartup = (
@@ -14,18 +78,7 @@ export const ensureFederationAsyncStartup = (
14
78
  }
15
79
 
16
80
  for (const plugin of rspackConfig.plugins) {
17
- if (!plugin || typeof plugin !== 'object') {
18
- continue;
19
- }
20
- const federationPlugin = plugin as ModuleFederationPluginLike;
21
- if (
22
- federationPlugin.name !== 'ModuleFederationPlugin' &&
23
- federationPlugin.name !== 'RspackModuleFederationPlugin'
24
- ) {
25
- continue;
26
- }
27
-
28
- const pluginOptions = federationPlugin._options ?? federationPlugin.options;
81
+ const pluginOptions = getModuleFederationOptions(plugin);
29
82
  if (!pluginOptions) {
30
83
  continue;
31
84
  }
@@ -36,3 +89,29 @@ export const ensureFederationAsyncStartup = (
36
89
  };
37
90
  }
38
91
  };
92
+
93
+ /**
94
+ * `@module-federation/node` replaces Rspack's `readFileVm` chunk loader with one
95
+ * that tracks loaded chunks privately, so initial chunks split off a
96
+ * multi-entry server build never satisfy Rspack's startup gate
97
+ * (`__webpack_require__.O`) and the async startup resolves to `undefined`
98
+ * instead of the server build's exports. Keep server code splitting to async
99
+ * chunks only. Runs at the final `tools.rspack` boundary so a user
100
+ * `optimization.splitChunks` override or a preset whose cache group selects
101
+ * `chunks: 'all'` (e.g. Rsbuild's `single-vendor`, `enforce: true`) cannot
102
+ * reintroduce initial chunk dependencies. A disabled `splitChunks` is kept.
103
+ */
104
+ export const enforceAsyncOnlyServerSplitChunks = (
105
+ rspackConfig: Rspack.Configuration | undefined
106
+ ): void => {
107
+ const splitChunks = rspackConfig?.optimization?.splitChunks;
108
+ if (!splitChunks) {
109
+ return;
110
+ }
111
+ splitChunks.chunks = 'async';
112
+ for (const group of Object.values(splitChunks.cacheGroups ?? {})) {
113
+ if (group && typeof group === 'object' && 'chunks' in group) {
114
+ group.chunks = 'async';
115
+ }
116
+ }
117
+ };
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type ResolvedReactRouterConfig,
27
27
  } from './react-router-config.js';
28
28
  import {
29
+ collectUnsupportedRscScriptAssets,
29
30
  configRoutesToRouteManifest,
30
31
  createReactRouterManifestStats,
31
32
  type ReactRouterManifestForDev as ReactRouterManifest,
@@ -773,6 +774,27 @@ export const pluginReactRouter = (
773
774
  stats?.compilation,
774
775
  manifestChunkNames
775
776
  );
777
+ if (isRscMode && stats) {
778
+ // Rspack's RSC manifest only records browser scripts whose emitted
779
+ // name ends in ".js" (entry files and client-reference chunks
780
+ // alike); anything else silently disappears from `entryJsFiles` and
781
+ // the client manifest, and the server cannot bootstrap or preload
782
+ // it. Check the emitted output, which is what the manifest saw, so
783
+ // function filenames and `tools.rspack` overrides are covered too.
784
+ const unsupported = collectUnsupportedRscScriptAssets(
785
+ stats.compilation
786
+ );
787
+ if (unsupported.length > 0) {
788
+ throw new Error(
789
+ `[${PLUGIN_NAME}] RSC mode requires every browser JavaScript asset to be named "*.js" (no query, no other extension): rspack's RSC manifest omits ${unsupported
790
+ .slice(0, 5)
791
+ .map(asset => JSON.stringify(asset))
792
+ .join(
793
+ ', '
794
+ )}${unsupported.length > 5 ? ` and ${unsupported.length - 5} more` : ''}. Adjust web \`output.filename.js\` / \`chunkFilename\`.`
795
+ );
796
+ }
797
+ }
776
798
  }
777
799
  if (pluginOptions.federation && ssr) {
778
800
  const serverBuildDir = resolve(buildDirectory, 'server');
@@ -848,22 +870,6 @@ export const pluginReactRouter = (
848
870
 
849
871
  api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => {
850
872
  const webConfig = config.environments?.web;
851
- const webJsFilename =
852
- webConfig?.output?.filename?.js ?? config.output?.filename?.js;
853
- // Rspack's RSC manifest only records browser entry files named `*.js`
854
- // (`entryJsFiles`), and the server renders its bootstrap scripts from
855
- // that list. Reject filename schemes it would silently drop up front.
856
- if (
857
- isRscMode &&
858
- typeof webJsFilename === 'string' &&
859
- !/\.js$/.test(webJsFilename)
860
- ) {
861
- throw new Error(
862
- `[${PLUGIN_NAME}] RSC mode requires web \`output.filename.js\` to end in ".js" (got ${JSON.stringify(
863
- webJsFilename
864
- )}): rspack's RSC manifest omits entry files with a query or another extension, so the server could not render bootstrap scripts.`
865
- );
866
- }
867
873
  const assetPrefix = resolveEffectiveAssetPrefix(
868
874
  { dev: webConfig?.dev, output: webConfig?.output, isBuild },
869
875
  { dev: config.dev, output: config.output }
@@ -1044,6 +1050,31 @@ export const pluginReactRouter = (
1044
1050
  webOutput: modePlan.webOutput,
1045
1051
  });
1046
1052
 
1053
+ if (pluginOptions.federation && modePlan.kind === 'classic') {
1054
+ // Module Federation's async startup makes every entry's startup a
1055
+ // promise. React Router imports each browser route-module entry
1056
+ // synchronously (`import * as route0 from ".../root.js"`) and reads its
1057
+ // exports right away, and `import()`s split route chunks the same way.
1058
+ // Making those entry modules async (top-level await) turns Rspack's
1059
+ // module-library export into `(await startup).default`, so importers
1060
+ // wait for the awaited startup instead of reading a snapshot of the
1061
+ // promise (#132). Runs after SWC so it applies to the final module code.
1062
+ const browserEntryModules = new Set([
1063
+ finalEntryClientPath,
1064
+ ...routeByFilePath.keys(),
1065
+ ]);
1066
+ api.transform(
1067
+ {
1068
+ environments: ['web'],
1069
+ order: 'post',
1070
+ test: (resourcePath: string) => browserEntryModules.has(resourcePath),
1071
+ },
1072
+ // `export {}` keeps an otherwise-empty client module (a route with only
1073
+ // server exports) parsed as ESM, which top-level await requires.
1074
+ ({ code }) => `${code}\nexport {};\nawait Promise.resolve();\n`
1075
+ );
1076
+ }
1077
+
1047
1078
  if (modePlan.kind === 'classic' && useRouteModuleTransformLoader) {
1048
1079
  api.modifyEnvironmentConfig(
1049
1080
  async (config, { name, mergeEnvironmentConfig }) => {
package/src/manifest.ts CHANGED
@@ -179,6 +179,79 @@ export const isManifestJsAsset = (asset: string): boolean =>
179
179
  export const isManifestCssAsset = (asset: string): boolean =>
180
180
  /\.css(?:\?.*)?$/.test(asset);
181
181
 
182
+ /**
183
+ * The minimal compilation surface for `collectUnsupportedRscScriptAssets`.
184
+ * Chunks are classified as JavaScript-emitting from compilation metadata (their
185
+ * `javascript` content hash / modules), never from a filename.
186
+ */
187
+ export type RscScriptAssetCompilation = {
188
+ chunks: Iterable<RscScriptAssetChunk>;
189
+ chunkGraph: {
190
+ getChunkModulesIterableBySourceType(
191
+ chunk: RscScriptAssetChunk,
192
+ sourceType: string
193
+ ): Iterable<unknown>;
194
+ };
195
+ outputOptions: {
196
+ filename?: unknown;
197
+ chunkFilename?: unknown;
198
+ };
199
+ getPath(filename: string, data: Record<string, unknown>): string;
200
+ };
201
+
202
+ export type RscScriptAssetChunk = {
203
+ contentHash?: Record<string, string>;
204
+ canBeInitial(): boolean;
205
+ };
206
+
207
+ const hasSome = (iterable: Iterable<unknown>): boolean => {
208
+ for (const _ of iterable) {
209
+ return true;
210
+ }
211
+ return false;
212
+ };
213
+
214
+ /**
215
+ * Browser JavaScript assets rspack's RSC manifest would drop: it only records
216
+ * chunk files whose emitted name ends in ".js", so `.mjs` names, query-hash
217
+ * names (`[name].js?v=...`), or any other extension vanish from
218
+ * `entryJsFiles` and the client manifest. The emitted script name is derived
219
+ * from the chunk's own filename template (entry or async) the same way rspack
220
+ * emits it, so function templates and `tools.rspack` overrides are covered.
221
+ */
222
+ export const collectUnsupportedRscScriptAssets = (
223
+ compilation: RscScriptAssetCompilation
224
+ ): string[] => {
225
+ const unsupported = new Set<string>();
226
+ for (const chunk of compilation.chunks) {
227
+ const emitsJavaScript =
228
+ chunk.contentHash?.javascript !== undefined ||
229
+ hasSome(
230
+ compilation.chunkGraph.getChunkModulesIterableBySourceType(
231
+ chunk,
232
+ 'javascript'
233
+ )
234
+ );
235
+ if (!emitsJavaScript) {
236
+ continue;
237
+ }
238
+ const pathData = { chunk, contentHashType: 'javascript' };
239
+ const template = chunk.canBeInitial()
240
+ ? compilation.outputOptions.filename
241
+ : compilation.outputOptions.chunkFilename;
242
+ const resolvedTemplate =
243
+ typeof template === 'function' ? template(pathData) : template;
244
+ if (typeof resolvedTemplate !== 'string') {
245
+ continue;
246
+ }
247
+ const file = compilation.getPath(resolvedTemplate, pathData);
248
+ if (!file.endsWith('.js')) {
249
+ unsupported.add(file);
250
+ }
251
+ }
252
+ return [...unsupported];
253
+ };
254
+
182
255
  const collectManifestFilesByName = <T>(
183
256
  items: ReactRouterManifestStatsLookup<T>,
184
257
  names: ReadonlySet<string> | undefined,
@@ -127,11 +127,19 @@ export const createReactRouterRscVirtualModules = ({
127
127
  'virtual/react-router/unstable_rsc/manifest-prefix': `const manifest = __webpack_require__.rscM;
128
128
  const serverPrefix = ${JSON.stringify(serverPublicPath)};
129
129
  const appliedPrefix = manifest?.moduleLoading?.prefix;
130
- if (appliedPrefix && appliedPrefix !== serverPrefix) {
130
+ // An empty applied prefix (web \`assetPrefix: ''\`) yields relative references
131
+ // such as "static/js/index.js"; those are rebased too, while absolute and
132
+ // protocol-relative URLs are left alone.
133
+ const isAbsoluteUrl = url => /^(?:[a-z][a-z\\d+.-]*:|\\/\\/|\\/)/i.test(url);
134
+ if (typeof appliedPrefix === "string" && appliedPrefix !== serverPrefix) {
131
135
  const rewrite = url =>
132
- typeof url === "string" && url.startsWith(appliedPrefix)
133
- ? serverPrefix + url.slice(appliedPrefix.length)
134
- : url;
136
+ typeof url !== "string"
137
+ ? url
138
+ : appliedPrefix !== "" && url.startsWith(appliedPrefix)
139
+ ? serverPrefix + url.slice(appliedPrefix.length)
140
+ : appliedPrefix === "" && !isAbsoluteUrl(url)
141
+ ? serverPrefix + url
142
+ : url;
135
143
  const rewriteAll = list => {
136
144
  if (Array.isArray(list)) for (let i = 0; i < list.length; i++) list[i] = rewrite(list[i]);
137
145
  };