Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cached-route-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rsbuild-plugin-react-router': patch
---

Preserve compiled route analysis in Rspack module metadata so persistent-cache hits retain MDX and other transformed route exports. Reject inferred reserved root IDs and non-string route IDs before building, and resolve RSC support checks from the configured project root.
7 changes: 7 additions & 0 deletions .changeset/closed-issue-edge-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'rsbuild-plugin-react-router': patch
---

Resolve configuration, version-dependent defaults, typegen, and build paths from the Rsbuild project root, reject collisions between route entries and generated split chunks, and only include emitted CSS assets in development manifests.

Preserve RSC chunk loading in federation builds and keep consuming federation hosts on the shared application runtime. Handle RSC SPA fallbacks under a basename, escape prerendered redirect HTML, and avoid consuming unused redirect response bodies.
9 changes: 9 additions & 0 deletions .changeset/fix-route-manifest-generations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'rsbuild-plugin-react-router': patch
---

Recognize route imports after native resolution, preserving aliases, dependency-specific conditions, extension priority, TypeScript paths, and symlinks while keeping server-only exports out of client bundles and preserving one module instance for repeated route imports.

Publish CSS and loader edits with the new server build, retain pending client and server edits across retries, send CSS ownership reloads only after successful commits, and defer fallback RSC revalidation until pending compilations succeed.

