rsbuild-plugin-react-router
Version:
React Router plugin for Rsbuild
691 lines (647 loc) • 21.5 kB
text/typescript
import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
import * as EffectDeferred from 'effect/Deferred';
import * as Effect from 'effect/Effect';
import type { ServerBuild } from 'react-router';
import {
evaluateServerBuilds,
getEnvironmentStats,
isSafeOneSidedChange,
pinServerBuildsToManifests,
snapshotDependencies,
type DependencySnapshot,
type DevCompilationIdentity,
type DevGraphChanges,
type DevGraphIdentity,
type DevRuntimeStats,
type ReactRouterDevBuildPlan,
type ReactRouterDevManifestSet,
type ReactRouterServerBuilds,
type WebArtifact,
} from './dev-runtime-artifacts.js';
import { normalizeEffectError, runPluginEffect } from './effect-runtime.js';
export { snapshotDevChangedFiles } from './dev-runtime-artifacts.js';
export type {
DevChangedFiles,
DevGraphChanges,
DevGraphIdentity,
ReactRouterDevBuildPlan,
ReactRouterDevManifest,
ReactRouterDevManifestSet,
} from './dev-runtime-artifacts.js';
type CommittedGeneration = {
buildsByEntryName: ReactRouterServerBuilds;
webIdentity: DevCompilationIdentity;
nodeIdentity: DevCompilationIdentity;
web: WebArtifact;
nodeDependencies: DependencySnapshot;
};
type RuntimeState =
| {
kind: 'starting';
attemptId: number;
readiness: EffectDeferred.Deferred<CommittedGeneration, Error>;
}
| { kind: 'failed'; attemptId: number; error: Error }
| {
kind: 'ready';
committed: CommittedGeneration;
pendingAttemptId: number | null;
}
| { kind: 'closed'; error: Error };
export type ReactRouterDevRuntime = {
beginAttempt: () => void;
captureWeb: (
compilation: Rspack.Compilation,
manifestsByEntryName: ReactRouterDevManifestSet
) => void;
finishAttempt: (
stats: DevRuntimeStats,
changes: DevGraphChanges,
identity: DevGraphIdentity
) => Promise<'committed' | 'ignored' | 'retry-node'>;
failAttempt: (error: Error) => void;
load: (entryName?: string) => Promise<ServerBuild>;
close: (error?: Error) => void;
};
type CreateReactRouterDevRuntimeOptions = {
server: RsbuildDevServer;
buildPlan: ReactRouterDevBuildPlan;
onEvaluationError: (error: Error) => void;
onCssAssetOwnershipChanged?: (change: 'removed' | 'restored') => void;
onRouteManifestChanged?: () => void;
onWarning?: (message: string) => void;
};
const collectManifestCssAssetOwnership = (
manifest: ReactRouterDevManifestSet[string]
): Set<string> => {
const ownership = new Set<string>();
for (const asset of manifest.entry?.css ?? []) {
ownership.add(`entry\0${asset}`);
}
for (const [routeId, route] of Object.entries(manifest.routes ?? {})) {
for (const asset of route.css ?? []) {
ownership.add(`route\0${routeId}\0${asset}`);
}
}
return ownership;
};
const hasRemovedCssAssetOwnership = (
previous: ReactRouterDevManifestSet,
next: ReactRouterDevManifestSet
): boolean => {
for (const [entryName, previousManifest] of Object.entries(previous)) {
const previousOwnership =
collectManifestCssAssetOwnership(previousManifest);
if (previousOwnership.size === 0) {
continue;
}
const nextManifest = next[entryName];
if (!nextManifest) {
return true;
}
const nextOwnership = collectManifestCssAssetOwnership(nextManifest);
for (const owner of previousOwnership) {
if (!nextOwnership.has(owner)) {
return true;
}
}
}
return false;
};
const hasAddedCssAssetOwnership = (
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,
},
])
),
};
};
const hasOnlyCssAssetOwnershipChanges = (
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);
});
};
type DevRouteManifestEntry = NonNullable<
ReactRouterDevManifestSet[string]['routes']
>[string];
const hasSameRouteMetadata = (
previous: DevRouteManifestEntry,
next: DevRouteManifestEntry
): boolean =>
previous.caseSensitive === next.caseSensitive &&
previous.clientActionModule === next.clientActionModule &&
previous.clientLoaderModule === next.clientLoaderModule &&
previous.clientMiddlewareModule === next.clientMiddlewareModule &&
previous.hasErrorBoundary === next.hasErrorBoundary &&
previous.hasAction === next.hasAction &&
previous.hasClientAction === next.hasClientAction &&
previous.hasClientLoader === next.hasClientLoader &&
previous.hasClientMiddleware === next.hasClientMiddleware &&
previous.hasDefaultExport === next.hasDefaultExport &&
previous.hasLoader === next.hasLoader &&
previous.hydrateFallbackModule === next.hydrateFallbackModule &&
previous.id === next.id &&
previous.index === next.index &&
previous.parentId === next.parentId &&
previous.path === next.path;
const hasRouteManifestMetadataChanges = (
previous: ReactRouterDevManifestSet,
next: ReactRouterDevManifestSet
): boolean => {
const previousEntryNames = Object.keys(previous);
if (previousEntryNames.length !== Object.keys(next).length) {
return true;
}
for (const entryName of previousEntryNames) {
const nextManifest = next[entryName];
if (!nextManifest) {
return true;
}
const previousRoutes = previous[entryName].routes ?? {};
const nextRoutes = nextManifest.routes ?? {};
const previousRouteIds = Object.keys(previousRoutes);
if (previousRouteIds.length !== Object.keys(nextRoutes).length) {
return true;
}
for (const routeId of previousRouteIds) {
const previousRoute = previousRoutes[routeId];
const nextRoute = nextRoutes[routeId];
if (!nextRoute || !hasSameRouteMetadata(previousRoute, nextRoute)) {
return true;
}
}
}
return false;
};
const createReadinessDeferred = (): EffectDeferred.Deferred<
CommittedGeneration,
Error
> => Effect.runSync(EffectDeferred.make<CommittedGeneration, Error>());
export const createReactRouterDevRuntime = ({
server,
buildPlan,
onEvaluationError,
onCssAssetOwnershipChanged = () => undefined,
onRouteManifestChanged = () => undefined,
onWarning = () => undefined,
}: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => {
let nextAttemptId = 1;
let reloadAfterCssRemoval = false;
let state: RuntimeState = {
kind: 'starting',
attemptId: 0,
readiness: createReadinessDeferred(),
};
const manifestsByCompilation = new WeakMap<
Rspack.Compilation,
ReactRouterDevManifestSet
>();
const notifyCssAssetOwnershipChanged = (
change: 'removed' | 'restored'
): void => {
try {
onCssAssetOwnershipChanged(change);
} catch (cause) {
onWarning(
`[rsbuild-plugin-react-router] Failed to notify the browser after CSS asset ownership changed: ${normalizeEffectError(cause).message}`
);
}
};
const notifyRouteManifestChanged = (): void => {
try {
onRouteManifestChanged();
} catch (cause) {
onWarning(
`[rsbuild-plugin-react-router] Failed to notify the browser after route manifest metadata changed: ${normalizeEffectError(cause).message}`
);
}
};
const uniqueEntryNames = new Set(buildPlan.entryNames);
if (
uniqueEntryNames.size !== buildPlan.entryNames.length ||
!uniqueEntryNames.has(buildPlan.defaultEntryName)
) {
throw new Error(
'[rsbuild-plugin-react-router] The development server build plan must contain unique entries and include its default entry.'
);
}
const selectBuild = (
generation: CommittedGeneration,
requestedEntryName?: string
): ServerBuild => {
const entryName = requestedEntryName ?? buildPlan.defaultEntryName;
const build = generation.buildsByEntryName[entryName];
if (!build) {
throw new Error(
`[rsbuild-plugin-react-router] Committed React Router server build ${JSON.stringify(entryName)} was not found.`
);
}
return build;
};
const getCurrentAttemptId = (): number | null => {
if (state.kind === 'starting') {
return state.attemptId;
}
return state.kind === 'ready' ? state.pendingAttemptId : null;
};
const isCurrentAttempt = (attemptId: number): boolean =>
getCurrentAttemptId() === attemptId;
const rejectAttempt = (
attemptId: number,
error: Error,
report: boolean
): void => {
if (!isCurrentAttempt(attemptId)) {
return;
}
if (state.kind === 'starting') {
const { readiness } = state;
state = { kind: 'failed', attemptId, error };
Effect.runSync(EffectDeferred.fail(readiness, error));
} else if (state.kind === 'ready') {
state = { ...state, pendingAttemptId: null };
}
if (report) {
onEvaluationError(error);
}
};
const commit = (
attemptId: number,
committed: CommittedGeneration
): boolean => {
if (!isCurrentAttempt(attemptId)) {
return false;
}
if (state.kind === 'starting') {
const { readiness } = state;
state = { kind: 'ready', committed, pendingAttemptId: null };
Effect.runSync(EffectDeferred.succeed(readiness, committed));
} else if (state.kind === 'ready') {
state = { kind: 'ready', committed, pendingAttemptId: null };
}
return true;
};
const discardUnsafeOneSidedResult = (
attemptId: number,
previous: CommittedGeneration,
webChanged: boolean,
changes: DevGraphChanges
): boolean => {
const side = webChanged ? 'web-only' : 'node-only';
const changedFiles = webChanged ? changes.web : changes.node;
const unchangedDependencies = webChanged
? previous.nodeDependencies
: previous.web.dependencies;
if (isSafeOneSidedChange(changedFiles, unchangedDependencies)) {
return false;
}
onWarning(
`[rsbuild-plugin-react-router] Discarded an incomplete ${side} development result and kept the last-good build.`
);
rejectAttempt(
attemptId,
new Error(`Incomplete ${side} development result.`),
false
);
return true;
};
return {
beginAttempt(): void {
if (state.kind === 'closed') {
return;
}
const attemptId = nextAttemptId++;
if (state.kind === 'failed') {
state = {
kind: 'starting',
attemptId,
readiness: createReadinessDeferred(),
};
} else if (state.kind === 'starting') {
state = { ...state, attemptId };
} else {
state = { ...state, pendingAttemptId: attemptId };
}
},
captureWeb(compilation, manifestsByEntryName): void {
if (state.kind !== 'closed') {
manifestsByCompilation.set(
compilation,
structuredClone(manifestsByEntryName)
);
}
},
async finishAttempt(stats, changes, identity) {
const attemptId = getCurrentAttemptId();
if (attemptId === null) {
return 'ignored';
}
const webStats = getEnvironmentStats(stats, 'web');
const nodeStats = getEnvironmentStats(stats, 'node');
if (!webStats || !nodeStats) {
rejectAttempt(
attemptId,
new Error(
'[rsbuild-plugin-react-router] Development compilation did not provide both web and node results.'
),
true
);
return 'ignored';
}
if (
webStats.compilation.needAdditionalPass ||
nodeStats.compilation.needAdditionalPass
) {
return 'ignored';
}
if (webStats.hasErrors() || nodeStats.hasErrors()) {
rejectAttempt(
attemptId,
new Error(
'[rsbuild-plugin-react-router] The React Router development compilation failed.'
),
false
);
return 'ignored';
}
const webCompilation = webStats.compilation;
const nodeCompilation = nodeStats.compilation;
const webIdentity = identity.web;
const nodeIdentity = identity.node;
if (!webIdentity || !nodeIdentity) {
rejectAttempt(
attemptId,
new Error(
'[rsbuild-plugin-react-router] Development compilation identity was unavailable.'
),
true
);
return 'ignored';
}
const previous = state.kind === 'ready' ? state.committed : undefined;
const webChanged = !previous || previous.webIdentity !== webIdentity;
const nodeChanged = !previous || previous.nodeIdentity !== nodeIdentity;
if (!webChanged && !nodeChanged) {
return 'ignored';
}
const manifestsByEntryName = webChanged
? manifestsByCompilation.get(webCompilation)
: previous?.web.manifestsByEntryName;
if (!manifestsByEntryName) {
rejectAttempt(
attemptId,
new Error(
'[rsbuild-plugin-react-router] The web compilation completed without a matching React Router manifest. Keeping the last-good development build.'
),
true
);
return 'ignored';
}
const cssAssetsRemoved =
!!previous &&
webChanged &&
hasRemovedCssAssetOwnership(
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const cssAssetsAdded =
!!previous &&
webChanged &&
hasAddedCssAssetOwnership(
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const cssOnlyWebManifestChange =
(cssAssetsRemoved || cssAssetsAdded) &&
hasOnlyCssAssetOwnershipChanges(
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const routeManifestMetadataChanged =
!!previous &&
webChanged &&
hasRouteManifestMetadataChanges(
previous.web.manifestsByEntryName,
manifestsByEntryName
);
const reusePreviousNodeBuild =
!!previous &&
cssOnlyWebManifestChange &&
(!nodeChanged || identity.nodeWeb !== webIdentity);
if (
nodeChanged &&
identity.nodeWeb !== webIdentity &&
!identity.attempt &&
!reusePreviousNodeBuild
) {
const message =
'[rsbuild-plugin-react-router] Discarded web and node results from different compiler cycles and kept the last-good build.';
if (!previous) {
return 'retry-node';
}
onWarning(message);
rejectAttempt(attemptId, new Error(message), false);
return 'retry-node';
}
const shouldEvaluateNode = nodeChanged && !reusePreviousNodeBuild;
if (
previous &&
webChanged !== shouldEvaluateNode &&
!cssOnlyWebManifestChange &&
discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes)
) {
return 'ignored';
}
try {
const buildsByEntryName = shouldEvaluateNode
? await evaluateServerBuilds(server, buildPlan.entryNames)
: previous!.buildsByEntryName;
if (!isCurrentAttempt(attemptId)) {
return 'ignored';
}
const web = webChanged
? {
manifestsByEntryName,
dependencies: snapshotDependencies(webCompilation),
}
: previous!.web;
const committed = commit(attemptId, {
buildsByEntryName: pinServerBuildsToManifests(
buildsByEntryName,
buildPlan.entryNames,
web.manifestsByEntryName
),
webIdentity,
nodeIdentity: shouldEvaluateNode
? nodeIdentity
: previous!.nodeIdentity,
web,
nodeDependencies: shouldEvaluateNode
? snapshotDependencies(nodeCompilation)
: previous!.nodeDependencies,
});
if (!committed) {
return 'ignored';
}
if (cssAssetsRemoved) {
reloadAfterCssRemoval = !cssAssetsAdded;
notifyCssAssetOwnershipChanged('removed');
} else if (cssAssetsAdded) {
if (reloadAfterCssRemoval) {
notifyCssAssetOwnershipChanged('restored');
}
reloadAfterCssRemoval = false;
}
if (routeManifestMetadataChanged) {
notifyRouteManifestChanged();
}
return 'committed';
} catch (cause) {
rejectAttempt(attemptId, normalizeEffectError(cause), true);
return 'ignored';
}
},
failAttempt(error): void {
const attemptId = getCurrentAttemptId();
if (attemptId !== null) {
rejectAttempt(attemptId, error, false);
}
},
load(entryName?: string): Promise<ServerBuild> {
if (entryName && !uniqueEntryNames.has(entryName)) {
return Promise.reject(
new Error(
`[rsbuild-plugin-react-router] React Router server build ${JSON.stringify(entryName)} is not part of this development server build plan.`
)
);
}
if (state.kind === 'ready') {
return Promise.resolve(selectBuild(state.committed, entryName));
}
if (state.kind === 'starting') {
const selected = runPluginEffect(
EffectDeferred.await(state.readiness).pipe(
Effect.map(generation => selectBuild(generation, entryName))
)
);
void selected.catch(() => undefined);
return selected;
}
return Promise.reject(state.error);
},
close(error?: Error): void {
if (state.kind === 'closed') {
return;
}
const closeError =
error ??
new Error(
'[rsbuild-plugin-react-router] The development server closed before a React Router build was ready.'
);
if (state.kind === 'starting') {
Effect.runSync(EffectDeferred.fail(state.readiness, closeError));
}
state = { kind: 'closed', error: closeError };
},
};
};
const DEV_RUNTIME_KEY = Symbol.for(
'rsbuild-plugin-react-router.dev-runtime.v1'
);
const getRegisteredRuntime = (
server: RsbuildDevServer
): ReactRouterDevRuntime | undefined =>
Reflect.get(server, DEV_RUNTIME_KEY) as ReactRouterDevRuntime | undefined;
export const registerReactRouterDevRuntime = (
server: RsbuildDevServer,
runtime: ReactRouterDevRuntime
): void => {
// Symbol.for keeps registration shared when the plugin and public helper are
// loaded through different ESM and CommonJS package entrypoints.
Object.defineProperty(server, DEV_RUNTIME_KEY, {
configurable: true,
enumerable: false,
value: runtime,
});
};
export const unregisterReactRouterDevRuntime = (
server: RsbuildDevServer,
runtime: ReactRouterDevRuntime
): void => {
if (getRegisteredRuntime(server) === runtime) {
Reflect.deleteProperty(server, DEV_RUNTIME_KEY);
}
};
export const loadReactRouterServerBuild = (
server: RsbuildDevServer,
entryName?: string
): Promise<ServerBuild> => {
const runtime = getRegisteredRuntime(server);
if (!runtime) {
return Promise.reject(
new Error(
'[rsbuild-plugin-react-router] This Rsbuild development server is not registered with the React Router plugin. Add pluginReactRouter() before calling loadReactRouterServerBuild().'
)
);
}
return runtime.load(entryName);
};