diff --git a/.changeset/reuse-unchanged-server-build.md b/.changeset/reuse-unchanged-server-build.md new file mode 100644 index 00000000..fd678d79 --- /dev/null +++ b/.changeset/reuse-unchanged-server-build.md @@ -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. diff --git a/src/dev-generation.ts b/src/dev-generation.ts index 02122918..824dc9e9 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -11,6 +11,7 @@ import { isSafeOneSidedChange, pinServerBuildsToManifests, snapshotDependencies, + snapshotRuntimeOutputs, type DependencySnapshot, type DevCompilationIdentity, type DevGraphChanges, @@ -33,6 +34,7 @@ type CommittedGeneration = { buildsByEntryName: ReactRouterServerBuilds; webIdentity: DevCompilationIdentity; nodeIdentity: DevCompilationIdentity; + nodeOutputs: string | undefined; web: WebArtifact; nodeDependencies: DependencySnapshot; }; @@ -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'; } @@ -526,6 +544,7 @@ export const createReactRouterDevRuntime = ({ ), webIdentity, nodeIdentity: nodeChanged ? nodeIdentity : previous!.nodeIdentity, + nodeOutputs, web, nodeDependencies: nodeChanged ? snapshotDependencies(nodeCompilation) diff --git a/src/dev-runtime-artifacts.ts b/src/dev-runtime-artifacts.ts index 20be0f4a..c51de442 100644 --- a/src/dev-runtime-artifacts.ts +++ b/src/dev-runtime-artifacts.ts @@ -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'; @@ -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 ( diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 6c2e5c5e..6827a613 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -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, + assets: Record + ) => + 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) => { + 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();