rsbuild-plugin-react-router 0.3.1 → 0.4.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,5 +1,9 @@
1
+ import { basename } from 'pathe';
2
+
1
3
  import {
4
+ CLIENT_EXPORTS,
2
5
  CLIENT_ROUTE_EXPORTS_SET,
6
+ SERVER_EXPORTS,
3
7
  SERVER_ONLY_ROUTE_EXPORTS_SET,
4
8
  } from './constants.js';
5
9
  import { getExportNames } from './export-utils.js';
@@ -22,6 +26,10 @@ export type RouteClientEntryArtifactOptions = {
22
26
  isBuild: boolean;
23
27
  routeChunkCache?: RouteChunkCache;
24
28
  routeChunkConfig: RouteChunkConfig;
29
+ /** React Router route id for this route module, when known. */
30
+ routeId?: string;
31
+ /** Emit development HMR/HDR glue into web route client entries. */
32
+ devHmr?: boolean;
25
33
  };
26
34
 
27
35
  type RouteClientEntryArtifact = {
@@ -42,16 +50,111 @@ type RouteChunkArtifact = {
42
50
  map: null;
43
51
  };
44
52
 
53
+ // Exactly the route-manifest flags the client HMR runtime can patch in place
54
+ // without a full reload. Array order defines the bit layout shared by the
55
+ // encoder below and the decoder emitted in `generateDevHmrRuntimeModule`.
56
+ export const HMR_PATCHABLE_ROUTE_FLAGS = [
57
+ 'hasAction',
58
+ 'hasClientAction',
59
+ 'hasClientLoader',
60
+ 'hasClientMiddleware',
61
+ 'hasErrorBoundary',
62
+ 'hasLoader',
63
+ ] as const;
64
+
65
+ const HMR_FLAG_EXPORT_NAME: Record<
66
+ (typeof HMR_PATCHABLE_ROUTE_FLAGS)[number],
67
+ string
68
+ > = {
69
+ hasAction: SERVER_EXPORTS.action,
70
+ hasClientAction: CLIENT_EXPORTS.clientAction,
71
+ hasClientLoader: CLIENT_EXPORTS.clientLoader,
72
+ hasClientMiddleware: CLIENT_EXPORTS.clientMiddleware,
73
+ hasErrorBoundary: CLIENT_EXPORTS.ErrorBoundary,
74
+ hasLoader: SERVER_EXPORTS.loader,
75
+ };
76
+
77
+ export const buildRouteHmrFlags = (exportNames: readonly string[]): number => {
78
+ const exports = new Set(exportNames);
79
+ let flags = 0;
80
+ HMR_PATCHABLE_ROUTE_FLAGS.forEach((flag, index) => {
81
+ if (exports.has(HMR_FLAG_EXPORT_NAME[flag])) flags |= 1 << index;
82
+ });
83
+ return flags;
84
+ };
85
+
86
+ /**
87
+ * Development-only HMR glue for a web route client entry.
88
+ *
89
+ * The route client entry is the webpack entry for a route, so it must
90
+ * self-accept hot updates to stop them from bubbling into a full reload. It
91
+ * also accepts updates of the underlying route module and forwards fresh
92
+ * exports plus route metadata (loader/action flags derived from the current
93
+ * export names) to the shared HMR runtime, which applies the React Router
94
+ * route-module update contract and revalidates loader data.
95
+ */
96
+ const buildRouteClientEntryHmrCode = ({
97
+ routeId,
98
+ target,
99
+ acceptTarget,
100
+ flags,
101
+ }: {
102
+ routeId: string;
103
+ target: string;
104
+ acceptTarget: string;
105
+ flags: number;
106
+ }): string => {
107
+ const targetJson = JSON.stringify(target);
108
+ const acceptTargetJson = JSON.stringify(acceptTarget);
109
+ return `
110
+ import * as __rrm from ${targetJson};
111
+ import {
112
+ registerReactRouterRouteExports as __rrr,
113
+ scheduleReactRouterRouteUpdate as __rru,
114
+ } from "virtual/react-router/hmr-runtime";
115
+
116
+ const __rrid = ${JSON.stringify(routeId)};
117
+ const __rrf = ${flags};
118
+ const __rrg = () => __rrm;
119
+ const __rru0 = () => {
120
+ __rrr(__rrid, __rrm);
121
+ __rru(__rrid, __rrf, __rrg);
122
+ };
123
+
124
+ __rrr(__rrid, __rrm);
125
+
126
+ if (import.meta.webpackHot) {
127
+ const __rrh = import.meta.webpackHot;
128
+ __rrh.accept(${acceptTargetJson}, __rru0);
129
+ __rrh.accept();
130
+ __rrh.dispose(data => { data.__rr = true; });
131
+ if (__rrh.data && __rrh.data.__rr) __rru0();
132
+ }
133
+ `;
134
+ };
135
+
136
+ // The accept target is spelled relative (`./name?react-router-route`) while
137
+ // the import above uses the absolute resource path; both resolve to the same
138
+ // module because the client entry replaces the route file in place, so its
139
+ // resolution context is the route's own directory.
140
+ const createRouteHmrAcceptTarget = (resourcePath: string): string => {
141
+ return `./${basename(resourcePath)}?react-router-route`;
142
+ };
143
+
45
144
  export const buildRouteClientEntryCode = ({
46
145
  exportNames,
47
146
  chunkedExports,
48
147
  isServer,
49
148
  resourcePath,
149
+ routeId,
150
+ devHmr,
50
151
  }: {
51
152
  exportNames: readonly string[];
52
153
  chunkedExports: readonly string[];
53
154
  isServer: boolean;
54
155
  resourcePath: string;
156
+ routeId?: string;
157
+ devHmr?: boolean;
55
158
  }): string => {
56
159
  const chunkedExportSet =
57
160
  chunkedExports.length > 0 ? new Set<string>(chunkedExports) : undefined;
@@ -67,7 +170,19 @@ export const buildRouteClientEntryCode = ({
67
170
  })
68
171
  .sort();
69
172
  const target = `${resourcePath}?react-router-route`;
70
- return `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`;
173
+ const reexportCode = `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`;
174
+ if (!devHmr || isServer || routeId === undefined) {
175
+ return reexportCode;
176
+ }
177
+ return (
178
+ reexportCode +
179
+ buildRouteClientEntryHmrCode({
180
+ routeId,
181
+ target,
182
+ acceptTarget: createRouteHmrAcceptTarget(resourcePath),
183
+ flags: buildRouteHmrFlags(exportNames),
184
+ })
185
+ );
71
186
  };
72
187
 
73
188
  export const createRouteClientEntryArtifact = async ({
@@ -77,6 +192,8 @@ export const createRouteClientEntryArtifact = async ({
77
192
  isBuild,
78
193
  routeChunkCache,
79
194
  routeChunkConfig,
195
+ routeId,
196
+ devHmr,
80
197
  }: RouteClientEntryArtifactOptions): Promise<RouteClientEntryArtifact> => {
81
198
  const isServer = environmentName === 'node';
82
199
  const mightHaveRouteChunks =
@@ -100,6 +217,8 @@ export const createRouteClientEntryArtifact = async ({
100
217
  chunkedExports,
101
218
  isServer,
102
219
  resourcePath,
220
+ routeId,
221
+ devHmr: devHmr && !isBuild,
103
222
  }),
104
223
  };
105
224
  };
@@ -24,7 +24,10 @@ import {
24
24
  type RouteChunkCache,
25
25
  type RouteChunkConfig,
26
26
  } from './route-chunks.js';
27
- import { getProgram } from './route-ast.js';
27
+ import {
28
+ getProgram,
29
+ type AnyNode,
30
+ } from './route-ast.js';
28
31
 
29
32
  export type RouteTransformResult = {
30
33
  code: string;
@@ -41,6 +44,8 @@ export type RouteClientEntryTransformTask = BaseRouteTransformTask & {
41
44
  environmentName?: string;
42
45
  isBuild: boolean;
43
46
  routeChunkConfig: RouteChunkConfig;
47
+ routeId?: string;
48
+ devHmr?: boolean;
44
49
  };
45
50
 
46
51
  export type RouteChunkTransformTask = BaseRouteTransformTask & {
@@ -69,6 +74,7 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & {
69
74
  isBuild: boolean;
70
75
  isSpaMode: boolean;
71
76
  rootRoutePath: string | null;
77
+ devHmr?: boolean;
72
78
  };
73
79
 
74
80
  export type RouteTransformTask =
@@ -151,6 +157,158 @@ const createClientOnlyStub = async (
151
157
  };
152
158
  };
153
159
 
160
+ const isComponentishName = (name: string): boolean => /^[A-Z]/.test(name);
161
+
162
+ /**
163
+ * Whether an expression that appears as the first argument of a wrapping call
164
+ * resolves to a component, mirroring react-refresh/babel's
165
+ * `findInnerComponents` recursion for the node kinds it accepts as arguments.
166
+ */
167
+ const argumentResolvesToComponent = (node: AnyNode | undefined): boolean => {
168
+ switch (node?.type) {
169
+ case 'FunctionExpression':
170
+ return true;
171
+ case 'ArrowFunctionExpression':
172
+ // Babel bails on a curried arrow (an arrow whose body is another arrow):
173
+ // that is a component factory, not a component.
174
+ return node.body?.type !== 'ArrowFunctionExpression';
175
+ case 'Identifier':
176
+ return !!node.name && isComponentishName(node.name);
177
+ case 'CallExpression':
178
+ return callResolvesToComponent(node);
179
+ default:
180
+ return false;
181
+ }
182
+ };
183
+
184
+ /**
185
+ * Whether a `CallExpression` resolves to a component: it must have at least one
186
+ * argument, a callee that is not `Import`/`require*`/`import*`, and a first
187
+ * argument that itself resolves to a component.
188
+ */
189
+ const callResolvesToComponent = (node: AnyNode): boolean => {
190
+ const args = node.arguments ?? [];
191
+ if (args.length === 0) {
192
+ return false;
193
+ }
194
+ const callee = node.callee;
195
+ if (!callee || callee.type === 'Import') {
196
+ return false;
197
+ }
198
+ if (callee.type === 'Identifier') {
199
+ const calleeName = callee.name ?? '';
200
+ if (calleeName.startsWith('require') || calleeName.startsWith('import')) {
201
+ return false;
202
+ }
203
+ } else if (callee.type !== 'MemberExpression') {
204
+ return false;
205
+ }
206
+ return argumentResolvesToComponent(args[0]);
207
+ };
208
+
209
+ /**
210
+ * Whether a `VariableDeclarator` initializer resolves to a component, matching
211
+ * react-refresh/babel's accepted init kinds: a non-curried arrow, a function
212
+ * expression, a tagged template, or a qualifying call expression.
213
+ */
214
+ const initResolvesToComponent = (init: AnyNode): boolean => {
215
+ switch (init.type) {
216
+ case 'FunctionExpression':
217
+ case 'TaggedTemplateExpression':
218
+ return true;
219
+ case 'ArrowFunctionExpression':
220
+ return init.body?.type !== 'ArrowFunctionExpression';
221
+ case 'CallExpression':
222
+ return callResolvesToComponent(init);
223
+ default:
224
+ return false;
225
+ }
226
+ };
227
+
228
+ const collectDeclaredComponentNames = (
229
+ declaration: AnyNode,
230
+ names: Set<string>
231
+ ): void => {
232
+ if (
233
+ declaration.type === 'FunctionDeclaration' &&
234
+ declaration.id?.name &&
235
+ isComponentishName(declaration.id.name)
236
+ ) {
237
+ names.add(declaration.id.name);
238
+ return;
239
+ }
240
+ if (declaration.type !== 'VariableDeclaration') {
241
+ return;
242
+ }
243
+ const declarators = declaration.declarations ?? [];
244
+ // Babel's react-refresh visitor only registers a `VariableDeclaration` with
245
+ // exactly one declarator; multi-declarator declarations are skipped whole.
246
+ if (declarators.length !== 1) {
247
+ return;
248
+ }
249
+ const [declarator] = declarators;
250
+ if (
251
+ declarator?.id?.type === 'Identifier' &&
252
+ declarator.id.name &&
253
+ isComponentishName(declarator.id.name) &&
254
+ declarator.init &&
255
+ initResolvesToComponent(declarator.init)
256
+ ) {
257
+ names.add(declarator.id.name);
258
+ }
259
+ };
260
+
261
+ /**
262
+ * Names of top-level components that still need a React Fast Refresh
263
+ * registration, following react-refresh/babel's name-based detection.
264
+ *
265
+ * SWC's refresh transform registers components in JSX/TSX sources, but
266
+ * compiled route modules whose JSX was already lowered by an earlier loader
267
+ * (e.g. MDX routes) reach it without JSX syntax and end up unregistered. An
268
+ * unregistered component has no refresh family, so hot updates remount its
269
+ * subtree instead of updating it in place.
270
+ */
271
+ const collectUnregisteredComponentNames = (program: {
272
+ body?: AnyNode[];
273
+ }): string[] => {
274
+ const declared = new Set<string>();
275
+ const registered = new Set<string>();
276
+ for (const statement of program.body ?? []) {
277
+ if (statement.type === 'ExportNamedDeclaration' && statement.declaration) {
278
+ collectDeclaredComponentNames(statement.declaration, declared);
279
+ continue;
280
+ }
281
+ if (
282
+ statement.type === 'ExpressionStatement' &&
283
+ statement.expression?.type === 'CallExpression' &&
284
+ statement.expression.callee?.type === 'Identifier' &&
285
+ statement.expression.callee.name === '$RefreshReg$'
286
+ ) {
287
+ const nameArgument = statement.expression.arguments?.[1];
288
+ if (typeof nameArgument?.value === 'string') {
289
+ registered.add(nameArgument.value);
290
+ }
291
+ continue;
292
+ }
293
+ collectDeclaredComponentNames(statement, declared);
294
+ }
295
+ return [...declared].filter(name => !registered.has(name));
296
+ };
297
+
298
+ const buildComponentRefreshRegistrations = (names: string[]): string => {
299
+ const registrations = names
300
+ .map(
301
+ name =>
302
+ // react-refresh's runtime `register()` tags plain functions *and*
303
+ // non-null exotic objects (memo/forwardRef `$$typeof` wrappers),
304
+ // ignoring everything else safely -- so mirror that guard here. The
305
+ // `typeof` short-circuit keeps this safe even for undeclared names.
306
+ ` if (typeof ${name} === 'function' || (typeof ${name} === 'object' && ${name} !== null)) $RefreshReg$(${name}, ${JSON.stringify(name)});`
307
+ )
308
+ .join('\n');
309
+ return `\nif (typeof $RefreshReg$ === 'function') {\n${registrations}\n}\n`;
310
+ };
311
+
154
312
  const transformRouteModule = async (
155
313
  task: RouteModuleTransformTask
156
314
  ): Promise<RouteTransformResult> => {
@@ -207,13 +365,24 @@ const transformRouteModule = async (
207
365
  removeUnusedImports(ast);
208
366
  }
209
367
 
210
- return generate(ast, {
368
+ const result = generate(ast, {
211
369
  // Rsbuild merges this map with its downstream SWC transform. Only pay the
212
370
  // code-generation cost when this environment actually emits JS maps.
213
371
  sourceMaps: task.sourceMaps,
214
372
  filename: task.resource,
215
373
  sourceFileName: task.resourcePath,
216
374
  });
375
+
376
+ if (task.devHmr && task.environmentName === 'web' && !task.isBuild) {
377
+ const unregisteredComponents = collectUnregisteredComponentNames(
378
+ getProgram(ast)
379
+ );
380
+ if (unregisteredComponents.length > 0) {
381
+ result.code += buildComponentRefreshRegistrations(unregisteredComponents);
382
+ }
383
+ }
384
+
385
+ return result;
217
386
  };
218
387
 
219
388
  export const executeRouteTransformTask = async (
@@ -229,6 +398,8 @@ export const executeRouteTransformTask = async (
229
398
  isBuild: task.isBuild,
230
399
  routeChunkCache: getRouteChunkCache(options),
231
400
  routeChunkConfig: task.routeChunkConfig,
401
+ routeId: task.routeId,
402
+ devHmr: task.devHmr,
232
403
  });
233
404
  case 'routeChunk':
234
405
  return createRouteChunkArtifact({