Emit versioned browser manifests in development so a document can fetch the manifest generation it rendered with after a later rebuild.
93 changes: 37 additions & 56 deletions src/build-output-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ export const registerBuildOutputTransforms = ({
const rememberRouteModuleAnalysis = (
args: Parameters<TransformHandler>[0]
): void => {
if (!routeByFilePath.has(args.resourcePath)) {
if (
args.environment.name !== 'web' ||
!routeByFilePath.has(args.resourcePath)
) {
return;
}
onRouteModuleAnalysis?.(
Expand Down Expand Up @@ -195,20 +198,15 @@ export const registerBuildOutputTransforms = ({

api.transform(
{
test: /virtual\/react-router\/((browser|server)-manifest|server-build)/,
test: /virtual\/react-router\/(server-manifest|server-build)/,
environments: ['node'],
},
async args =>
performanceProfiler.record(
args.environment?.name,
'manifest:transform',
args.resource,
async () => {
if (args.environment.name === 'web') {
return {
code: `window.__reactRouterManifest = "PLACEHOLDER";`,
};
}

// Cache identity for a module whose source never changes (#136);
// see `serverManifestStampPath` in index.ts.
if (existsSync(serverManifestStampPath)) {
Expand Down Expand Up @@ -311,11 +309,8 @@ export const registerBuildOutputTransforms = ({
routeChunkConfig,
});

// Invariant with the transformRouteModule registration below: in
// split-chunk production builds, web route modules are transformed
// HERE (on the main chunk) and the shared registration is scoped to
// ['node']. If either gate changes, web modules get transformed
// twice or not at all.
// Main chunks need server-export pruning after chunk extraction;
// the shared route transform excludes all route-chunk requests.
if (!isBuild || routeChunkName !== 'main') {
return routeChunkArtifact;
}
Expand All @@ -339,33 +334,6 @@ export const registerBuildOutputTransforms = ({
}
);

if (isBuild && splitRouteModules) {
api.transform(
{
test: path => routeByFilePath.has(path),
resourceQuery: {
not: /__react-router-build-client-route|react-router-route|route-chunk=/,
},
environments: ['web'],
order: 'post',
},
async args => {
return performanceProfiler.record(
args.environment?.name,
'route:split-exports',
args.resource,
async () =>
routeTransformRunner({
kind: 'splitRouteExports',
code: args.code,
resourcePath: args.resourcePath,
routeChunkConfig,
})
);
}
);
}

api.transform(
{
test: /[\\/]\.server[\\/]|\.server(\.[cm]?[jt]sx?)?$/,
Expand Down Expand Up @@ -409,29 +377,42 @@ export const registerBuildOutputTransforms = ({
)
);

if (useRouteModuleTransformApi) {
api.transform(
{
resourceQuery: /\?react-router-route/,
order: 'post',
},
transformRouteModule
);

if (useRouteModuleTransformApi || (isBuild && splitRouteModules)) {
// Explicit route queries and imports tagged after native resolution must
// select the same loader, so Rspack gives them one module identity.
api.transform(
{
test: path => routeByFilePath.has(path),
resourceQuery: {
not: /__react-router-build-client-route|react-router-route|route-chunk=/,
not: /__react-router-build-client-route|route-chunk=/,
},
// Invariant with the route-chunk= handler above: when split-chunk
// production builds transform web modules on the main chunk, this
// registration must stay scoped to ['node'] so web modules are not
// transformed twice.
environments: isBuild && splitRouteModules ? ['node'] : undefined,
environments: useRouteModuleTransformApi ? undefined : ['web'],
order: 'post',
},
transformRouteModule
async args => {
if (
isBuild &&
splitRouteModules &&
args.environment.name === 'web' &&
args.resourceQuery !== '?react-router-route'
) {
return performanceProfiler.record(
args.environment.name,
'route:split-exports',
args.resource,
() =>
routeTransformRunner({
kind: 'splitRouteExports',
code: args.code,
resourcePath: args.resourcePath,
routeChunkConfig,
})
);
}
return useRouteModuleTransformApi
? transformRouteModule(args)
: { code: args.code };
}
);
}
};
32 changes: 16 additions & 16 deletions src/classic-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,26 +142,26 @@ export const createClassicWebRouteEntries = ({
webRouteEntries: Record<string, RsbuildEntryDescription>;
} => {
const manifestChunkNames = new Set<string>(['entry.client']);
// Entry names are sanitized paths, so two distinct route files could in
// principle map to one name (for example `../shared/x.tsx` and
// `__/shared/x.tsx`). Refuse that instead of letting one route silently
// serve the other's module. Routes that share a file intentionally share
// an entry.
// Route files and generated chunks share the same entry namespace. Routes
// sharing a file may reuse an entry, but distinct files must never overwrite it.
const routeFileByEntryName = new Map<string, string>();
const reserveEntryName = (entryName: string, routeFilePath: string) => {
const existingRouteFile = routeFileByEntryName.get(entryName);
if (
existingRouteFile !== undefined &&
existingRouteFile !== routeFilePath
) {
throw new Error(
`[rsbuild-plugin-react-router] Route files ${JSON.stringify(existingRouteFile)} and ${JSON.stringify(routeFilePath)} both resolve to the entry name ${JSON.stringify(entryName)}. Rename one of them so its route entry and generated chunk names are unique.`
);
}
routeFileByEntryName.set(entryName, routeFilePath);
};
const webRouteEntries = Object.values(routes).reduce(
(acc, route) => {
const entryName = getRouteEntryBaseName(route, appDirectory);
const routeFilePath = resolve(appDirectory, route.file);
const existingRouteFile = routeFileByEntryName.get(entryName);
if (
existingRouteFile !== undefined &&
existingRouteFile !== routeFilePath
) {
throw new Error(
`[rsbuild-plugin-react-router] Route files ${JSON.stringify(existingRouteFile)} and ${JSON.stringify(routeFilePath)} both resolve to the entry name ${JSON.stringify(entryName)}. Rename one of them so their paths relative to the app directory differ.`
);
}
routeFileByEntryName.set(entryName, routeFilePath);
reserveEntryName(entryName, routeFilePath);
manifestChunkNames.add(entryName);
acc[entryName] = {
import: `${routeFilePath}${BUILD_CLIENT_ROUTE_QUERY_STRING}`,
Expand All @@ -183,6 +183,7 @@ export const createClassicWebRouteEntries = ({
continue;
}
const chunkEntryName = getRouteChunkEntryName(entryName, exportName);
reserveEntryName(chunkEntryName, routeFilePath);
manifestChunkNames.add(chunkEntryName);
acc[chunkEntryName] = {
import: getRouteChunkModuleId(routeFilePath, exportName),
Expand Down Expand Up @@ -253,7 +254,6 @@ export const createClassicVirtualModules = ({
);

return {
'virtual/react-router/browser-manifest': 'export default {};',
'virtual/react-router/server-manifest': 'export default {};',
'virtual/react-router/server-build': generateServerBuild(
routes,
Expand Down
3 changes: 0 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,3 @@ export const CLIENT_EXPORTS = {

// SPA-mode prerender fallback document, served when no prerendered page matches.
export const SPA_FALLBACK_HTML_FILE = '__spa-fallback.html';

export const BROWSER_MANIFEST_ENTRY_NAME =
'virtual/react-router/browser-manifest';
19 changes: 5 additions & 14 deletions src/dev-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,16 +534,10 @@ export const createReactRouterDevRuntime = ({
manifestsByEntryName,
true
);
const reusePreviousNodeBuild =
!!previous &&
cssOnlyWebManifestChange &&
(!nodeChanged || identity.nodeWeb !== webIdentity);

if (
nodeChanged &&
identity.nodeWeb !== webIdentity &&
!identity.attempt &&
!reusePreviousNodeBuild
!identity.attempt
) {
const message =
'[rsbuild-plugin-react-router] Discarded web and node results from different compiler cycles and kept the last-good build.';
Expand All @@ -555,18 +549,17 @@ export const createReactRouterDevRuntime = ({
return 'retry-node';
}

const shouldEvaluateNode = nodeChanged && !reusePreviousNodeBuild;
if (
previous &&
webChanged !== shouldEvaluateNode &&
webChanged !== nodeChanged &&
!cssOnlyWebManifestChange &&
discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes)
) {
return 'ignored';
}

try {
const buildsByEntryName = shouldEvaluateNode
const buildsByEntryName = nodeChanged
? await evaluateServerBuilds(server, buildPlan.entryNames)
: previous!.buildsByEntryName;
if (!isCurrentAttempt(attemptId)) {
Expand All @@ -585,11 +578,9 @@ export const createReactRouterDevRuntime = ({
web.manifestsByEntryName
),
webIdentity,
nodeIdentity: shouldEvaluateNode
? nodeIdentity
: previous!.nodeIdentity,
nodeIdentity: nodeChanged ? nodeIdentity : previous!.nodeIdentity,
web,
nodeDependencies: shouldEvaluateNode
nodeDependencies: nodeChanged
? snapshotDependencies(nodeCompilation)
: previous!.nodeDependencies,
});
Expand Down
Loading
Loading