rsbuild-plugin-react-router 0.3.1 → 0.4.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/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 -30
- package/dist/index.d.ts +1 -0
- package/dist/index.js +346 -23
- 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 -1
- 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/dev-hmr.ts
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import type { Rspack } from '@rsbuild/core';
|
|
4
|
+
import { dirname, join } from 'pathe';
|
|
5
|
+
|
|
6
|
+
import { HMR_PATCHABLE_ROUTE_FLAGS } from './route-artifacts.js';
|
|
7
|
+
|
|
8
|
+
export const DEV_HMR_RUNTIME_MODULE_ID = 'virtual/react-router/hmr-runtime';
|
|
9
|
+
|
|
10
|
+
type SwcLoaderOptions = {
|
|
11
|
+
jsc?: { transform?: { react?: { refresh?: boolean } } };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
15
|
+
value !== null && typeof value === 'object';
|
|
16
|
+
|
|
17
|
+
const isSwcLoader = (loader: unknown): boolean =>
|
|
18
|
+
typeof loader === 'string' && loader.includes('builtin:swc-loader');
|
|
19
|
+
|
|
20
|
+
const hasReactRefresh = (options: unknown): boolean =>
|
|
21
|
+
(options as SwcLoaderOptions | undefined)?.jsc?.transform?.react?.refresh ===
|
|
22
|
+
true;
|
|
23
|
+
|
|
24
|
+
const readSwcLoaderRefresh = (value: unknown): boolean => {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value.some(readSwcLoaderRefresh);
|
|
27
|
+
}
|
|
28
|
+
if (!isObject(value)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (isSwcLoader(value.loader)) {
|
|
32
|
+
return hasReactRefresh(value.options);
|
|
33
|
+
}
|
|
34
|
+
return Object.values(value).some(readSwcLoaderRefresh);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const readRuleSwcRefresh = (rule: unknown): boolean => {
|
|
38
|
+
if (!isObject(rule)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (isSwcLoader(rule.loader)) {
|
|
42
|
+
return hasReactRefresh(rule.options);
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
readSwcLoaderRefresh(rule.use) ||
|
|
46
|
+
readRuleSetSwcRefresh(rule.oneOf) ||
|
|
47
|
+
readRuleSetSwcRefresh(rule.rules)
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const readRuleSetSwcRefresh = (rules: unknown): boolean =>
|
|
52
|
+
Array.isArray(rules) && rules.some(readRuleSwcRefresh);
|
|
53
|
+
|
|
54
|
+
export const isRspackSwcReactRefreshEnabled = (
|
|
55
|
+
rspackConfig: Rspack.Configuration
|
|
56
|
+
): boolean => readRuleSetSwcRefresh(rspackConfig.module?.rules);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves the `react-refresh/runtime` module that
|
|
60
|
+
* `@rspack/plugin-react-refresh` injects into the web bundle. The resolution
|
|
61
|
+
* walks the same dependency chain the refresh plugin uses so the returned file
|
|
62
|
+
* is the exact runtime instance already present in the browser module graph.
|
|
63
|
+
* Returns `undefined` when React Fast Refresh is unavailable, in which case
|
|
64
|
+
* dev HMR falls back to full reloads.
|
|
65
|
+
*/
|
|
66
|
+
export const resolveReactRefreshRuntimePath = (
|
|
67
|
+
rootPath: string
|
|
68
|
+
): string | undefined => {
|
|
69
|
+
const resolveFrom = (base: string, request: string): string =>
|
|
70
|
+
createRequire(base).resolve(request);
|
|
71
|
+
const rootPackageJson = join(rootPath, 'package.json');
|
|
72
|
+
try {
|
|
73
|
+
const pluginReactEntry = resolveFrom(
|
|
74
|
+
rootPackageJson,
|
|
75
|
+
'@rsbuild/plugin-react'
|
|
76
|
+
);
|
|
77
|
+
const refreshPluginEntry = resolveFrom(
|
|
78
|
+
pluginReactEntry,
|
|
79
|
+
'@rspack/plugin-react-refresh'
|
|
80
|
+
);
|
|
81
|
+
return resolveFrom(refreshPluginEntry, 'react-refresh/runtime');
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const hdrRevisionModuleContent = (revision: number): string =>
|
|
88
|
+
`export default ${revision};\n`;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The HDR revision module is a real file (not a virtual module) because it
|
|
92
|
+
* must wake the web compiler through the regular file watcher: the browser
|
|
93
|
+
* HMR runtime imports it, so bumping the revision produces a web hot update
|
|
94
|
+
* whenever server code changes, which the client answers by revalidating
|
|
95
|
+
* React Router loader data.
|
|
96
|
+
*/
|
|
97
|
+
export const DEV_HDR_REVISION_RELATIVE_PATH = '.react-router/hdr-revision.mjs';
|
|
98
|
+
|
|
99
|
+
export const getDevHdrRevisionFilePath = (rootPath: string): string =>
|
|
100
|
+
join(rootPath, DEV_HDR_REVISION_RELATIVE_PATH);
|
|
101
|
+
|
|
102
|
+
export type DevHdrRevisionSignal = {
|
|
103
|
+
/** Writes the initial revision module so the first compile can resolve it. */
|
|
104
|
+
ensure: () => void;
|
|
105
|
+
/** Increments the revision, signaling hot data revalidation to the client. */
|
|
106
|
+
bump: () => void;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const createDevHdrRevisionSignal = ({
|
|
110
|
+
filePath,
|
|
111
|
+
onError,
|
|
112
|
+
}: {
|
|
113
|
+
filePath: string;
|
|
114
|
+
onError?: (error: Error) => void;
|
|
115
|
+
}): DevHdrRevisionSignal => {
|
|
116
|
+
let revision = 0;
|
|
117
|
+
let dirEnsured = false;
|
|
118
|
+
const write = (): void => {
|
|
119
|
+
try {
|
|
120
|
+
if (!dirEnsured) {
|
|
121
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
122
|
+
dirEnsured = true;
|
|
123
|
+
}
|
|
124
|
+
writeFileSync(filePath, hdrRevisionModuleContent(revision));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
ensure: write,
|
|
131
|
+
bump() {
|
|
132
|
+
revision += 1;
|
|
133
|
+
write();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Browser-side HMR runtime shared by all route client entries in development.
|
|
140
|
+
*
|
|
141
|
+
* This mirrors React Router's Vite HMR contract (see `refresh-utils.mjs` in
|
|
142
|
+
* `@react-router/dev`): route module updates are applied by patching
|
|
143
|
+
* `window.__reactRouterRouteModules` while preserving the previous component
|
|
144
|
+
* identities (React Fast Refresh swaps their implementations in place),
|
|
145
|
+
* recreating the client routes with revalidation opt-out, revalidating loader
|
|
146
|
+
* data, and finally performing a React refresh.
|
|
147
|
+
*/
|
|
148
|
+
export const generateDevHmrRuntimeModule = ({
|
|
149
|
+
reactRefreshRuntimePath,
|
|
150
|
+
hdrRevisionFilePath,
|
|
151
|
+
}: {
|
|
152
|
+
reactRefreshRuntimePath: string;
|
|
153
|
+
hdrRevisionFilePath: string;
|
|
154
|
+
}): string => `
|
|
155
|
+
import * as __refreshRuntimeModule from ${JSON.stringify(reactRefreshRuntimePath)};
|
|
156
|
+
// Read revision so the import survives sideEffects: false tree-shaking.
|
|
157
|
+
import __hdrRevision from ${JSON.stringify(hdrRevisionFilePath)};
|
|
158
|
+
|
|
159
|
+
void __hdrRevision;
|
|
160
|
+
|
|
161
|
+
const RefreshRuntime =
|
|
162
|
+
__refreshRuntimeModule && __refreshRuntimeModule.performReactRefresh
|
|
163
|
+
? __refreshRuntimeModule
|
|
164
|
+
: __refreshRuntimeModule.default;
|
|
165
|
+
|
|
166
|
+
const pendingRouteUpdates = new Map();
|
|
167
|
+
let flushTimeout;
|
|
168
|
+
let pendingRevalidation = false;
|
|
169
|
+
|
|
170
|
+
function getCurrentRouterPath(router) {
|
|
171
|
+
const basename = router.basename || '/';
|
|
172
|
+
let pathname = window.location.pathname;
|
|
173
|
+
if (basename !== '/' && pathname.startsWith(basename)) {
|
|
174
|
+
pathname = pathname.slice(basename.length) || '/';
|
|
175
|
+
// A trailing-slash basename (e.g. "/mybase/") consumes the leading slash,
|
|
176
|
+
// leaving a relative path that react-router resolves against the current
|
|
177
|
+
// location and doubles. Force it back to absolute.
|
|
178
|
+
if (pathname[0] !== '/') pathname = '/' + pathname;
|
|
179
|
+
}
|
|
180
|
+
return pathname + window.location.search + window.location.hash;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function registerReactRouterRouteExports(routeId, moduleExports) {
|
|
184
|
+
if (
|
|
185
|
+
typeof window === 'undefined' ||
|
|
186
|
+
!RefreshRuntime ||
|
|
187
|
+
typeof RefreshRuntime.register !== 'function'
|
|
188
|
+
) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const key in moduleExports) {
|
|
192
|
+
if (key === '__esModule') continue;
|
|
193
|
+
const exportValue = moduleExports[key];
|
|
194
|
+
if (RefreshRuntime.isLikelyComponentType(exportValue)) {
|
|
195
|
+
RefreshRuntime.register(exportValue, routeId + ' export ' + key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function scheduleReactRouterRouteUpdate(
|
|
201
|
+
routeId,
|
|
202
|
+
routeFlags,
|
|
203
|
+
getRouteModuleExports
|
|
204
|
+
) {
|
|
205
|
+
pendingRouteUpdates.set(routeId, { routeFlags, getRouteModuleExports });
|
|
206
|
+
scheduleFlush();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function scheduleReactRouterRevalidation() {
|
|
210
|
+
pendingRevalidation = true;
|
|
211
|
+
scheduleFlush();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function scheduleFlush() {
|
|
215
|
+
if (typeof window === 'undefined') {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
clearTimeout(flushTimeout);
|
|
219
|
+
flushTimeout = setTimeout(flush, 16);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function takePendingRouteUpdates() {
|
|
223
|
+
const updates = Array.from(pendingRouteUpdates, ([routeId, update]) => ({
|
|
224
|
+
routeId,
|
|
225
|
+
update,
|
|
226
|
+
}));
|
|
227
|
+
pendingRouteUpdates.clear();
|
|
228
|
+
return updates;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getRouteMetadata(routeFlags) {
|
|
232
|
+
return {
|
|
233
|
+
${HMR_PATCHABLE_ROUTE_FLAGS.map(
|
|
234
|
+
(flag, index) => ` ${flag}: Boolean(routeFlags & ${1 << index}),`
|
|
235
|
+
).join('\n')}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function applyRouteModuleUpdate(routeId, update, routeEntry, routeModules) {
|
|
240
|
+
Object.assign(routeEntry, getRouteMetadata(update.routeFlags));
|
|
241
|
+
const imported = update.getRouteModuleExports();
|
|
242
|
+
registerReactRouterRouteExports(routeId, imported);
|
|
243
|
+
const current = routeModules[routeId];
|
|
244
|
+
const preserveIdentity = key =>
|
|
245
|
+
imported[key] ? (current && current[key]) || imported[key] : imported[key];
|
|
246
|
+
routeModules[routeId] = {
|
|
247
|
+
...imported,
|
|
248
|
+
default: preserveIdentity('default'),
|
|
249
|
+
ErrorBoundary: preserveIdentity('ErrorBoundary'),
|
|
250
|
+
HydrateFallback: preserveIdentity('HydrateFallback'),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function getRouteById(routes, routeId) {
|
|
255
|
+
for (const route of routes) {
|
|
256
|
+
if (route.id === routeId) {
|
|
257
|
+
return route;
|
|
258
|
+
}
|
|
259
|
+
if (route.children) {
|
|
260
|
+
const child = getRouteById(route.children, routeId);
|
|
261
|
+
if (child) {
|
|
262
|
+
return child;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Deliberate coupling to React Router's private dev API: patching the live
|
|
269
|
+
// match objects is the only way to swap route implementations without a
|
|
270
|
+
// navigation. The typeof guard below degrades to a no-op if RR removes it.
|
|
271
|
+
function patchCurrentRouteMatches(router, routes) {
|
|
272
|
+
if (
|
|
273
|
+
!router.state ||
|
|
274
|
+
!Array.isArray(router.state.matches) ||
|
|
275
|
+
typeof router._internalSetStateDoNotUseOrYouWillBreakYourApp !== 'function'
|
|
276
|
+
) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let changed = false;
|
|
281
|
+
const matches = router.state.matches.map(match => {
|
|
282
|
+
const route = getRouteById(routes, match.route.id);
|
|
283
|
+
if (!route || route === match.route) {
|
|
284
|
+
return match;
|
|
285
|
+
}
|
|
286
|
+
changed = true;
|
|
287
|
+
return { ...match, route };
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (changed) {
|
|
291
|
+
router._internalSetStateDoNotUseOrYouWillBreakYourApp({ matches });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function applyPendingRouteUpdates(router, routeModules, manifest, context) {
|
|
296
|
+
if (pendingRouteUpdates.size === 0) {
|
|
297
|
+
return {
|
|
298
|
+
nextManifest: undefined,
|
|
299
|
+
shouldRefreshRouteState: false,
|
|
300
|
+
routesToRevalidate: new Set(),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Clone only entries mutated before the manifest is committed in flush().
|
|
305
|
+
const nextManifest = { ...manifest, routes: { ...manifest.routes } };
|
|
306
|
+
const routesToRevalidate = new Set();
|
|
307
|
+
let shouldRefreshRouteState = false;
|
|
308
|
+
for (const { routeId, update } of takePendingRouteUpdates()) {
|
|
309
|
+
const existingEntry = nextManifest.routes[routeId];
|
|
310
|
+
if (!existingEntry) continue;
|
|
311
|
+
|
|
312
|
+
// Shallow clone is enough: only top-level flags are mutated below.
|
|
313
|
+
const routeEntry = { ...existingEntry };
|
|
314
|
+
nextManifest.routes[routeId] = routeEntry;
|
|
315
|
+
applyRouteModuleUpdate(routeId, update, routeEntry, routeModules);
|
|
316
|
+
if (
|
|
317
|
+
routeEntry.hasLoader ||
|
|
318
|
+
routeEntry.hasClientLoader ||
|
|
319
|
+
routeEntry.hasClientMiddleware
|
|
320
|
+
) {
|
|
321
|
+
routesToRevalidate.add(routeId);
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
existingEntry.hasLoader ||
|
|
325
|
+
existingEntry.hasClientLoader ||
|
|
326
|
+
existingEntry.hasClientMiddleware ||
|
|
327
|
+
routeEntry.hasLoader ||
|
|
328
|
+
routeEntry.hasClientLoader ||
|
|
329
|
+
routeEntry.hasClientMiddleware
|
|
330
|
+
) {
|
|
331
|
+
shouldRefreshRouteState = true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (
|
|
336
|
+
typeof router.createRoutesForHMR === 'function' &&
|
|
337
|
+
typeof router._internalSetRoutes === 'function'
|
|
338
|
+
) {
|
|
339
|
+
const routes = router.createRoutesForHMR(
|
|
340
|
+
routesToRevalidate,
|
|
341
|
+
nextManifest.routes,
|
|
342
|
+
routeModules,
|
|
343
|
+
context.ssr,
|
|
344
|
+
context.isSpaMode
|
|
345
|
+
);
|
|
346
|
+
router._internalSetRoutes(routes);
|
|
347
|
+
patchCurrentRouteMatches(router, routes);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { nextManifest, shouldRefreshRouteState, routesToRevalidate };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function withHdrActive(fn) {
|
|
354
|
+
try {
|
|
355
|
+
window.__reactRouterHdrActive = true;
|
|
356
|
+
await fn();
|
|
357
|
+
} finally {
|
|
358
|
+
window.__reactRouterHdrActive = false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function revalidateRouter(router) {
|
|
363
|
+
if (typeof router.revalidate === 'function') {
|
|
364
|
+
await withHdrActive(() => router.revalidate());
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (typeof router.navigate === 'function') {
|
|
368
|
+
await withHdrActive(() =>
|
|
369
|
+
router.navigate(getCurrentRouterPath(router), {
|
|
370
|
+
replace: true,
|
|
371
|
+
preventScrollReset: true,
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function refreshRouteState(router) {
|
|
378
|
+
if (typeof router.revalidate === 'function') {
|
|
379
|
+
await withHdrActive(() => router.revalidate());
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function performReactRefresh() {
|
|
386
|
+
if (
|
|
387
|
+
RefreshRuntime &&
|
|
388
|
+
typeof RefreshRuntime.performReactRefresh === 'function'
|
|
389
|
+
) {
|
|
390
|
+
RefreshRuntime.performReactRefresh();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function flush() {
|
|
395
|
+
const router = window.__reactRouterDataRouter;
|
|
396
|
+
const routeModules = window.__reactRouterRouteModules;
|
|
397
|
+
const manifest = window.__reactRouterManifest;
|
|
398
|
+
const context = window.__reactRouterContext;
|
|
399
|
+
if (!router || !routeModules || !manifest || !context) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let shouldRevalidate = pendingRevalidation;
|
|
404
|
+
pendingRevalidation = false;
|
|
405
|
+
const { nextManifest, shouldRefreshRouteState, routesToRevalidate } =
|
|
406
|
+
applyPendingRouteUpdates(router, routeModules, manifest, context);
|
|
407
|
+
if (nextManifest) {
|
|
408
|
+
Object.assign(manifest, nextManifest);
|
|
409
|
+
}
|
|
410
|
+
// Component-only updates do not need a full loader revalidation.
|
|
411
|
+
if (
|
|
412
|
+
shouldRefreshRouteState &&
|
|
413
|
+
(routesToRevalidate.size > 0 || shouldRevalidate)
|
|
414
|
+
) {
|
|
415
|
+
if (await refreshRouteState(router)) {
|
|
416
|
+
shouldRevalidate = false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (shouldRevalidate) {
|
|
420
|
+
await revalidateRouter(router);
|
|
421
|
+
}
|
|
422
|
+
performReactRefresh();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (typeof window !== 'undefined' && import.meta.webpackHot) {
|
|
426
|
+
import.meta.webpackHot.accept(
|
|
427
|
+
${JSON.stringify(hdrRevisionFilePath)},
|
|
428
|
+
scheduleReactRouterRevalidation
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
`;
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type ReactRouterDevBuildPlan,
|
|
28
28
|
type ReactRouterDevManifestSet,
|
|
29
29
|
} from './dev-runtime-artifacts.js';
|
|
30
|
+
import { DEV_HDR_REVISION_RELATIVE_PATH } from './dev-hmr.js';
|
|
30
31
|
import {
|
|
31
32
|
createDevRuntimeSessionManager,
|
|
32
33
|
type RuntimeBinding,
|
|
@@ -55,6 +56,11 @@ type CreateControllerOptions = {
|
|
|
55
56
|
api: RsbuildPluginAPI;
|
|
56
57
|
isBuild: boolean;
|
|
57
58
|
buildPlan: ReactRouterDevBuildPlan;
|
|
59
|
+
/**
|
|
60
|
+
* Invoked after a development attempt commits a re-evaluated node build for
|
|
61
|
+
* changed server files. Used to signal hot data revalidation to the client.
|
|
62
|
+
*/
|
|
63
|
+
onNodeRebuildCommitted?: () => void;
|
|
58
64
|
};
|
|
59
65
|
|
|
60
66
|
const escapeHtml = (value: string): string =>
|
|
@@ -65,10 +71,29 @@ const escapeHtml = (value: string): string =>
|
|
|
65
71
|
|
|
66
72
|
const CSS_SOURCE_RELOAD_DELAY_MS = 1000;
|
|
67
73
|
|
|
74
|
+
const isHdrRevisionFile = (file: string): boolean =>
|
|
75
|
+
file.includes(DEV_HDR_REVISION_RELATIVE_PATH);
|
|
76
|
+
|
|
77
|
+
const isCssSourceFile = (file: string): boolean =>
|
|
78
|
+
/\.css(?:\.[cm]?[jt]s)?$/.test(file);
|
|
79
|
+
|
|
80
|
+
// A change that should bump the HDR revision: anything except the revision
|
|
81
|
+
// file itself (the bump's own echo) and CSS sources (styling never changes
|
|
82
|
+
// loader data).
|
|
83
|
+
const hasHdrTriggeringChange = (files: Iterable<string>): boolean => {
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!isHdrRevisionFile(file) && !isCssSourceFile(file)) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
|
|
68
92
|
export const createReactRouterDevRuntimeController = ({
|
|
69
93
|
api,
|
|
70
94
|
isBuild,
|
|
71
95
|
buildPlan,
|
|
96
|
+
onNodeRebuildCommitted,
|
|
72
97
|
}: CreateControllerOptions): ReactRouterDevRuntimeController => {
|
|
73
98
|
if (isBuild) {
|
|
74
99
|
return {
|
|
@@ -132,6 +157,14 @@ export const createReactRouterDevRuntimeController = ({
|
|
|
132
157
|
const compilationIdentities = createCompilationIdentityTracker();
|
|
133
158
|
const { getCompilationIdentity } = compilationIdentities;
|
|
134
159
|
|
|
160
|
+
// Web-only commits reuse the node compiler's stale `modifiedFiles`
|
|
161
|
+
// snapshot, and every HDR bump itself triggers a web rebuild — so signal
|
|
162
|
+
// once per node compilation identity or the bump loop self-sustains.
|
|
163
|
+
const hdrSignaledNodeIdentity = new WeakMap<
|
|
164
|
+
DevCompilerPair,
|
|
165
|
+
NonNullable<DevGraphIdentity['node']>
|
|
166
|
+
>();
|
|
167
|
+
|
|
135
168
|
const finishRuntimeAttemptEffect = (
|
|
136
169
|
binding: RuntimeBinding,
|
|
137
170
|
pair: DevCompilerPair,
|
|
@@ -144,11 +177,22 @@ export const createReactRouterDevRuntimeController = ({
|
|
|
144
177
|
).pipe(
|
|
145
178
|
Effect.flatMap(result =>
|
|
146
179
|
tryPluginSync(() => {
|
|
180
|
+
if (sessions.getActiveBinding()?.id !== binding.id) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (result === 'retry-node') {
|
|
184
|
+
pair.node.watching?.invalidate();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
147
187
|
if (
|
|
148
|
-
result === '
|
|
149
|
-
|
|
188
|
+
result === 'committed' &&
|
|
189
|
+
changes.node.known &&
|
|
190
|
+
identity.node !== undefined &&
|
|
191
|
+
hdrSignaledNodeIdentity.get(pair) !== identity.node &&
|
|
192
|
+
hasHdrTriggeringChange(changes.node.files)
|
|
150
193
|
) {
|
|
151
|
-
pair.node
|
|
194
|
+
hdrSignaledNodeIdentity.set(pair, identity.node);
|
|
195
|
+
onNodeRebuildCommitted?.();
|
|
152
196
|
}
|
|
153
197
|
})
|
|
154
198
|
),
|
package/src/index.ts
CHANGED
|
@@ -82,7 +82,16 @@ import {
|
|
|
82
82
|
createReactRouterRouteWatchFiles,
|
|
83
83
|
registerReactRouterDevBackgroundResources,
|
|
84
84
|
} from './dev-background-resources.js';
|
|
85
|
+
import {
|
|
86
|
+
createDevHdrRevisionSignal,
|
|
87
|
+
generateDevHmrRuntimeModule,
|
|
88
|
+
getDevHdrRevisionFilePath,
|
|
89
|
+
isRspackSwcReactRefreshEnabled,
|
|
90
|
+
resolveReactRefreshRuntimePath,
|
|
91
|
+
DEV_HMR_RUNTIME_MODULE_ID,
|
|
92
|
+
} from './dev-hmr.js';
|
|
85
93
|
|
|
94
|
+
export type { Config as ReactRouterRsbuildConfig } from './react-router-config.js';
|
|
86
95
|
export { loadReactRouterServerBuild } from './dev-generation.js';
|
|
87
96
|
export { resolveReactRouterServerBuild };
|
|
88
97
|
|
|
@@ -575,10 +584,26 @@ export const pluginReactRouter = (
|
|
|
575
584
|
defaultEntryName: devServerBuildEntryName,
|
|
576
585
|
});
|
|
577
586
|
const { serverBundleEntries } = serverBuildPlan;
|
|
587
|
+
|
|
588
|
+
const devHmrRefreshRuntimePath = isBuild
|
|
589
|
+
? undefined
|
|
590
|
+
: resolveReactRefreshRuntimePath(api.context.rootPath);
|
|
591
|
+
const devHdrSignal = devHmrRefreshRuntimePath
|
|
592
|
+
? createDevHdrRevisionSignal({
|
|
593
|
+
filePath: getDevHdrRevisionFilePath(api.context.rootPath),
|
|
594
|
+
onError: error =>
|
|
595
|
+
api.logger.debug(
|
|
596
|
+
`[${PLUGIN_NAME}] Failed to signal hot data revalidation: ${error.message}`
|
|
597
|
+
),
|
|
598
|
+
})
|
|
599
|
+
: undefined;
|
|
600
|
+
let devHmrEnabled = false;
|
|
601
|
+
|
|
578
602
|
const devRuntime = createReactRouterDevRuntimeController({
|
|
579
603
|
api,
|
|
580
604
|
isBuild,
|
|
581
605
|
buildPlan: serverBuildPlan,
|
|
606
|
+
onNodeRebuildCommitted: () => devHdrSignal?.bump(),
|
|
582
607
|
});
|
|
583
608
|
|
|
584
609
|
let clientStats: ReactRouterManifestStats | undefined;
|
|
@@ -702,6 +727,16 @@ export const pluginReactRouter = (
|
|
|
702
727
|
...bundleVirtualModules,
|
|
703
728
|
...bundleManifestModules,
|
|
704
729
|
'virtual/react-router/with-props': generateWithProps(),
|
|
730
|
+
...(devHmrRefreshRuntimePath
|
|
731
|
+
? {
|
|
732
|
+
[DEV_HMR_RUNTIME_MODULE_ID]: generateDevHmrRuntimeModule({
|
|
733
|
+
reactRefreshRuntimePath: devHmrRefreshRuntimePath,
|
|
734
|
+
hdrRevisionFilePath: getDevHdrRevisionFilePath(
|
|
735
|
+
api.context.rootPath
|
|
736
|
+
),
|
|
737
|
+
}),
|
|
738
|
+
}
|
|
739
|
+
: {}),
|
|
705
740
|
})
|
|
706
741
|
);
|
|
707
742
|
};
|
|
@@ -765,7 +800,6 @@ export const pluginReactRouter = (
|
|
|
765
800
|
assetPrefix: config.output?.assetPrefix || '/',
|
|
766
801
|
},
|
|
767
802
|
dev: {
|
|
768
|
-
writeToDisk: true,
|
|
769
803
|
...lazyCompilation,
|
|
770
804
|
watchFiles: mergeWatchFiles(config.dev?.watchFiles, routeWatchFiles),
|
|
771
805
|
},
|
|
@@ -913,6 +947,18 @@ export const pluginReactRouter = (
|
|
|
913
947
|
ensureFederationAsyncStartup(rspackConfig);
|
|
914
948
|
}
|
|
915
949
|
|
|
950
|
+
if (name === 'web') {
|
|
951
|
+
devHmrEnabled =
|
|
952
|
+
!isBuild &&
|
|
953
|
+
devHmrRefreshRuntimePath !== undefined &&
|
|
954
|
+
config.mode === 'development' &&
|
|
955
|
+
config.dev?.hmr !== false &&
|
|
956
|
+
isRspackSwcReactRefreshEnabled(rspackConfig);
|
|
957
|
+
if (devHmrEnabled) {
|
|
958
|
+
devHdrSignal?.ensure();
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
916
962
|
if (name === 'node') {
|
|
917
963
|
const output = rspackConfig.output;
|
|
918
964
|
if (output) {
|
|
@@ -985,6 +1031,7 @@ export const pluginReactRouter = (
|
|
|
985
1031
|
ssr,
|
|
986
1032
|
isSpaMode,
|
|
987
1033
|
rootRoutePath,
|
|
1034
|
+
isDevHmrEnabled: () => devHmrEnabled,
|
|
988
1035
|
});
|
|
989
1036
|
},
|
|
990
1037
|
});
|
package/src/prerender-build.ts
CHANGED
|
@@ -179,11 +179,21 @@ const prerenderData = async ({
|
|
|
179
179
|
api: PrerenderBuildApi;
|
|
180
180
|
requestInit?: RequestInit;
|
|
181
181
|
}): Promise<string> => {
|
|
182
|
-
const
|
|
182
|
+
const dataOutputPath = createDataRequestPath(
|
|
183
183
|
prerenderPath,
|
|
184
184
|
trailingSlashAwareDataRequests
|
|
185
185
|
);
|
|
186
|
+
// The handler always serves the root route's data at /_root.data, even when
|
|
187
|
+
// trailing-slash-aware naming writes the root output to /_.data.
|
|
188
|
+
const dataRequestPath =
|
|
189
|
+
trailingSlashAwareDataRequests && prerenderPath === '/'
|
|
190
|
+
? '/_root.data'
|
|
191
|
+
: dataOutputPath;
|
|
186
192
|
const normalizedPath = `${basename}${dataRequestPath}`.replace(/\/\/+/g, '/');
|
|
193
|
+
const outputNormalizedPath =
|
|
194
|
+
dataOutputPath === dataRequestPath
|
|
195
|
+
? normalizedPath
|
|
196
|
+
: `${basename}${dataOutputPath}`.replace(/\/\/+/g, '/');
|
|
187
197
|
const url = new URL(`http://localhost${normalizedPath}`);
|
|
188
198
|
if (onlyRoutes?.length) {
|
|
189
199
|
url.searchParams.set('_routes', onlyRoutes.join(','));
|
|
@@ -201,7 +211,10 @@ const prerenderData = async ({
|
|
|
201
211
|
);
|
|
202
212
|
}
|
|
203
213
|
|
|
204
|
-
const outputPath = resolve(
|
|
214
|
+
const outputPath = resolve(
|
|
215
|
+
clientBuildDir,
|
|
216
|
+
...outputNormalizedPath.split('/')
|
|
217
|
+
);
|
|
205
218
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
206
219
|
await writeFile(outputPath, data);
|
|
207
220
|
api.logger.info(
|
|
@@ -527,17 +540,24 @@ const createPrerenderPathEffect = ({
|
|
|
527
540
|
clientBuildDir,
|
|
528
541
|
basename,
|
|
529
542
|
api,
|
|
530
|
-
requestInit: data
|
|
531
|
-
? {
|
|
532
|
-
headers: {
|
|
533
|
-
'X-React-Router-Prerender-Data': encodeURI(data),
|
|
534
|
-
},
|
|
535
|
-
}
|
|
536
|
-
: undefined,
|
|
543
|
+
requestInit: data ? createPrerenderDataRequestInit(data) : undefined,
|
|
537
544
|
})
|
|
538
545
|
);
|
|
539
546
|
});
|
|
540
547
|
|
|
548
|
+
const createPrerenderDataRequestInit = (
|
|
549
|
+
data: string
|
|
550
|
+
): RequestInit | undefined => {
|
|
551
|
+
const encodedData = encodeURI(data);
|
|
552
|
+
return encodedData.length < 8 * 1024
|
|
553
|
+
? {
|
|
554
|
+
headers: {
|
|
555
|
+
'X-React-Router-Prerender-Data': encodedData,
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
: undefined;
|
|
559
|
+
};
|
|
560
|
+
|
|
541
561
|
const runPrerenderPaths = async ({
|
|
542
562
|
build,
|
|
543
563
|
requestHandler,
|
package/src/prerender.ts
CHANGED
|
@@ -249,7 +249,7 @@ export const resolvePrerenderPaths = async (
|
|
|
249
249
|
[
|
|
250
250
|
'Warning: Paths with dynamic/splat params cannot be prerendered when using `prerender: true`.',
|
|
251
251
|
'You may want to use the `prerender()` API to prerender the following paths:',
|
|
252
|
-
...paramRoutes.map(path => ` - ${path}`),
|
|
252
|
+
...paramRoutes.map(path => ` - ${path.replace(/^\/(?=[:*])/, '')}`),
|
|
253
253
|
].join('\n')
|
|
254
254
|
);
|
|
255
255
|
}
|