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/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 === 'retry-node' &&
149
- sessions.getActiveBinding()?.id === binding.id
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.watching?.invalidate();
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
  };
@@ -913,6 +948,18 @@ export const pluginReactRouter = (
913
948
  ensureFederationAsyncStartup(rspackConfig);
914
949
  }
915
950
 
951
+ if (name === 'web') {
952
+ devHmrEnabled =
953
+ !isBuild &&
954
+ devHmrRefreshRuntimePath !== undefined &&
955
+ config.mode === 'development' &&
956
+ config.dev?.hmr !== false &&
957
+ isRspackSwcReactRefreshEnabled(rspackConfig);
958
+ if (devHmrEnabled) {
959
+ devHdrSignal?.ensure();
960
+ }
961
+ }
962
+
916
963
  if (name === 'node') {
917
964
  const output = rspackConfig.output;
918
965
  if (output) {
@@ -985,6 +1032,7 @@ export const pluginReactRouter = (
985
1032
  ssr,
986
1033
  isSpaMode,
987
1034
  rootRoutePath,
1035
+ isDevHmrEnabled: () => devHmrEnabled,
988
1036
  });
989
1037
  },
990
1038
  });
@@ -179,11 +179,21 @@ const prerenderData = async ({
179
179
  api: PrerenderBuildApi;
180
180
  requestInit?: RequestInit;
181
181
  }): Promise<string> => {
182
- const dataRequestPath = createDataRequestPath(
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(clientBuildDir, ...normalizedPath.split('/'));
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
  }