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
9 changes: 9 additions & 0 deletions .changeset/css-preload-restoration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'rsbuild-plugin-react-router': patch
---

Serve development manifest stylesheets through retained content-addressed assets so preloaded CSS cannot revive stale bytes after an exact source restoration. Publish committed CSS manifests through the HMR-idle queue and replay the last committed manifest on reconnect.

Keep extracted stylesheet updates under Router ownership in development so the extract loader's fallback cannot remove React-owned links during Vanilla Extract HMR.

Keep the last-good server/browser generation when a CSS update also contains shared JavaScript changes or has unknown invalidation provenance.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ pluginReactRouter({
| `onRouteTopologyChange` | `undefined` | Notification for programmatic/custom dev servers. Recreate the Rsbuild server when route files are added, removed, or moved. The callback is not awaited. |
| `federation` | `false` | Enables the plugin's experimental Module Federation integration. |

Lazy compilation applies to the browser build. Server dynamic imports still
compile eagerly; `lazyCompilation` does not enable on-demand SSR compilation.
Supporting suspended SSR imports across rebuilds requires support for retaining
the server runtime and coordinating lazy activations, tracked in [#155](https://github.com/rstackjs/rsbuild-plugin-react-router/issues/155).

When `federation` is enabled, configure the Module Federation plugin with
`experiments.asyncStartup: true` on every compiler (the plugin enforces it) and
keep shared dependencies non-eager. The dev server resolves async server build
Expand Down
65 changes: 65 additions & 0 deletions src/dev-css-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createHash } from 'node:crypto';
import { rspack, type Rspack } from '@rsbuild/core';
import {
getManifestAssetType,
type ReactRouterManifestStats,
} from './manifest-assets.js';

export const stripDevCssVersion = (url: string): string =>
url.replace(/\.__react_router_css_[a-f0-9]{64}\.css(?=[?#]|$)/, '');

// TODO: Once supported Rspack versions include the ownership fix below, remove
// this plugin and its registration in index.ts. Verify CSS edit/restore/remount
// preserves form state and async chunk CSS still hot-updates. Keep immutable
// CSS aliases: the upstream fix does not address stale document preloads.
// https://github.com/web-infra-dev/rspack/pull/15879 (fixes issue #15845)
export const devCssOwnershipPlugin: Rspack.RspackPluginInstance = {
apply(compiler) {
compiler.hooks.compilation.tap('ReactRouterCssOwnership', compilation => {
rspack.NormalModule.getCompilationHooks(compilation).loader.tap(
'ReactRouterCssOwnership',
context => {
if (
context.loaders.some(
({ path }) => path === rspack.CssExtractRspackPlugin.loader
)
) {
// Router updates extracted styles through committed manifests. The
// loader's fallback scans all links, including React-owned nodes.
context.hot = false;
}
}
);
});
},
};

export const versionDevCssAssets = (
compilation: Pick<Rspack.Compilation, 'getAsset' | 'emitAsset'>,
stats: ReactRouterManifestStats
): void => {
const names = new Set([
...Object.values(stats.assetsByChunkName ?? {}).flat(),
...Object.values(stats.entrypointFilesByName ?? {}).flat(),
]);
const urls: Record<string, string> = {};
for (const name of names) {
if (getManifestAssetType(name, stats.assetTypesByName) !== 'css') continue;
const asset = compilation.getAsset(name);
if (!asset) throw new Error(`[react-router] Missing CSS asset ${name}`);
// Asset metadata can predate processAssets transforms; version final bytes.
const version = createHash('sha256')
.update(asset.source.buffer())
.digest('hex');
const bareName = name.replace(/[?#].*$/, '');
const alias = `${bareName}.__react_router_css_${version}.css`;
// A distinct path identifies the exact CSS bytes for document preloads.
// The same directory preserves relative CSS URLs. Development output
// retention keeps old manifest URLs serving their original bytes.
if (!compilation.getAsset(alias)) {
compilation.emitAsset(alias, asset.source, asset.info);
}
urls[name] = alias + name.slice(bareName.length);
}
stats.cssUrlsByName = urls;
};
112 changes: 35 additions & 77 deletions src/dev-generation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripDevCssVersion } from './dev-css-assets.js';
import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
import * as EffectDeferred from 'effect/Deferred';
import * as Effect from 'effect/Effect';
Expand Down Expand Up @@ -63,6 +64,7 @@ export type ReactRouterDevRuntime = {
) => Promise<'committed' | 'ignored' | 'retry-node'>;
/** Node identity actually retained by the last successful generation. */
getCommittedNodeIdentity: () => DevCompilationIdentity | undefined;
getCommittedManifest: () => ReactRouterDevManifestSet[string] | undefined;
failAttempt: (error: Error) => void;
load: (entryName?: string) => Promise<ServerBuild>;
close: (error?: Error) => void;
Expand All @@ -84,11 +86,11 @@ const collectManifestCssAssetOwnership = (
): Set<string> => {
const ownership = new Set<string>();
for (const asset of manifest.entry?.css ?? []) {
ownership.add(`entry\0${asset}`);
ownership.add(`entry\0${stripDevCssVersion(asset)}`);
}
for (const [routeId, route] of Object.entries(manifest.routes ?? {})) {
for (const asset of route.css ?? []) {
ownership.add(`route\0${routeId}\0${asset}`);
ownership.add(`route\0${routeId}\0${stripDevCssVersion(asset)}`);
}
}
return ownership;
Expand Down Expand Up @@ -118,82 +120,27 @@ const hasRemovedCssAssetOwnership = (
return false;
};

const hasAddedCssAssetOwnership = (
const hasCssManifestChanges = (
previous: ReactRouterDevManifestSet,
next: ReactRouterDevManifestSet
): boolean => hasRemovedCssAssetOwnership(next, previous);

const collectManifestCssAssets = (
manifest: ReactRouterDevManifestSet[string]
): Set<string> => {
const assets = new Set(manifest.entry?.css ?? []);
for (const route of Object.values(manifest.routes ?? {})) {
for (const asset of route.css ?? []) {
assets.add(asset);
}
}
return assets;
};

const normalizeManifestForCssOwnershipCheck = (
manifest: ReactRouterDevManifestSet[string]
) => {
const cssAssets = collectManifestCssAssets(manifest);
const nonCssImports = (imports: string[] = []) =>
imports.filter(importPath => !cssAssets.has(importPath));

return {
entry: {
imports: nonCssImports(manifest.entry?.imports),
module: manifest.entry?.module,
},
routes: Object.fromEntries(
Object.entries(manifest.routes ?? {})
.sort(([left], [right]) => left.localeCompare(right))
.map(([routeId, route]) => [
routeId,
{
caseSensitive: route.caseSensitive,
clientActionModule: route.clientActionModule,
clientLoaderModule: route.clientLoaderModule,
clientMiddlewareModule: route.clientMiddlewareModule,
errorBoundary: route.hasErrorBoundary,
hasAction: route.hasAction,
hasClientAction: route.hasClientAction,
hasClientLoader: route.hasClientLoader,
hasClientMiddleware: route.hasClientMiddleware,
hasDefaultExport: route.hasDefaultExport,
hasLoader: route.hasLoader,
hydrateFallbackModule: route.hydrateFallbackModule,
id: route.id,
imports: nonCssImports(route.imports),
index: route.index,
module: route.module,
parentId: route.parentId,
path: route.path,
},
])
),
};
): boolean => {
const css = (manifests: ReactRouterDevManifestSet) =>
Object.entries(manifests)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, manifest]) => [
name,
manifest.entry?.css ?? [],
Object.entries(manifest.routes ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, route]) => [id, route.css ?? []]),
]);
return JSON.stringify(css(previous)) !== JSON.stringify(css(next));
};

const hasOnlyCssAssetOwnershipChanges = (
const hasAddedCssAssetOwnership = (
previous: ReactRouterDevManifestSet,
next: ReactRouterDevManifestSet
): boolean => {
const previousEntryNames = Object.keys(previous).sort();
const nextEntryNames = Object.keys(next).sort();
if (previousEntryNames.join('\0') !== nextEntryNames.join('\0')) {
return false;
}
return previousEntryNames.every(entryName => {
const previousManifest = normalizeManifestForCssOwnershipCheck(
previous[entryName]
);
const nextManifest = normalizeManifestForCssOwnershipCheck(next[entryName]);
return JSON.stringify(previousManifest) === JSON.stringify(nextManifest);
});
};
): boolean => hasRemovedCssAssetOwnership(next, previous);

type DevRouteManifestEntry = NonNullable<
ReactRouterDevManifestSet[string]['routes']
Expand Down Expand Up @@ -520,9 +467,10 @@ export const createReactRouterDevRuntime = ({
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const cssOnlyWebManifestChange =
(cssAssetsRemoved || cssAssetsAdded) &&
hasOnlyCssAssetOwnershipChanges(
const cssManifestChanged =
!!previous &&
webChanged &&
hasCssManifestChanges(
previous.web.manifestsByEntryName,
manifestsByEntryName
);
Expand Down Expand Up @@ -552,7 +500,6 @@ export const createReactRouterDevRuntime = ({
if (
previous &&
webChanged !== nodeChanged &&
!cssOnlyWebManifestChange &&
discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes)
) {
return 'ignored';
Expand Down Expand Up @@ -587,6 +534,8 @@ export const createReactRouterDevRuntime = ({
if (!committed) {
return 'ignored';
}
const ownershipReloaded =
cssAssetsRemoved || (cssAssetsAdded && reloadAfterCssRemoval);
if (cssAssetsRemoved) {
reloadAfterCssRemoval = !cssAssetsAdded;
notifyCssAssetOwnershipChanged('removed');
Expand All @@ -596,7 +545,10 @@ export const createReactRouterDevRuntime = ({
}
reloadAfterCssRemoval = false;
}
if (routeManifestMetadataChanged) {
if (
routeManifestMetadataChanged ||
(cssManifestChanged && !ownershipReloaded)
) {
notifyRouteManifestChanged(
web.manifestsByEntryName[buildPlan.defaultEntryName]
);
Expand All @@ -608,6 +560,12 @@ export const createReactRouterDevRuntime = ({
}
},

getCommittedManifest() {
return state.kind === 'ready'
? state.committed.web.manifestsByEntryName[buildPlan.defaultEntryName]
: undefined;
},

getCommittedNodeIdentity() {
return state.kind === 'ready' ? state.committed.nodeIdentity : undefined;
},
Expand Down
40 changes: 19 additions & 21 deletions src/dev-hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const RefreshRuntime =
: __refreshRuntimeModule.default;

const pendingRouteUpdates = new Map();
let pendingManifestUpdate;
let flushTimeout;
let pendingRevalidation = false;
let flushing = false;
Expand Down Expand Up @@ -332,30 +333,16 @@ function performReactRefresh() {
}
}

function applyManifestUpdate(nextRoutes) {
const router = window.__reactRouterDataRouter;
const routeModules = window.__reactRouterRouteModules;
const manifest = window.__reactRouterManifest;
const context = window.__reactRouterContext;
if (
!router ||
!routeModules ||
!manifest ||
!context ||
!nextRoutes ||
typeof router.createRoutesForHMR !== 'function' ||
typeof router._internalSetRoutes !== 'function'
) {
return;
}
function applyManifestUpdate(update, router, routeModules, manifest, context) {
if (typeof router.createRoutesForHMR !== 'function' || typeof router._internalSetRoutes !== 'function') return;
const routes = router.createRoutesForHMR(
new Set(Object.keys(nextRoutes)),
nextRoutes,
new Set(Object.keys(update.routes)),
update.routes,
routeModules,
context.ssr,
context.isSpaMode
);
manifest.routes = nextRoutes;
Object.assign(manifest, update);
router._internalSetRoutes(routes);
patchCurrentRouteMatches(router, routes);
}
Expand All @@ -374,8 +361,19 @@ async function flush() {
scheduleFlush();
return;
}
// Reconnect also replays the initial SSR manifest. Avoid route-state changes
// during hydration when the client already has exactly that committed version.
if (pendingManifestUpdate?.version && pendingManifestUpdate.version === manifest.version) {
pendingManifestUpdate = undefined;
if (!pendingRevalidation && pendingRouteUpdates.size === 0) return;
}
flushing = true;
try {
if (pendingManifestUpdate) {
const update = pendingManifestUpdate;
pendingManifestUpdate = undefined;
applyManifestUpdate(update, router, routeModules, manifest, context);
}
let shouldRevalidate = pendingRevalidation;
pendingRevalidation = false;
const { nextManifest, hmrRoutes, shouldRefreshRouteState } =
Expand Down Expand Up @@ -403,14 +401,14 @@ async function flush() {
performReactRefresh();
} finally {
flushing = false;
if (pendingRevalidation || pendingRouteUpdates.size > 0) scheduleFlush();
if (pendingManifestUpdate || pendingRevalidation || pendingRouteUpdates.size > 0) scheduleFlush();
}
}

if (typeof window !== 'undefined' && import.meta.webpackHot) {
import.meta.webpackHot.on(
${JSON.stringify(DEV_MANIFEST_UPDATE_EVENT)},
applyManifestUpdate
update => { pendingManifestUpdate = update; scheduleFlush(); }
);
import.meta.webpackHot.on(
${JSON.stringify(DEV_HDR_UPDATE_EVENT)},
Expand Down
Loading
Loading