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.
- package/README.md +34 -12
- package/dist/451.js +128 -10
- package/dist/build-output-transforms.d.ts +2 -1
- package/dist/dev-hmr.d.ts +45 -0
- package/dist/dev-runtime-controller.d.ts +6 -1
- package/dist/index.cjs +476 -29
- package/dist/index.d.ts +1 -0
- package/dist/index.js +346 -22
- package/dist/route-artifacts.d.ts +10 -2
- package/dist/route-transform-tasks.d.ts +3 -0
- package/package.json +8 -2
- package/src/build-output-transforms.ts +5 -0
- package/src/dev-hmr.ts +431 -0
- package/src/dev-runtime-controller.ts +47 -3
- package/src/index.ts +48 -0
- package/src/prerender-build.ts +29 -9
- package/src/prerender.ts +1 -1
- package/src/route-artifacts.ts +120 -1
- package/src/route-transform-tasks.ts +173 -2
package/src/route-artifacts.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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({
|