rsbuild-plugin-react-router 0.6.6 → 0.7.1

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/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
@@ -6,11 +6,7 @@ import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core';
6
6
  import { relative, resolve } from 'pathe';
7
7
 
8
8
  import { getDefaultConcurrency } from './concurrency.js';
9
- import {
10
- DEFAULT_JS_DIST_PATH,
11
- JS_EXTENSIONS,
12
- PLUGIN_NAME,
13
- } from './constants.js';
9
+ import { JS_EXTENSIONS, PLUGIN_NAME } from './constants.js';
14
10
  import { guardReactRouterLazyCompilation } from './lazy-compilation.js';
15
11
  import {
16
12
  findEntryFile,
@@ -30,6 +26,7 @@ import {
30
26
  type ResolvedReactRouterConfig,
31
27
  } from './react-router-config.js';
32
28
  import {
29
+ collectUnsupportedRscScriptAssets,
33
30
  configRoutesToRouteManifest,
34
31
  createReactRouterManifestStats,
35
32
  type ReactRouterManifestForDev as ReactRouterManifest,
@@ -222,13 +219,25 @@ export const pluginReactRouter = (
222
219
  warnOnClientSourceMaps(normalized, msg => api.logger.warn(msg), 'web');
223
220
  });
224
221
 
222
+ // The manifest / server `publicPath` follows the web environment's asset
223
+ // prefix because that is where the browser assets are served from. A web
224
+ // prefix the server cannot use (`'auto'`) falls back to the root prefix,
225
+ // so `output.assetPrefix: 'https://cdn/'` + web `'auto'` still emits CDN
226
+ // URLs from the server.
225
227
  api.onBeforeCreateCompiler(() => {
226
- const normalized = api.getNormalizedConfig();
227
- assetPrefix = resolveEffectiveAssetPrefix({
228
- dev: normalized.dev,
229
- output: normalized.output,
230
- isBuild: api.context.action === 'build',
231
- });
228
+ const root = api.getNormalizedConfig();
229
+ // `getNormalizedConfig({ environment: 'web' })` throws when the build was
230
+ // narrowed to other environments (`--environment node`), so look the web
231
+ // environment up on the root config instead.
232
+ const web = root.environments.web;
233
+ assetPrefix = resolveEffectiveAssetPrefix(
234
+ {
235
+ dev: web?.dev,
236
+ output: web?.output,
237
+ isBuild: api.context.action === 'build',
238
+ },
239
+ { dev: root.dev, output: root.output }
240
+ );
232
241
  });
233
242
 
234
243
  const configPath = findEntryFile(resolve('react-router.config'));
@@ -765,6 +774,27 @@ export const pluginReactRouter = (
765
774
  stats?.compilation,
766
775
  manifestChunkNames
767
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
+ }
768
798
  }
