Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/css-preload-restoration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'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.
60 changes: 60 additions & 0 deletions src/dev-css-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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(?=[?#]|$)/, '');

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 keeps Rspack HMR from removing React-owned links.
// 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;
};
45 changes: 41 additions & 4 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,6 +120,23 @@ const hasRemovedCssAssetOwnership = (
return false;
};

const hasCssManifestChanges = (
previous: ReactRouterDevManifestSet,
next: ReactRouterDevManifestSet
): 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 hasAddedCssAssetOwnership = (
previous: ReactRouterDevManifestSet,
next: ReactRouterDevManifestSet
Expand Down Expand Up @@ -520,8 +539,15 @@ export const createReactRouterDevRuntime = ({
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const cssManifestChanged =
!!previous &&
webChanged &&
hasCssManifestChanges(
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const cssOnlyWebManifestChange =
(cssAssetsRemoved || cssAssetsAdded) &&
cssManifestChanged &&
hasOnlyCssAssetOwnershipChanges(
previous.web.manifestsByEntryName,
manifestsByEntryName
Expand Down Expand Up @@ -587,6 +613,8 @@ export const createReactRouterDevRuntime = ({
if (!committed) {
return 'ignored';
}
const ownershipReloaded =
cssAssetsRemoved || (cssAssetsAdded && reloadAfterCssRemoval);
if (cssAssetsRemoved) {
reloadAfterCssRemoval = !cssAssetsAdded;
notifyCssAssetOwnershipChanged('removed');
Expand All @@ -596,7 +624,10 @@ export const createReactRouterDevRuntime = ({
}
reloadAfterCssRemoval = false;
}
if (routeManifestMetadataChanged) {
if (
routeManifestMetadataChanged ||
(cssManifestChanged && !ownershipReloaded)
) {
notifyRouteManifestChanged(
web.manifestsByEntryName[buildPlan.defaultEntryName]
);
Expand All @@ -608,6 +639,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
30 changes: 26 additions & 4 deletions src/dev-runtime-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ type CreateControllerOptions = {
const isCssSourceFile = (file: string): boolean =>
/\.css(?:\.[cm]?[jt]s)?$/.test(file);

const manifestPayload = (manifest: ReactRouterDevManifestSet[string]) => ({
event: DEV_MANIFEST_UPDATE_EVENT,
data: {
entry: manifest.entry,
routes: manifest.routes,
version: manifest.version,
url: manifest.url,
},
});

export const createReactRouterDevRuntimeController = ({
api,
isBuild,
Expand Down Expand Up @@ -92,6 +102,7 @@ export const createReactRouterDevRuntimeController = ({
binding.server.sockWrite('full-reload', { path: '*' });
};

const manifestSubscriptions = new WeakMap<RuntimeBinding, () => void>();
const hdrChannels = new WeakMap<
RuntimeBinding,
ReturnType<typeof createDevHdrChannel>
Expand All @@ -102,6 +113,8 @@ export const createReactRouterDevRuntimeController = ({
: clientPatchesRouteMetadata === true;

const closeBinding = (binding: RuntimeBinding, error?: Error): void => {
manifestSubscriptions.get(binding)?.();
manifestSubscriptions.delete(binding);
hdrChannels.get(binding)?.close();
hdrChannels.delete(binding);
const pair = binding.compilers;
Expand Down Expand Up @@ -291,17 +304,26 @@ export const createReactRouterDevRuntimeController = ({
return;
}
if (isHmrEnabled()) {
server.sockWrite('custom', {
event: DEV_MANIFEST_UPDATE_EVENT,
data: manifest.routes,
});
server.sockWrite('custom', manifestPayload(manifest));
} else {
server.sockWrite('full-reload', { path: '*' });
}
},
onWarning: message => api.logger.warn(message),
});
const binding = sessions.createBinding(server, runtime);
manifestSubscriptions.set(
binding,
server.environments.web.hot.onConnect(client => {
const manifest = runtime.getCommittedManifest();
if (
sessions.getActiveBinding() === binding &&
isHmrEnabled() &&
manifest
)
client.send('custom', manifestPayload(manifest));
})
);
hdrChannels.set(
binding,
createDevHdrChannel({
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ReactRouterManifestSnapshot,
} from './manifest-snapshot.js';
import { createReactRouterManifestState } from './manifest-state.js';
import { devCssOwnershipPlugin } from './dev-css-assets.js';
import { registerNodeOnlyManifestValidation } from './node-only-manifest.js';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
Expand Down Expand Up @@ -616,6 +617,8 @@ export const pluginReactRouter = (
rspack: rspackConfig => {
devHmrEnabled = isRspackSwcReactRefreshEnabled(rspackConfig);
if (devHmrEnabled) {
rspackConfig.plugins ??= [];
rspackConfig.plugins.push(devCssOwnershipPlugin);
const entries = rspackConfig.entry;
if (
entries &&
Expand Down
5 changes: 4 additions & 1 deletion src/manifest-assets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DEFAULT_JS_DIST_PATH } from './constants.js';

export type ReactRouterManifestStats = {
cssUrlsByName?: Record<string, string>;
assetsByChunkName?: Record<string, string[]>;
entrypointFilesByName?: Record<string, string[]>;
assetTypesByName?: Record<string, string>;
Expand Down Expand Up @@ -220,7 +221,9 @@ export const createChunkAssetResolver = (

const result = {
js: [...new Set(jsAssets)],
css: [...cssAssets],
css: [...cssAssets].map(
asset => clientStats?.cssUrlsByName?.[asset] ?? asset
),
};
chunkAssetsByName.set(chunkName, result);
return result;
Expand Down
2 changes: 2 additions & 0 deletions src/modify-browser-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { versionDevCssAssets } from './dev-css-assets.js';
import { PLUGIN_NAME } from './constants.js';
import { getManifestAssetType, stripAssetQuery } from './manifest-assets.js';
import { createHash } from 'node:crypto';
Expand Down Expand Up @@ -200,6 +201,7 @@ export function registerModifyBrowserManifestAssets(
compilation,
manifestChunkNames
);
if (!isBuild && stats) versionDevCssAssets(compilation, stats);
const { manifest, moduleExportsByRouteId } =
await generateReactRouterManifestForDev(
routes,
Expand Down
Loading
Loading