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/reuse-unchanged-server-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rsbuild-plugin-react-router': patch
---

Keep the evaluated development server build when a node recompile emits byte-identical runtime output (for example CSS-only or source-map-only edits), instead of re-evaluating it and resetting server module state before Hot Data Revalidation. Changed server output still evaluates fresh.
25 changes: 22 additions & 3 deletions src/dev-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isSafeOneSidedChange,
pinServerBuildsToManifests,
snapshotDependencies,
snapshotRuntimeOutputs,
type DependencySnapshot,
type DevCompilationIdentity,
type DevGraphChanges,
Expand All @@ -33,6 +34,7 @@ type CommittedGeneration = {
buildsByEntryName: ReactRouterServerBuilds;
webIdentity: DevCompilationIdentity;
nodeIdentity: DevCompilationIdentity;
nodeOutputs: string | undefined;
web: WebArtifact;
nodeDependencies: DependencySnapshot;
};
Expand Down Expand Up @@ -506,9 +508,25 @@ export const createReactRouterDevRuntime = ({
}

try {
const buildsByEntryName = nodeChanged
? await evaluateServerBuilds(server, buildPlan.entryNames)
: previous!.buildsByEntryName;
// A node recompile with byte-identical runtime output (CSS-only edits,
// source-map-only changes) keeps the evaluated build instead of asking
// Rsbuild for a fresh runner, which would reset server module state and
// delay HDR. Module-scope state therefore survives same-output edits.
// TODO(#140): only whole-output equality is detectable here. Reusing
// loaded modules when just an unimported chunk changed needs runner-level
// consumed-output tracking from Rsbuild; revisit once
// https://github.com/web-infra-dev/rsbuild/issues/8496 is addressed.
const nodeOutputs = nodeChanged
? snapshotRuntimeOutputs(nodeCompilation)
: previous!.nodeOutputs;
const reuseServerBuilds =
!!previous &&
nodeOutputs !== undefined &&
nodeOutputs === previous.nodeOutputs;
const buildsByEntryName =
nodeChanged && !reuseServerBuilds
? await evaluateServerBuilds(server, buildPlan.entryNames)
: previous!.buildsByEntryName;
if (!isCurrentAttempt(attemptId)) {
return 'ignored';
}
Expand All @@ -526,6 +544,7 @@ export const createReactRouterDevRuntime = ({
),
webIdentity,
nodeIdentity: nodeChanged ? nodeIdentity : previous!.nodeIdentity,
nodeOutputs,
web,
nodeDependencies: nodeChanged
? snapshotDependencies(nodeCompilation)
Expand Down
26 changes: 26 additions & 0 deletions src/dev-runtime-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { isAbsolute, relative } from 'node:path';
import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
import * as Effect from 'effect/Effect';
Expand Down Expand Up @@ -86,6 +87,31 @@ export const snapshotDependencies = (
missing: new Set(compilation.missingDependencies),
});

// Digest of every emitted runtime asset except source maps. Undefined when an
// asset source is unavailable; callers must treat that as changed output.
export const snapshotRuntimeOutputs = (
compilation: Rspack.Compilation
): string | undefined => {
if (typeof compilation.getAssets !== 'function') {
return undefined;
}
const hash = createHash('sha1');
const assets = [...compilation.getAssets()].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
for (const asset of assets) {
if (asset.name.endsWith('.map')) {
continue;
}
const source = asset.source?.buffer?.();
if (!source) {
return undefined;
}
hash.update(asset.name).update('\0').update(source).update('\0');
}
return hash.digest('hex');
};

const isWithinDirectory = (directory: string, file: string): boolean => {
const relativePath = relative(directory, file);
return (
Expand Down
46 changes: 46 additions & 0 deletions tests/dev-generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,52 @@ describe('React Router development runtime', () => {
});
});

it('reuses the evaluated server build when node runtime output is unchanged', async () => {
const withAssets = (
compilation: ReturnType<typeof createCompilation>,
assets: Record<string, string>
) =>
Object.assign(compilation, {
getAssets: () =>
Object.entries(assets).map(([name, content]) => ({
name,
source: { buffer: () => Buffer.from(content) },
})),
});
let build = createBuild('initial');
const { loadBundle, runtime } = createHarness(() => build);
const run = async (marker: string, assets: Record<string, string>) => {
const web = createCompilation('web');
const node = withAssets(createCompilation('node'), assets);
runtime.beginAttempt();
captureWeb(runtime, web, marker);
await runtime.finishAttempt(
createGraphStats(web, node),
noKnownChanges,
graphIdentity(web, node)
);
};

await run('first', { 'app.js': 'same', 'app.js.map': 'map-1' });
build = createBuild('second');
await run('second', { 'app.js': 'same', 'app.js.map': 'map-2' });

expect(loadBundle).toHaveBeenCalledTimes(1);
await expect(runtime.load()).resolves.toMatchObject({
marker: 'initial',
assets: { version: 'second' },
});

build = createBuild('third');
await run('third', { 'app.js': 'changed', 'app.js.map': 'map-2' });

expect(loadBundle).toHaveBeenCalledTimes(2);
await expect(runtime.load()).resolves.toMatchObject({
marker: 'third',
assets: { version: 'third' },
});
});

it('evaluates changed node output during css ownership removals', async () => {
const routePath = '/app/routes/about.tsx';
const onCssAssetOwnershipChanged = rstest.fn();
Expand Down
Loading