769
799
  if (pluginOptions.federation && ssr) {
770
800
  const serverBuildDir = resolve(buildDirectory, 'server');
@@ -832,39 +862,19 @@ export const pluginReactRouter = (
832
862
  }
833
863
 
834
864
  // Public requests stay bare while Rspack resolves seeded virtual files.
835
- const createVirtualModulePlugin = (
836
- publicPath: string,
837
- jsDistPath: string
838
- ) => {
865
+ const createVirtualModulePlugin = (publicPath: string) => {
839
866
  return new rspack.experiments.VirtualModulesPlugin(
840
- mapVirtualModules(modePlan.createVirtualModules(publicPath, jsDistPath))
867
+ mapVirtualModules(modePlan.createVirtualModules(publicPath))
841
868
  );
842
869
  };
843
870
 
844
871
  api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => {
845
- // The RSC bootstrap script URL must reflect the user's web js distPath;
846
- // the entry filename itself is deterministic because the plugin forces
847
- // web `output.filename.js` to '[name].js' below.
848
- const webDistPath = config.environments?.web?.output?.distPath;
849
- const rootDistPath = config.output?.distPath;
850
- const jsDistPath =
851
- (typeof webDistPath === 'object' ? webDistPath.js : undefined) ??
852
- (typeof rootDistPath === 'object' ? rootDistPath.js : undefined) ??
853
- DEFAULT_JS_DIST_PATH;
854
- const assetPrefix = resolveEffectiveAssetPrefix({
855
- dev: config.dev,
856
- output: config.output,
857
- isBuild,
858
- });
859
- const vmodPlugin = createVirtualModulePlugin(assetPrefix, jsDistPath);
860
- const useAsyncNodeChunkLoading =
861
- options.federation && resolvedServerOutput === 'commonjs';
862
- let nodeChunkLoading: 'import' | 'async-node' | 'require' = 'require';
863
- if (resolvedServerOutput === 'module') {
864
- nodeChunkLoading = 'import';
865
- } else if (useAsyncNodeChunkLoading) {
866
- nodeChunkLoading = 'async-node';
867
- }
872
+ const webConfig = config.environments?.web;
873
+ const assetPrefix = resolveEffectiveAssetPrefix(
874
+ { dev: webConfig?.dev, output: webConfig?.output, isBuild },
875
+ { dev: config.dev, output: config.output }
876
+ );
877
+ const vmodPlugin = createVirtualModulePlugin(assetPrefix);
868
878
  const configuredLazyCompilation = Object.prototype.hasOwnProperty.call(
869
879
  options,
870
880
  'lazyCompilation'
@@ -964,9 +974,6 @@ export const pluginReactRouter = (
964
974
  }),
965
975
  },
966
976
  output: {
967
- filename: {
968
- js: '[name].js',
969
- },
970
977
  distPath: {
971
978
  root: outputClientPath,
972
979
  },
@@ -985,15 +992,6 @@ export const pluginReactRouter = (
985
992
  ],
986
993
  },
987
994
  externalsType: modePlan.webExternalsType,
988
- output: {
989
- ...modePlan.webOutput,
990
- publicPath: assetPrefix,
991
- ...(options.federation
992
- ? {
993
- chunkLoading: 'import',
994
- }
995
- : {}),
996
- },
997
995
  optimization: modePlan.webOptimization,
998
996
  },
999
997
  },
@@ -1038,17 +1036,6 @@ export const pluginReactRouter = (
1038
1036
  externals: modePlan.nodeExternals,
1039
1037
  ...modePlan.nodeDependencies,
1040
1038
  externalsType: resolvedServerOutput,
1041
- output: {
1042
- chunkFormat: resolvedServerOutput,
1043
- chunkLoading: nodeChunkLoading,
1044
- devtoolModuleFilenameTemplate: '[absolute-resource-path]',
1045
- devtoolFallbackModuleFilenameTemplate:
1046
- '[absolute-resource-path]?[hash]',
1047
- workerChunkLoading: nodeChunkLoading,
1048
- wasmLoading: 'fetch',
1049
- module: resolvedServerOutput === 'module',
1050
- chunkFilename: 'static/js/async/[name].js',
1051
- },
1052
1039
  },
1053
1040
  },
1054
1041
  },
@@ -1060,8 +1047,34 @@ export const pluginReactRouter = (
1060
1047
  api,
1061
1048
  federation: pluginOptions.federation,
1062
1049
  resolvedServerOutput,
1050
+ webOutput: modePlan.webOutput,
1063
1051
  });
1064
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
+
1065
1078
  if (modePlan.kind === 'classic' && useRouteModuleTransformLoader) {
1066
1079
  api.modifyEnvironmentConfig(
1067
1080
  async (config, { name, mergeEnvironmentConfig }) => {
package/src/manifest.ts CHANGED
@@ -167,18 +167,89 @@ type ReactRouterManifestStatsCompilation = {
167
167
  entrypoints?: ReactRouterManifestStatsLookup<ReactRouterManifestStatsEntrypoint>;
168
168
  };
169
169
 
170
- const orderChunkFiles = (chunkName: string, files: string[]): string[] => {
171
- const ownChunkAsset = `${chunkName}.js`;
172
- const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset));
173
- if (ownFileIndex <= 0) {
174
- return files;
170
+ // Emitted asset names may carry a query (`output.filename.js:
171
+ // '[name].js?v=[contenthash:8]'`); classify on the pathname but keep the full
172
+ // reference, since the query is part of the URL the browser must request.
173
+ // A chunk's own script is whatever JavaScript the compilation rendered for it,
174
+ // regardless of `output.filename` scheme. In development, HMR update files are
175
+ // also recorded on `chunk.files`; they are never the module to load.
176
+ export const isManifestJsAsset = (asset: string): boolean =>
177
+ /(?<!\.hot-update)\.[cm]?js(?:\?.*)?$/.test(asset);
178
+
179
+ export const isManifestCssAsset = (asset: string): boolean =>
180
+ /\.css(?:\?.*)?$/.test(asset);
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;
175
210
  }
211
+ return false;
212
+ };
176
213
 
177
- return [
178
- files[ownFileIndex],
179
- ...files.slice(0, ownFileIndex),
180
- ...files.slice(ownFileIndex + 1),
181
- ];
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];
182
253
  };
