rsbuild-plugin-react-router 0.6.6 → 0.7.0

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/manifest.ts CHANGED
@@ -167,19 +167,17 @@ 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;
175
- }
176
-
177
- return [
178
- files[ownFileIndex],
179
- ...files.slice(0, ownFileIndex),
180
- ...files.slice(ownFileIndex + 1),
181
- ];
182
- };
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);
183
181
 
184
182
  const collectManifestFilesByName = <T>(
185
183
  items: ReactRouterManifestStatsLookup<T>,
@@ -241,8 +239,7 @@ export const createReactRouterManifestStats = (
241
239
  const assetsByChunkName = collectManifestFilesByName(
242
240
  compilation.namedChunks,
243
241
  chunkNames,
244
- (chunkName, chunk) =>
245
- orderChunkFiles(chunkName, Array.from(chunk.files ?? []))
242
+ (_chunkName, chunk) => Array.from(chunk.files ?? [])
246
243
  );
247
244
  const entrypointFilesByName = compilation.entrypoints
248
245
  ? collectManifestFilesByName(
@@ -303,22 +300,31 @@ const createChunkAssetResolver = (
303
300
 
304
301
  const cssAssets = new Set<string>();
305
302
  const jsAssets = new Set<string>();
303
+ // The chunk's own files come first so `js[0]` is the route module itself;
304
+ // entrypoint files (runtime, shared vendor chunks) follow as `imports`.
306
305
  for (const asset of assets) {
307
- if (asset.endsWith('.css')) {
306
+ if (isManifestCssAsset(asset)) {
308
307
  cssAssets.add(asset);
309
- } else if (asset.endsWith('.js')) {
308
+ } else if (isManifestJsAsset(asset)) {
310
309
  jsAssets.add(asset);
311
310
  }
312
311
  }
313
312
  for (const asset of clientStats?.entrypointFilesByName?.[chunkName] ?? []) {
314
- if (asset.endsWith('.css')) {
313
+ if (isManifestCssAsset(asset)) {
315
314
  cssAssets.add(asset);
316
- } else if (includeEntrypointJs && asset.endsWith('.js')) {
315
+ } else if (includeEntrypointJs && isManifestJsAsset(asset)) {
317
316
  jsAssets.add(asset);
318
317
  }
319
318
  }
320
319
  if (jsAssets.size === 0) {
321
- jsAssets.add(`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`);
320
+ // Compilation metadata exists for this chunk but names no module script.
321
+ // Guessing `<dir>/<chunk>.js` here would turn an identifiable build
322
+ // problem into a browser 404, so surface it at build time instead.
323
+ throw new Error(
324
+ `[react-router] Chunk "${chunkName}" emitted no JavaScript asset the browser manifest can reference (files: ${
325
+ assets.join(', ') || 'none'
326
+ }). Check the web \`output.filename.js\` scheme.`
327
+ );
322
328
  }
323
329
 
324
330
  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
  );
@@ -1,7 +1,7 @@
1
1
  import { relative, resolve } from 'pathe';
2
2
  import type { Config } from './react-router-config.js';
3
3
  import type { Route } from './types.js';
4
- import { combineURLs, normalizeAssetPrefix } from './plugin-utils.js';
4
+ import { normalizeAssetPrefix } from './plugin-utils.js';
5
5
  import { getVirtualModuleFilePath } from './virtual-modules.js';
6
6
  import {
7
7
  createRscInternalClientModule,
@@ -19,6 +19,7 @@ const RSC_VIRTUAL_ALIAS_IDS = [
19
19
  'allowed-action-origins',
20
20
  'client-version',
21
21
  'react-router-serve-config',
22
+ 'manifest-prefix',
22
23
  'bootstrap-scripts',
23
24
  'server-manifest',
24
25
  ] as const;
@@ -29,8 +30,6 @@ type RscVirtualModulesOptions = {
29
30
  basename: string;
30
31
  buildDirectory: string;
31
32
  isBuild: boolean;
32
- /** Resolved web `output.distPath.js` segment, e.g. `static/js`. */
33
- jsDistPath: string;
34
33
  outputClientPath: string;
35
34
  publicPath: string;
36
35
  routeDiscovery: Config['routeDiscovery'];
@@ -73,7 +72,6 @@ export const createReactRouterRscVirtualModules = ({
73
72
  basename,
74
73
  buildDirectory,
75
74
  isBuild,
76
- jsDistPath,
77
75
  outputClientPath,
78
76
  publicPath,
79
77
  routeDiscovery,
@@ -84,7 +82,10 @@ export const createReactRouterRscVirtualModules = ({
84
82
  resolve(buildDirectory, 'server'),
85
83
  outputClientPath
86
84
  );
87
- const bootstrapPublicPath = normalizeAssetPrefix(publicPath);
85
+ // Absolute prefix the server must use for initial asset URLs (see
86
+ // `resolveEffectiveAssetPrefix`); the browser compiler may itself be on
87
+ // `'auto'`, which no server-rendered URL can express.
88
+ const serverPublicPath = normalizeAssetPrefix(publicPath);
88
89
 
89
90
  return {
90
91
  'virtual/react-router/unstable_rsc/routes': createRscRouteConfig({
@@ -111,9 +112,51 @@ export const createReactRouterRscVirtualModules = ({
111
112
  assetsBuildDirectory: rscAssetsBuildDirectory,
112
113
  publicPath,
113
114
  }),
114
- 'virtual/react-router/unstable_rsc/bootstrap-scripts': defaultExport([
115
- combineURLs(bootstrapPublicPath, `${jsDistPath}/index.js`),
116
- ]),
115
+ // Every server-facing asset URL in the rspack RSC manifest -- bootstrap
116
+ // `entryJsFiles`, route `entryCssFiles`, client references' `cssFiles`
117
+ // (react-server-dom-rspack renders those as <link>s), and Flight's
118
+ // `moduleLoading.prefix` for client-chunk preloads -- carries the browser
119
+ // compiler's public path, recorded in `moduleLoading.prefix`. With the
120
+ // browser compiler on `'auto'` rspack records `/`, which the server cannot
121
+ // serve from. `__rspack_rsc_manifest__` is this same object, so aligning
122
+ // it once, in place (arrays are mutated, not replaced, because
123
+ // `createServerEntry` has already captured references), makes every
124
+ // consumer agree on the server prefix without stacking a second one. The
125
+ // aligned manifest is exported (not imported for side effects only) so a
126
+ // `sideEffects: false` package cannot tree-shake the alignment away.
127
+ 'virtual/react-router/unstable_rsc/manifest-prefix': `const manifest = __webpack_require__.rscM;
128
+ const serverPrefix = ${JSON.stringify(serverPublicPath)};
129
+ const appliedPrefix = manifest?.moduleLoading?.prefix;
130
+ if (appliedPrefix && appliedPrefix !== serverPrefix) {
131
+ const rewrite = url =>
132
+ typeof url === "string" && url.startsWith(appliedPrefix)
133
+ ? serverPrefix + url.slice(appliedPrefix.length)
134
+ : url;
135
+ const rewriteAll = list => {
136
+ if (Array.isArray(list)) for (let i = 0; i < list.length; i++) list[i] = rewrite(list[i]);
137
+ };
138
+ manifest.moduleLoading.prefix = serverPrefix;
139
+ rewriteAll(manifest.entryJsFiles);
140
+ for (const files of Object.values(manifest.entryCssFiles ?? {})) rewriteAll(files);
141
+ for (const reference of Object.values(manifest.clientManifest ?? {})) rewriteAll(reference.cssFiles);
142
+ }
143
+ export const rscManifest = manifest;
144
+ `,
145
+ // The compiled browser entry scripts come from the manifest (like Next's
146
+ // `buildManifest` or the Vite plugin's `loadBootstrapScriptContent`), so
147
+ // hashed entry filenames resolve without any naming contract. The list and
148
+ // its order are preserved. An empty list is a build problem (rspack only
149
+ // records entry files named `*.js`); fail loudly instead of guessing a
150
+ // filename that would render a document which cannot hydrate.
151
+ 'virtual/react-router/unstable_rsc/bootstrap-scripts': `import { rscManifest } from "virtual/react-router/unstable_rsc/manifest-prefix";
152
+ const entryJsFiles = rscManifest?.entryJsFiles;
153
+ if (!entryJsFiles?.length) {
154
+ throw new Error(
155
+ "[rsbuild-plugin-react-router] The rspack RSC manifest lists no browser entry script (entryJsFiles is empty), so the server cannot render bootstrap scripts. Rspack only records entry files whose name ends in \\".js\\"; web output.filename.js values with a query (for example \\"[name].js?v=[contenthash:8]\\") or another extension are not supported in RSC mode."
156
+ );
157
+ }
158
+ export default entryJsFiles;
159
+ `,
117
160
  'virtual/react-router/unstable_rsc/server-manifest': `export default function getServerManifest() {
118
161
  return __webpack_require__.rscM?.serverManifest;
119
162
  }