rsbuild-plugin-react-router 0.7.3 → 0.8.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.
@@ -1,3 +1,5 @@
1
+ import { BROWSER_MANIFEST_ENTRY_NAME } from './constants.js';
2
+ import { getManifestAssetType, stripAssetQuery } from './manifest-assets.js';
1
3
  import { createHash } from 'node:crypto';
2
4
  import type { Route, PluginOptions } from './types.js';
3
5
  import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core';
@@ -30,6 +32,8 @@ type CompilationAssetWithIntegrity = {
30
32
  source?: { source(): string | Buffer };
31
33
  info?: {
32
34
  integrity?: unknown;
35
+ assetType?: string;
36
+ javascriptModule?: boolean;
33
37
  };
34
38
  };
35
39
 
@@ -79,14 +83,10 @@ const toManifestAssetUrl = (assetPrefix: string, assetName: string) => {
79
83
  const addIntegrity = (
80
84
  sri: Record<string, string>,
81
85
  assetPrefix: string,
82
- assetName: unknown,
86
+ assetName: string,
83
87
  integrity: unknown
84
88
  ) => {
85
- if (
86
- typeof assetName !== 'string' ||
87
- !isManifestJsAsset(assetName) ||
88
- typeof integrity !== 'string'
89
- ) {
89
+ if (typeof integrity !== 'string') {
90
90
  return;
91
91
  }
92
92
  sri[toManifestAssetUrl(assetPrefix, assetName)] = integrity;
@@ -111,6 +111,9 @@ export const collectSubresourceIntegrity = (
111
111
  const sri: Record<string, string> = {};
112
112
 
113
113
  for (const asset of stats?.assets ?? []) {
114
+ if (typeof asset.name !== 'string' || !isManifestJsAsset(asset.name)) {
115
+ continue;
116
+ }
114
117
  addIntegrity(sri, assetPrefix, asset.name, asset.integrity);
115
118
  }
116
119
 
@@ -118,7 +121,17 @@ export const collectSubresourceIntegrity = (
118
121
  const assets =
119
122
  compilation.getAssets() as readonly CompilationAssetWithIntegrity[];
120
123
  for (const asset of assets) {
121
- if (!isManifestJsAsset(asset.name)) {
124
+ const assetType =
125
+ asset.info?.assetType ??
126
+ (typeof asset.info?.javascriptModule === 'boolean'
127
+ ? 'javascript'
128
+ : undefined);
129
+ if (
130
+ getManifestAssetType(
131
+ asset.name,
132
+ assetType === undefined ? undefined : { [asset.name]: assetType }
133
+ ) !== 'javascript'
134
+ ) {
122
135
  continue;
123
136
  }
124
137
  addIntegrity(
@@ -144,13 +157,15 @@ export function registerModifyBrowserManifestAssets(
144
157
  ): void {
145
158
  const getAssetPrefix =
146
159
  typeof assetPrefix === 'function' ? assetPrefix : () => assetPrefix;
147
- const manifestChunkNames =
160
+ const manifestChunkNames = new Set(
148
161
  options?.manifestChunkNames ??
149
- getReactRouterManifestChunkNames(
150
- routes,
151
- appDirectory,
152
- routeChunkOptions?.splitRouteModules
153
- );
162
+ getReactRouterManifestChunkNames(
163
+ routes,
164
+ appDirectory,
165
+ routeChunkOptions?.splitRouteModules
166
+ )
167
+ );
168
+ manifestChunkNames.add(BROWSER_MANIFEST_ENTRY_NAME);
154
169
  const isBuild = Boolean(routeChunkOptions?.isBuild);
155
170
  const finalizeSri = Boolean(
156
171
  isBuild &&
@@ -165,6 +180,7 @@ export function registerModifyBrowserManifestAssets(
165
180
  { assets, sources, compilation }: ManifestProcessAssetsContext,
166
181
  { withSri }: { withSri: boolean }
167
182
  ): Promise<void> => {
183
+ if (compilation.errors?.length) return;
168
184
  const currentAssetPrefix = getAssetPrefix();
169
185
  const stats = createReactRouterManifestStats(
170
186
  compilation,
@@ -185,8 +201,17 @@ export function registerModifyBrowserManifestAssets(
185
201
  const browserManifest = { ...manifest };
186
202
  delete browserManifest.sri;
187
203
 
188
- const browserManifestAsset = assets[BROWSER_MANIFEST_ASSET];
189
- if (browserManifestAsset) {
204
+ const browserManifestPaths = stats?.assetsByChunkName?.[
205
+ BROWSER_MANIFEST_ENTRY_NAME
206
+ ]?.filter(
207
+ name =>
208
+ getManifestAssetType(name, stats.assetTypesByName) === 'javascript'
209
+ ) ?? [BROWSER_MANIFEST_ASSET];
210
+ // Production consumes the separately emitted versioned manifest. Leave the
211
+ // placeholder chunk unchanged: its content hash has already been finalized.
212
+ for (const browserManifestPath of isBuild ? [] : browserManifestPaths) {
213
+ const browserManifestAsset = assets[browserManifestPath];
214
+ if (!browserManifestAsset) continue;
190
215
  const originalSource = browserManifestAsset.source().toString();
191
216
  const serializedManifest = jsesc(browserManifest, { es6: true });
192
217
  const newSource = originalSource.replace(
@@ -194,18 +219,22 @@ export function registerModifyBrowserManifestAssets(
194
219
  () => serializedManifest
195
220
  );
196
221
  compilation.updateAsset(
197
- BROWSER_MANIFEST_ASSET,
222
+ browserManifestPath,
198
223
  new sources.RawSource(newSource)
199
224
  );
200
225
  }
201
226
 
202
227
  if (isBuild) {
203
228
  const entryAssets = stats?.assetsByChunkName?.['entry.client'];
204
- const entryJsAssets = entryAssets?.filter(isManifestJsAsset) || [];
229
+ const entryJsAssets =
230
+ entryAssets?.filter(
231
+ name =>
232
+ getManifestAssetType(name, stats?.assetTypesByName) === 'javascript'
233
+ ) || [];
205
234
  const manifestPath = getReactRouterManifestPath({
206
235
  version: manifest.version,
207
236
  isBuild: true,
208
- entryModulePath: entryJsAssets[0],
237
+ entryModulePath: stripAssetQuery(entryJsAssets[0] ?? ''),
209
238
  });
210
239
  const manifestSource = `window.__reactRouterManifest=${jsesc(
211
240
  browserManifest,
@@ -0,0 +1,52 @@
1
+ import { rspack, type RsbuildPluginAPI, type Rspack } from '@rsbuild/core';
2
+ import { resolve } from 'pathe';
3
+ import type { ReactRouterManifestSnapshot } from './manifest-snapshot.js';
4
+ import type { Route } from './types.js';
5
+
6
+ // Inspect the compiled graph rather than parsing source: loaders, re-exports,
7
+ // and cached modules must all participate in the compatibility check.
8
+ export const registerNodeOnlyManifestValidation = ({
9
+ api,
10
+ routeByFilePath,
11
+ getSnapshot,
12
+ }: {
13
+ api: RsbuildPluginAPI;
14
+ routeByFilePath: ReadonlyMap<string, Route>;
15
+ getSnapshot: () => ReactRouterManifestSnapshot | null;
16
+ }): void => {
17
+ api.modifyRspackConfig((config, { environment }) => {
18
+ if (environment.name !== 'node') return;
19
+ config.plugins ??= [];
20
+ config.plugins.push({
21
+ apply(compiler: Rspack.Compiler) {
22
+ const name = 'ReactRouterNodeOnlyManifestValidation';
23
+ compiler.hooks.thisCompilation.tap(name, compilation => {
24
+ compilation.hooks.afterOptimizeModules.tap(name, modules => {
25
+ const snapshot = getSnapshot();
26
+ if (!snapshot) return;
27
+ for (const module of modules) {
28
+ if (!(module instanceof rspack.NormalModule)) continue;
29
+ const route = routeByFilePath.get(
30
+ resolve(module.resource.split('?')[0])
31
+ );
32
+ if (!route) continue;
33
+ const exports =
34
+ compilation.moduleGraph.getProvidedExports(module);
35
+ const saved = snapshot.browser.routes[route.id];
36
+ if (
37
+ !Array.isArray(exports) ||
38
+ !saved ||
39
+ exports.includes('loader') !== saved.hasLoader ||
40
+ exports.includes('action') !== saved.hasAction
41
+ ) {
42
+ throw new Error(
43
+ `Run a full build before building only the node environment: loader/action exports changed for route "${route.id}".`
44
+ );
45
+ }
46
+ }
47
+ });
48
+ });
49
+ },
50
+ });
51
+ });
52
+ };
@@ -6,10 +6,14 @@ import { JS_EXTENSIONS } from './constants.js';
6
6
  const requireFromApp = createRequire(resolve(process.cwd(), 'package.json'));
7
7
 
8
8
  export const resolveAppPackagePath = (
9
- specifier: string
9
+ specifier: string,
10
+ rootPath?: string
10
11
  ): string | undefined => {
11
12
  try {
12
- return requireFromApp.resolve(specifier);
13
+ const require = rootPath
14
+ ? createRequire(resolve(rootPath, 'package.json'))
15
+ : requireFromApp;
16
+ return require.resolve(specifier);
13
17
  } catch {
14
18
  return undefined;
15
19
  }
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  Analyzer,
3
+ SymbolFlags,
4
+ type Export as YukuExport,
3
5
  type Module,
6
+ type Reference as YukuReference,
4
7
  type Symbol as YukuSymbol,
5
8
  } from 'yuku-analyzer';
6
9
  import { print } from 'yuku-codegen';
7
- import { walk } from 'yuku-parser';
10
+ import { walk, type Node } from 'yuku-parser';
8
11
  import { dirname, normalize, relative, resolve } from 'pathe';
9
12
  import { SERVER_ONLY_ROUTE_EXPORTS_SET } from './constants.js';
10
13
  import { createRouteId } from './plugin-utils.js';
@@ -146,6 +149,7 @@ type ExportDependencies = {
146
149
  importedIdentifierNames: Set<string>;
147
150
  importSources: Set<string>;
148
151
  exportedVariableDeclarators: Set<AnyNode>;
152
+ exportedLocalSymbols: Set<YukuSymbol>;
149
153
  };
150
154
 
151
155
  const getTopLevelStatementForNode = (
@@ -162,30 +166,30 @@ const getTopLevelStatementForNode = (
162
166
  return current;
163
167
  };
164
168
 
165
- const getVariableDeclaratorForNode = (
169
+ const getExportedVariableDeclaratorForNode = (
166
170
  module: Module,
167
171
  node: AnyNode
168
172
  ): AnyNode | null => {
169
- let current: AnyNode | null = node;
170
- while (current) {
171
- if (current.type === 'VariableDeclarator') {
172
- return current;
173
+ let current = node as Node;
174
+ while (true) {
175
+ const parent = module.parentOf(current);
176
+ if (!parent || parent.type === 'Program') {
177
+ return null;
173
178
  }
174
- current = module.parentOf(current as never) as AnyNode | null;
175
- }
176
- return null;
177
- };
178
-
179
- const isTopLevelExportedVariableDeclarator = (
180
- module: Module,
181
- node: AnyNode
182
- ): boolean => {
183
- const declaration = module.parentOf(node as never) as AnyNode | null;
184
- if (declaration?.type !== 'VariableDeclaration') {
185
- return false;
179
+ if (
180
+ current.type === 'VariableDeclarator' &&
181
+ parent.type === 'VariableDeclaration'
182
+ ) {
183
+ const exported = module.parentOf(parent);
184
+ if (
185
+ exported?.type === 'ExportNamedDeclaration' &&
186
+ module.parentOf(exported)?.type === 'Program'
187
+ ) {
188
+ return current;
189
+ }
190
+ }
191
+ current = parent;
186
192
  }
187
- const statement = module.parentOf(declaration as never) as AnyNode | null;
188
- return statement?.type === 'ExportNamedDeclaration';
189
193
  };
190
194
 
191
195
  const getExportedName = (exported: AnyNode): string => {
@@ -241,9 +245,29 @@ const getExportDependencies = (
241
245
  code,
242
246
  () => {
243
247
  const { module } = analyzeCode(code, cache, cacheKey);
248
+ const namedExports = module.exports.filter(
249
+ (exp): exp is YukuExport & { name: string } =>
250
+ exp.name !== null &&
251
+ !exp.typeOnly &&
252
+ !exp.isStar &&
253
+ !exp.isExportEquals
254
+ );
255
+ // Removing type declarations can change legacy decorator metadata and
256
+ // name hygiene. Preserve the original dependency graph for decorated
257
+ // modules because the downstream compiler options are not known here.
258
+ let hasDecorators = false;
259
+ walk(module.ast, {
260
+ Decorator(_node, context) {
261
+ hasDecorators = true;
262
+ context.stop();
263
+ },
264
+ });
244
265
  const exportDependencies = new Map<string, ExportDependencies>();
245
266
  const topLevelStatementCache = new Map<AnyNode, AnyNode>();
246
- const variableDeclaratorCache = new Map<AnyNode, AnyNode | null>();
267
+ const exportedVariableDeclaratorCache = new Map<
268
+ AnyNode,
269
+ AnyNode | null
270
+ >();
247
271
  const getCachedTopLevelStatementForNode = (node: AnyNode): AnyNode => {
248
272
  const cached = topLevelStatementCache.get(node);
249
273
  if (cached) {
@@ -254,14 +278,67 @@ const getExportDependencies = (
254
278
  return statement;
255
279
  };
256
280
 
257
- const getCachedVariableDeclaratorForNode = (
281
+ // Ordinary imports can be repeated in multiple chunks. Exported local
282
+ // bindings must keep a single owner, including functions and classes.
283
+ const nonShareableExportedSymbols = new Set<YukuSymbol>();
284
+ for (const { local } of namedExports) {
285
+ if (!local?.has(SymbolFlags.ValueSpace | SymbolFlags.ValueImport)) {
286
+ continue;
287
+ }
288
+ const isImport = local.declarations.every(
289
+ declaration =>
290
+ getCachedTopLevelStatementForNode(declaration).type ===
291
+ 'ImportDeclaration'
292
+ );
293
+ // Setup belongs to the imported value too. If another export consumes
294
+ // that value, moving its setup into a separate chunk changes behavior.
295
+ const hasSetup =
296
+ isImport &&
297
+ local.references.some(reference => {
298
+ const statement = getCachedTopLevelStatementForNode(reference.node);
299
+ return (
300
+ reference.kind === 'value' &&
301
+ statement.type !== 'ImportDeclaration' &&
302
+ !statement.type.startsWith('Export')
303
+ );
304
+ });
305
+ if (isImport && !hasSetup) continue;
306
+ nonShareableExportedSymbols.add(local);
307
+ }
308
+
309
+ const isValueImportEqualsReference = (
310
+ reference: YukuReference
311
+ ): boolean => {
312
+ let node: Node = reference.node;
313
+ let parent = module.parentOf(node);
314
+ while (parent?.type === 'TSQualifiedName') {
315
+ node = parent;
316
+ parent = module.parentOf(node);
317
+ }
318
+ // Yuku also marks the runtime RHS of `import x = Namespace.value`
319
+ // as a type reference.
320
+ return (
321
+ parent?.type === 'TSImportEqualsDeclaration' &&
322
+ parent.moduleReference === node &&
323
+ parent.importKind !== 'type'
324
+ );
325
+ };
326
+
327
+ const isRuntimeRelevantReference = (reference: YukuReference): boolean =>
328
+ hasDecorators ||
329
+ reference.kind === 'value' ||
330
+ isValueImportEqualsReference(reference);
331
+
332
+ const getCachedExportedVariableDeclaratorForNode = (
258
333
  node: AnyNode
259
334
  ): AnyNode | null => {
260
- if (variableDeclaratorCache.has(node)) {
261
- return variableDeclaratorCache.get(node) ?? null;
335
+ if (exportedVariableDeclaratorCache.has(node)) {
336
+ return exportedVariableDeclaratorCache.get(node) ?? null;
262
337
  }
263
- const declarator = getVariableDeclaratorForNode(module, node);
264
- variableDeclaratorCache.set(node, declarator);
338
+ // Only direct exported declarators can be emitted independently.
339
+ // Every other top-level statement is moved as a whole.
340
+ const declarator = getExportedVariableDeclaratorForNode(module, node);
341
+ exportedVariableDeclaratorCache.set(node, declarator);
265
342
  return declarator;
266
343
  };
267
344
 
@@ -291,22 +368,41 @@ const getExportDependencies = (
291
368
  importedIdentifierNames: new Set(),
292
369
  importSources: new Set(),
293
370
  exportedVariableDeclarators: new Set(),
371
+ exportedLocalSymbols: new Set(),
294
372
  };
295
373
  const visitedSymbols = new Set<YukuSymbol>();
296
374
  const scannedNodes = new Set<AnyNode>();
297
375
 
376
+ const visitIdentifier = (node: YukuReference['node']) => {
377
+ const reference = module.referenceOf(node);
378
+ if (reference) {
379
+ if (reference.symbol && isRuntimeRelevantReference(reference)) {
380
+ visitSymbol(reference.symbol);
381
+ }
382
+ return;
383
+ }
384
+ const symbol = module.symbolOf(node);
385
+ if (
386
+ symbol?.scope === module.rootScope &&
387
+ symbol.has(SymbolFlags.ValueSpace | SymbolFlags.ValueImport) &&
388
+ dependencies.topLevelNonModuleStatements.has(
389
+ getCachedTopLevelStatementForNode(node)
390
+ )
391
+ ) {
392
+ // Moving a statement also moves the bindings it declares. Follow
393
+ // their consumers so no references remain in another chunk.
394
+ visitSymbol(symbol);
395
+ }
396
+ };
397
+
298
398
  const scanNode = (node: AnyNode) => {
299
399
  if (scannedNodes.has(node)) {
300
400
  return;
301
401
  }
302
402
  scannedNodes.add(node);
303
403
  walk(node as any, {
304
- Identifier(node: AnyNode) {
305
- const reference = module.referenceOf(node as never);
306
- if (reference?.symbol) {
307
- visitSymbol(reference.symbol);
308
- }
309
- },
404
+ Identifier: visitIdentifier,
405
+ JSXIdentifier: visitIdentifier,
310
406
  });
311
407
  };
312
408
 
@@ -318,6 +414,9 @@ const getExportDependencies = (
318
414
  if (symbol.declarations.length === 0) {
319
415
  return;
320
416
  }
417
+ if (nonShareableExportedSymbols.has(symbol)) {
418
+ dependencies.exportedLocalSymbols.add(symbol);
419
+ }
321
420
 
322
421
  for (const declaration of symbol.declarations as AnyNode[]) {
323
422
  const statement = addCachedTopLevelStatement(
@@ -329,24 +428,27 @@ const getExportDependencies = (
329
428
  if (typeof statement.source?.value === 'string') {
330
429
  dependencies.importSources.add(statement.source.value);
331
430
  }
332
- return;
431
+ // Ordinary imports are shareable; a directly exported import
432
+ // also owns setup statements such as `load.hydrate = true`.
433
+ if (symbol !== localSymbol) return;
333
434
  }
334
- const declarator = getCachedVariableDeclaratorForNode(declaration);
335
- if (
336
- declarator &&
337
- isTopLevelExportedVariableDeclarator(module, declarator)
338
- ) {
435
+ const declarator =
436
+ getCachedExportedVariableDeclaratorForNode(declaration);
437
+ if (declarator) {
339
438
  dependencies.exportedVariableDeclarators.add(declarator);
340
439
  }
341
440
  scanNode(declarator ?? statement);
342
441
  }
343
442
 
344
- for (const reference of symbol.references as any[]) {
443
+ for (const reference of symbol.references) {
444
+ if (!isRuntimeRelevantReference(reference)) {
445
+ continue;
446
+ }
345
447
  const statement = addCachedTopLevelStatement(
346
448
  dependencies,
347
449
  reference.node
348
450
  );
349
- const declarator = getCachedVariableDeclaratorForNode(
451
+ const declarator = getCachedExportedVariableDeclaratorForNode(
350
452
  reference.node
351
453
  );
352
454
  scanNode(declarator ?? statement);
@@ -365,10 +467,7 @@ const getExportDependencies = (
365
467
  exportDependencies.set(exportName, dependencies);
366
468
  };
367
469
 
368
- for (const exp of module.exports as any[]) {
369
- if (exp.typeOnly || exp.isStar || exp.isExportEquals) {
370
- continue;
371
- }
470
+ for (const exp of namedExports) {
372
471
  handleExport(exp.name, exp.node as AnyNode, exp.local ?? null);
373
472
  }
374
473
 
@@ -383,7 +482,7 @@ const isExportChunkable = (
383
482
  importer: string
384
483
  ) => {
385
484
  const dependencies = exportDependencies.get(exportName);
386
- if (!dependencies) {
485
+ if (!dependencies || dependencies.exportedVariableDeclarators.size > 1) {
387
486
  return false;
388
487
  }
389
488
  if (exportName === 'clientLoader' && hasHydrateAssignment(dependencies)) {
@@ -403,29 +502,19 @@ const isExportChunkable = (
403
502
  setsIntersect(
404
503
  currentDependencies.topLevelNonModuleStatements,
405
504
  dependencies.topLevelNonModuleStatements
505
+ ) ||
506
+ setsIntersect(
507
+ currentDependencies.exportedVariableDeclarators,
508
+ dependencies.exportedVariableDeclarators
509
+ ) ||
510
+ setsIntersect(
511
+ currentDependencies.exportedLocalSymbols,
512
+ dependencies.exportedLocalSymbols
406
513
  )
407
514
  ) {
408
515
  return false;
409
516
  }
410
517
  }
411
- if (dependencies.exportedVariableDeclarators.size > 1) {
412
- return false;
413
- }
414
- if (dependencies.exportedVariableDeclarators.size > 0) {
415
- for (const [currentExportName, currentDependencies] of exportDependencies) {
416
- if (currentExportName === exportName) {
417
- continue;
418
- }
419
- if (
420
- setsIntersect(
421
- currentDependencies.exportedVariableDeclarators,
422
- dependencies.exportedVariableDeclarators
423
- )
424
- ) {
425
- return false;
426
- }
427
- }
428
- }
429
518
  return true;
430
519
  };
431
520
 
package/src/types.ts CHANGED
@@ -11,6 +11,13 @@ export type Route = {
11
11
  };
12
12
 
13
13
  export type PluginOptions = {
14
+ /**
15
+ * Generate React Router route types during development and builds.
16
+ * Set to false when type generation is managed separately.
17
+ * @default true
18
+ */
19
+ typegen?: boolean;
20
+
14
21
  /**
15
22
  * Whether to disable automatic middleware setup for custom server implementation.
16
23
  * Use this when you want to handle server setup manually.