183
254
 
184
255
  const collectManifestFilesByName = <T>(
@@ -241,8 +312,7 @@ export const createReactRouterManifestStats = (
241
312
  const assetsByChunkName = collectManifestFilesByName(
242
313
  compilation.namedChunks,
243
314
  chunkNames,
244
- (chunkName, chunk) =>
245
- orderChunkFiles(chunkName, Array.from(chunk.files ?? []))
315
+ (_chunkName, chunk) => Array.from(chunk.files ?? [])
246
316
  );
247
317
  const entrypointFilesByName = compilation.entrypoints
248
318
  ? collectManifestFilesByName(
@@ -303,22 +373,31 @@ const createChunkAssetResolver = (
303
373
 
304
374
  const cssAssets = new Set<string>();
305
375
  const jsAssets = new Set<string>();
376
+ // The chunk's own files come first so `js[0]` is the route module itself;
377
+ // entrypoint files (runtime, shared vendor chunks) follow as `imports`.
306
378
  for (const asset of assets) {
307
- if (asset.endsWith('.css')) {
379
+ if (isManifestCssAsset(asset)) {
308
380
  cssAssets.add(asset);
309
- } else if (asset.endsWith('.js')) {
381
+ } else if (isManifestJsAsset(asset)) {
310
382
  jsAssets.add(asset);
311
383
  }
312
384
  }
313
385
  for (const asset of clientStats?.entrypointFilesByName?.[chunkName] ?? []) {
314
- if (asset.endsWith('.css')) {
386
+ if (isManifestCssAsset(asset)) {
315
387
  cssAssets.add(asset);
316
- } else if (includeEntrypointJs && asset.endsWith('.js')) {
388
+ } else if (includeEntrypointJs && isManifestJsAsset(asset)) {
317
389
  jsAssets.add(asset);
318
390
  }
319
391
  }
320
392
  if (jsAssets.size === 0) {
321
- jsAssets.add(`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`);
393
+ // Compilation metadata exists for this chunk but names no module script.
394
+ // Guessing `<dir>/<chunk>.js` here would turn an identifiable build
395
+ // problem into a browser 404, so surface it at build time instead.
396
+ throw new Error(
397
+ `[react-router] Chunk "${chunkName}" emitted no JavaScript asset the browser manifest can reference (files: ${
398
+ assets.join(', ') || 'none'
399
+ }). Check the web \`output.filename.js\` scheme.`
400
+ );
322
401
  }
323
402
 
324
403
  const result = { js: [...jsAssets], css: [...cssAssets] };
package/src/mode-plan.ts CHANGED
@@ -36,10 +36,7 @@ type CommonModePlan = {
36
36
  manifestChunkNames: Set<string>;
37
37
  webEntries: Record<string, string | RsbuildEntryDescription>;
38
38
  nodeEntries: Record<string, string | RsbuildEntryDescription>;
39
- createVirtualModules(
40
- publicPath: string,
41
- jsDistPath: string
42
- ): Record<string, string>;
39
+ createVirtualModules(publicPath: string): Record<string, string>;
43
40
  createResolveConfig(rootPath: string): Rspack.Configuration['resolve'];
44
41
  server: RsbuildConfig['server'] | undefined;
45
42
  webExternalsType: 'module' | undefined;
@@ -183,14 +180,13 @@ const createRscModePlan = async ({
183
180
  layer: RSC_LAYERS.rsc,
184
181
  },
185
182
  },
186
- createVirtualModules: (publicPath: string, jsDistPath: string) =>
183
+ createVirtualModules: (publicPath: string) =>
187
184
  createReactRouterRscVirtualModules({
188
185
  allowedActionOrigins: allowedActionOriginsForBuild,
189
186
  appDirectory,
190
187
  basename,
191
188
  buildDirectory,
192
189
  isBuild,
193
- jsDistPath,
194
190
  outputClientPath,
195
191
  publicPath,
196
192
  routeDiscovery,
@@ -314,7 +310,7 @@ const createClassicModePlan = async ({
314
310
  defaultEntryName,
315
311
  serverBundleEntries: artifacts.serverBundleEntries,
316
312
  }),
317
- createVirtualModules: (publicPath: string, _jsDistPath: string) =>
313
+ createVirtualModules: (publicPath: string) =>
318
314
  createClassicVirtualModules({
319
315
  allowedActionOrigins: allowedActionOriginsForBuild,
320
316
  appDirectory,
@@ -349,11 +345,9 @@ const createClassicModePlan = async ({
349
345
  wasmLoading: 'fetch',
350
346
  library: { type: 'module' },
351
347
  module: true,
352
- // Async chunks are addressed by id at runtime, so the chunk name only
353
- // adds bytes to the filename and to every place that references it.
354
- ...(isBuild
355
- ? { chunkFilename: 'static/js/async/[id]-[contenthash:16].js' }
356
- : {}),
348
+ // Async chunk filenames are left to Rsbuild, which derives them from
349
+ // `output.filename.js`, `output.filenameHash`, and `distPath.jsAsync`,
350
+ // so user configuration governs every JavaScript filename (#129).
357
351
  },
358
352
  webOptimization: {
359
353
  avoidEntryIife: true,
@@ -7,6 +7,7 @@ import {
7
7
  generateReactRouterManifestForDev,
8
8
  getReactRouterManifestChunkNames,
9
9
  getReactRouterManifestPath,
10
+ isManifestJsAsset,
10
11
  type ReactRouterManifestForDev as ReactRouterManifest,
11
12
  type RouteChunkManifestOptions,
12
13
  type RouteManifestModuleExports,
@@ -83,7 +84,7 @@ const addIntegrity = (
83
84
  ) => {
84
85
  if (
85
86
  typeof assetName !== 'string' ||
86
- !assetName.endsWith('.js') ||
87
+ !isManifestJsAsset(assetName) ||
87
88
  typeof integrity !== 'string'
88
89
  ) {
89
90
  return;
@@ -117,7 +118,7 @@ export const collectSubresourceIntegrity = (
117
118
  const assets =
118
119
  compilation.getAssets() as readonly CompilationAssetWithIntegrity[];
119
120
  for (const asset of assets) {
120
- if (!asset.name.endsWith('.js')) {
121
+ if (!isManifestJsAsset(asset.name)) {
121
122
  continue;
122
123
  }
123
124
  addIntegrity(
@@ -200,8 +201,7 @@ export function registerModifyBrowserManifestAssets(
200
201
 
201
202
  if (isBuild) {
202
203
  const entryAssets = stats?.assetsByChunkName?.['entry.client'];
203
- const entryJsAssets =
204
- entryAssets?.filter(asset => asset.endsWith('.js')) || [];
204
+ const entryJsAssets = entryAssets?.filter(isManifestJsAsset) || [];
205
205
  const manifestPath = getReactRouterManifestPath({
206
206
  version: manifest.version,
207
207
  isBuild: true,
@@ -79,34 +79,48 @@ export function normalizeAssetPrefix(assetPrefix?: string): string {
79
79
  return assetPrefix.endsWith('/') ? assetPrefix : `${assetPrefix}/`;
80
80
  }
81
81
 
82
+ type AssetPrefixConfig = {
83
+ dev?: { assetPrefix?: unknown };
84
+ output?: { assetPrefix?: unknown };
85
+ };
86
+
87
+ const asString = (value: unknown): string | undefined =>
88
+ typeof value === 'string' ? value : undefined;
89
+
90
+ const pickConfiguredAssetPrefix = (
91
+ { dev, output }: AssetPrefixConfig,
92
+ isBuild: boolean
93
+ ): string | undefined =>
94
+ isBuild
95
+ ? asString(output?.assetPrefix)
96
+ : (asString(dev?.assetPrefix) ?? asString(output?.assetPrefix));
97
+
82
98
  /**
83
- * Resolve the asset prefix Rsbuild applies to emitted asset URLs for the given
84
- * mode. In development the effective prefix is `dev.assetPrefix` (which Rsbuild
85
- * defaults from `server.base`, falling back to `output.assetPrefix`); in a
86
- * production build it is `output.assetPrefix`. Both fields are already resolved
87
- * on the normalized config, so this mirrors Rsbuild's own precedence rather than
88
- * re-deriving it from `server.base`.
99
+ * Resolve the absolute asset prefix the server build and browser manifest use
100
+ * for asset URLs. In development the effective prefix is `dev.assetPrefix`
101
+ * (which Rsbuild defaults from `server.base`, falling back to
102
+ * `output.assetPrefix`); in a production build it is `output.assetPrefix`.
103
+ *
104
+ * `fallbacks` are consulted in order (e.g. the root config after the web
105
+ * environment) when the preceding config only offers a prefix the server
106
+ * cannot use: `'auto'` and empty values are browser-runtime-only, so a root
107
+ * CDN prefix must survive a web `'auto'`. The choice happens before
108
+ * normalization so that `'auto'` is not first folded into `'/'`.
89
109
  *
90
110
  * `dev.assetPrefix` may be a boolean on the raw config (`false` disables it);
91
- * boolean/`'auto'`/empty values normalize to the root prefix `'/'`.
111
+ * boolean/`'auto'`/empty values ultimately normalize to the root prefix `'/'`.
92
112
  */
93
- export function resolveEffectiveAssetPrefix(config: {
94
- dev?: { assetPrefix?: unknown };
95
- output?: { assetPrefix?: unknown };
96
- isBuild: boolean;
97
- }): string {
98
- const outputPrefix =
99
- typeof config.output?.assetPrefix === 'string'
100
- ? config.output.assetPrefix
101
- : undefined;
102
- if (config.isBuild) {
103
- return normalizeAssetPrefix(outputPrefix);
113
+ export function resolveEffectiveAssetPrefix(
114
+ config: AssetPrefixConfig & { isBuild: boolean },
115
+ ...fallbacks: AssetPrefixConfig[]
116
+ ): string {
117
+ for (const candidate of [config, ...fallbacks]) {
118
+ const prefix = pickConfiguredAssetPrefix(candidate, config.isBuild);
119
+ if (prefix && prefix !== 'auto') {
120
+ return normalizeAssetPrefix(prefix);
121
+ }
104
122
  }
105
- const devPrefix =
106
- typeof config.dev?.assetPrefix === 'string'
107
- ? config.dev.assetPrefix
108
- : undefined;
109
- return normalizeAssetPrefix(devPrefix ?? outputPrefix);
123
+ return '/';
110
124
  }
111
125
 
112
126
  export function createRouteId(file: string): string {
@@ -623,6 +623,12 @@ const createServerRouteEntry = async (
623
623
  let needsReactImport = false;
624
624
  let needsEnsureHmrImport = false;
625
625
  let needsStyleEntryImport = false;
626
+ const pushStylesheetLinks = (entryCssFilesExpression: string): void => {
627
+ lines.push(` ...(${entryCssFilesExpression} ?? []).map(href =>`);
628
+ lines.push(
629
+ ' React.createElement("link", { key: href, rel: "stylesheet", href: href, precedence: "default" })),'
630
+ );
631
+ };
626
632
 
627
633
  // A client route whose default component is a client reference: its bundled
628
634
  // (non-vanilla) side-effect CSS is orphaned in the initial browser entry
@@ -651,12 +657,7 @@ const createServerRouteEntry = async (
651
657
  'export default function RscClientRouteWithStyles___(props) {'
652
658
  );
653
659
  lines.push(' return React.createElement(React.Fragment, null,');
654
- lines.push(
655
- ` ...(${RSC_ROUTE_STYLE_ENTRY_EXPORT}.entryCssFiles ?? []).map(href =>`
656
- );
657
- lines.push(
658
- ' React.createElement("link", { key: href, rel: "stylesheet", href: href, precedence: "default" })),'
659
- );
660
+ pushStylesheetLinks(`${RSC_ROUTE_STYLE_ENTRY_EXPORT}.entryCssFiles`);
660
661
  lines.push(' React.createElement(RscClientRouteDefault___, props),');
661
662
  lines.push(' );');
662
663
  lines.push('}');
@@ -682,12 +683,7 @@ const createServerRouteEntry = async (
682
683
  // graph contributes. Render those as stylesheet links at the top of the
683
684
  // stream (mirrors upstream's `import.meta.viteRsc.loadCss()`); React's
684
685
  // float support hoists them into `<head>`.
685
- lines.push(
686
- ` ...(${exportName}WithoutClientChunk.entryCssFiles ?? []).map(href =>`
687
- );
688
- lines.push(
689
- ' React.createElement("link", { key: href, rel: "stylesheet", href: href, precedence: "default" })),'
690
- );
686
+ pushStylesheetLinks(`${exportName}WithoutClientChunk.entryCssFiles`);
691
687
  lines.push(
692
688
  ' React.createElement(EnsureClientRouteModuleForHMR___, null),'
693
689
  );