From 5bae74efe6720c729062a3b68da6ea8ee2ae83c5 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 15:30:17 +0000 Subject: [PATCH 1/5] perf(start-plugin-core): reuse the Rsbuild dev client build when a rebuild cannot change it Reuse the previous normalized client build and dev manifest when the compilation directly follows the captured one, no file was removed, the chunk graph fingerprint is unchanged, and the active static imports of rebuilt modules and their importers are unchanged. The fingerprint covers only the files normalization reads, so HMR update files and other assets do not defeat reuse, and it is independent of chunk iteration order, which HMR rebuilds shuffle. Import signatures are recorded once and then updated for rebuilt modules only. --- .changeset/rsbuild-dev-client-build-reuse.md | 5 + .../src/rsbuild/normalized-client-build.ts | 207 +++++++++++++++++- .../src/rsbuild/rebuilt-modules.ts | 22 ++ .../src/rsbuild/virtual-modules.ts | 9 + .../rsbuild/dev-client-build-reuse.bench.ts | 192 ++++++++++++++++ .../rsbuild/normalized-client-build.test.ts | 176 ++++++++++++++- 6 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 .changeset/rsbuild-dev-client-build-reuse.md create mode 100644 packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts create mode 100644 packages/start-plugin-core/tests/rsbuild/dev-client-build-reuse.bench.ts diff --git a/.changeset/rsbuild-dev-client-build-reuse.md b/.changeset/rsbuild-dev-client-build-reuse.md new file mode 100644 index 00000000000..b326fc5a3fe --- /dev/null +++ b/.changeset/rsbuild-dev-client-build-reuse.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Speed up Rsbuild development rebuilds by reusing the client manifest when a rebuild cannot change it, which is typical for code-only edits. diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index 591d6b0113a..ae658e6e572 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -2,6 +2,7 @@ import { tsrSplit } from '@tanstack/router-plugin' import { tssHydrate } from '../hydration-constants' import { getCssAssetSource } from '../start-manifest-plugin/inlineCss' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' +import { getRebuiltModulesAndImporters } from './rebuilt-modules' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' import type { GetConfigFn, @@ -17,6 +18,7 @@ type RspackCompilationChunk = Rspack.Chunk type RspackModule = Rspack.Module const backslashRegex = /\\/g +const dependencyPathRegex = /[\\/]node_modules[\\/]/ /** * Convert an OS native path to the POSIX form used by the generated route tree. @@ -479,6 +481,30 @@ export function registerClientBuildCapture( getClientBuild: () => NormalizedClientBuild | undefined } { let clientBuild: NormalizedClientBuild | undefined + // `previous` is only reusable by the compilation that directly follows it. + let snapshot: (DevClientBuildSnapshot & { previous: boolean }) | undefined + + if (api.context.action === 'dev') { + api.modifyRspackConfig((config, utils) => { + if (utils.environment.name !== RSBUILD_ENVIRONMENT_NAMES.client) { + return + } + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + compiler.hooks.thisCompilation.tap( + 'TanStackStartClientBuildSnapshot', + () => { + if (snapshot) { + snapshot = snapshot.previous + ? undefined + : { ...snapshot, previous: true } + } + }, + ) + }, + }) + }) + } api.processAssets( { @@ -486,11 +512,35 @@ export function registerClientBuildCapture( environments: [RSBUILD_ENVIRONMENT_NAMES.client], }, (context: ProcessAssetsContext) => { - clientBuild = normalizeRspackClientBuild( - context.compilation, - api.context.action !== 'dev' && + if (api.context.action !== 'dev') { + clientBuild = normalizeRspackClientBuild( + context.compilation, getConfig().startConfig.server.build.inlineCss.enabled, - ) + ) + return + } + + const { compilation, compiler } = context + const chunkGraph = getChunkGraphFingerprint(compilation) + // Removing a file can re-resolve imports in modules that are not rebuilt. + const previous = + snapshot?.previous && !compiler.removedFiles?.size + ? snapshot + : undefined + const edgesByModule = + previous?.edgesByModule ?? getModuleEdgeSignatures(compilation) + const edgesChanged = + !previous || updateRebuiltEdgeSignatures(compilation, edgesByModule) + + if (previous && !edgesChanged && chunkGraph === previous.chunkGraph) { + snapshot = { ...previous, previous: false } + clientBuild = previous.clientBuild + return + } + + snapshot = undefined + clientBuild = normalizeRspackClientBuild(compilation, false) + snapshot = { clientBuild, chunkGraph, edgesByModule, previous: false } }, ) @@ -498,3 +548,152 @@ export function registerClientBuildCapture( getClientBuild: () => clientBuild, } } + +interface DevClientBuildSnapshot { + clientBuild: NormalizedClientBuild + chunkGraph: string + edgesByModule: Map +} + +/** + * Every chunk-graph input to normalization other than module edges. Order + * within a line is kept; lines are sorted because HMR rebuilds can reorder + * chunks without changing them. Only the files normalization reads count, so + * HMR updates and other assets don't. + */ +function getChunkGraphFingerprint(compilation: RspackCompilation): string { + const getChunkKey = (chunk: RspackCompilationChunk) => + `${chunk.id ?? ''}:${chunk.name ?? ''}` + const getGroupKey = (group: Rspack.ChunkGroup) => + group.chunks.map(getChunkKey).join(',') + const lines: Array = [] + + for (const chunk of compilation.chunks) { + lines.push( + [ + getChunkKey(chunk), + Array.from(chunk.runtime).join(','), + getChunkJsFiles(chunk).join(','), + getChunkCssFiles(chunk).join(','), + compilation.chunkGraph.getChunkModules(chunk).length, + ].join('|'), + ) + } + for (const group of compilation.chunkGroups) { + lines.push( + [ + getGroupKey(group), + group.getParents().map(getGroupKey).join(';'), + Array.from(group.childrenIterable, getGroupKey).join(';'), + ].join('|'), + ) + } + for (const [name, entrypoint] of compilation.entrypoints) { + lines.push(`${name}|${getGroupKey(entrypoint)}`) + } + + return lines.sort().join('\n') +} + +/** + * Reads, per module, the identifiers of its active static imports for each + * runtime it is emitted in; identifiers carry the queries that hold route + * metadata. Dependency modules are not rebuilt in development, so their + * signatures are neither recorded nor reusable. + */ +function createEdgeSignatureReader(compilation: RspackCompilation) { + const identifiers = new Map() + const runtimes = new Map< + RspackCompilationChunk, + { runtime: Array; key: string } + >() + const getIdentifier = (module: RspackModule) => { + let identifier = identifiers.get(module) + if (identifier === undefined) { + identifier = module.identifier() + identifiers.set(module, identifier) + } + return identifier + } + + return { + getIdentifier, + isDependency(module: RspackModule) { + const identifier = getIdentifier(module) + return dependencyPathRegex.test( + identifier.slice(identifier.lastIndexOf('!') + 1), + ) + }, + getSignature(module: RspackModule) { + const parts: Array = [] + const seenRuntimes = new Set() + + for (const chunk of compilation.chunkGraph.getModuleChunksIterable( + module, + )) { + let entry = runtimes.get(chunk) + if (!entry) { + const runtime = Array.from(chunk.runtime) + entry = { runtime, key: runtime.join(',') } + runtimes.set(chunk, entry) + } + if (seenRuntimes.has(entry.key)) { + continue + } + seenRuntimes.add(entry.key) + parts.push(entry.key) + + for (const dependency of module.dependencies) { + const connection = compilation.moduleGraph.getConnection(dependency) + const importedModule = connection?.module + if ( + importedModule && + connection.getActiveState(entry.runtime) !== false + ) { + parts.push(getIdentifier(importedModule)) + } + } + } + + return parts.join('\n') + }, + } +} + +function getModuleEdgeSignatures( + compilation: RspackCompilation, +): Map { + const reader = createEdgeSignatureReader(compilation) + const signatures = new Map() + for (const module of compilation.modules) { + if (!reader.isDependency(module)) { + signatures.set(reader.getIdentifier(module), reader.getSignature(module)) + } + } + return signatures +} + +/** + * Only rebuilt modules and their importers can change edges between directly + * consecutive development compilations. + */ +function updateRebuiltEdgeSignatures( + compilation: RspackCompilation, + edgesByModule: Map, +): boolean { + const reader = createEdgeSignatureReader(compilation) + let changed = false + for (const module of getRebuiltModulesAndImporters(compilation)) { + if (reader.isDependency(module)) { + changed = true + continue + } + const identifier = reader.getIdentifier(module) + const signature = reader.getSignature(module) + if (edgesByModule.get(identifier) !== signature) { + edgesByModule.set(identifier, signature) + changed = true + } + } + return changed +} diff --git a/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts new file mode 100644 index 00000000000..0578886bcb9 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts @@ -0,0 +1,22 @@ +import type { Rspack } from '@rsbuild/core' + +/** + * A rebuilt dependency can change what its importer sees without the importer + * itself being rebuilt. + */ +export function getRebuiltModulesAndImporters( + compilation: Rspack.Compilation, +): Array { + const modules = new Set() + for (const module of compilation.builtModules) { + modules.add(module) + for (const connection of compilation.moduleGraph.getIncomingConnections( + module, + )) { + if (connection.originModule) { + modules.add(connection.originModule) + } + } + } + return Array.from(modules) +} diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 960b2152f31..385f16fbf15 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -276,6 +276,7 @@ export function registerVirtualModules( const pendingWrites = new Map>() let clientBuild: NormalizedClientBuild | undefined + let devManifestRoutes: typeof globalThis.TSS_ROUTES_MANIFEST | undefined const lastResolverContentByEnvironment: Record = {} const resolverWriteCountByEnvironment: Record = {} @@ -560,10 +561,18 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is }, updateManifest(newClientBuild: NormalizedClientBuild) { + if ( + isDev && + newClientBuild === clientBuild && + globalThis.TSS_ROUTES_MANIFEST === devManifestRoutes + ) { + return + } clientBuild = newClientBuild // Safe to call getConfig() here — runs after client build const { resolvedStartConfig } = opts.getConfig() if (isDev) { + devManifestRoutes = globalThis.TSS_ROUTES_MANIFEST ;( globalThis as typeof globalThis & { [DEV_START_MANIFEST_GLOBAL]?: ReturnType diff --git a/packages/start-plugin-core/tests/rsbuild/dev-client-build-reuse.bench.ts b/packages/start-plugin-core/tests/rsbuild/dev-client-build-reuse.bench.ts new file mode 100644 index 00000000000..a9555599052 --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/dev-client-build-reuse.bench.ts @@ -0,0 +1,192 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { rspack } from '@rsbuild/core' +import { join } from 'pathe' +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' +import { + normalizeRspackClientBuild, + registerClientBuildCapture, +} from '../../src/rsbuild/normalized-client-build' +import { buildStartManifest } from '../../src/start-manifest-plugin/manifestBuilder' +import type { + ModifyRspackConfigFn, + RsbuildPluginAPI, + Rspack, +} from '@rsbuild/core' +import type { GetConfigFn, NormalizedClientBuild } from '../../src/types' + +const routes = 128 +const leaves = 12 + +describe('development client build after a code-only rebuild', () => { + let directory: string + let watching: Rspack.Watching | undefined + let compilation: Rspack.Compilation + let compiler: Rspack.Compiler + const routeTreeRoutes: Parameters< + typeof buildStartManifest + >[0]['routeTreeRoutes'] = { + __root__: { + children: Array.from({ length: routes }, (_, route) => `/r${route}`), + }, + } + + const createCapture = () => { + const processAssets = vi.fn() + const modifyRspackConfig = vi.fn<(handler: ModifyRspackConfigFn) => void>() + const { getClientBuild } = registerClientBuildCapture( + { + context: { action: 'dev' }, + processAssets, + modifyRspackConfig, + } as unknown as RsbuildPluginAPI, + (() => ({})) as unknown as GetConfigFn, + ) + const config = { plugins: [] as Array } + void modifyRspackConfig.mock.calls[0]![0]( + config as never, + { environment: { name: 'client' } } as never, + ) + let startCompilation: () => void = () => {} + config.plugins[0]!.apply({ + hooks: { + thisCompilation: { + tap: (_name: string, fn: () => void) => { + startCompilation = fn + }, + }, + }, + } as never) + const [, capture] = processAssets.mock.calls[0]! + return { + capture: () => { + startCompilation() + capture({ compilation, compiler } as Parameters[0]) + return getClientBuild()! + }, + } + } + + const buildManifest = (clientBuild: NormalizedClientBuild) => + buildStartManifest({ clientBuild, routeTreeRoutes, basePath: '/' }) + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'tss-dev-reuse-bench-')) + await writeFile( + join(directory, 'index.js'), + `globalThis.routes = [${Array.from( + { length: routes }, + (_, route) => `() => import('./route-${route}.js?tsr-split=component')`, + ).join(',')}]`, + ) + await writeFile(join(directory, 'shared.css'), '.shared { display: grid; }') + for (let route = 0; route < routes; route++) { + routeTreeRoutes[`/r${route}`] = { + filePath: join(directory, `route-${route}.js`), + } + await writeFile(join(directory, `route-${route}.css`), `.r${route} {}`) + await writeFile( + join(directory, `route-${route}.js`), + `import './shared.css'\nimport './route-${route}.css'\n${Array.from( + { length: leaves }, + (_, leaf) => + `import { value${leaf} } from './leaf-${route}-${leaf}.js${leaf % 3 === 0 ? `?tss-hydrate=leaf-${route}` : ''}'`, + ).join('\n')}\nexport const render = () => [${Array.from( + { length: leaves }, + (_, leaf) => `value${leaf}()`, + ).join(',')}]`, + ) + for (let leaf = 0; leaf < leaves; leaf++) { + await writeFile( + join(directory, `leaf-${route}-${leaf}.js`), + `export const value${leaf} = () => ${leaf}`, + ) + } + } + + compiler = rspack({ + context: directory, + mode: 'development', + devtool: false, + entry: { index: './index.js' }, + output: { path: join(directory, 'dist') }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [ + { + apply(compiler: Rspack.Compiler) { + compiler.hooks.thisCompilation.tap('Keep', (current) => { + compilation = current + }) + }, + }, + ], + }) + let builds = 0 + let onBuilt: () => void = () => {} + const built = () => + new Promise((resolve) => { + onBuilt = resolve + }) + const initial = built() + watching = compiler.watch({ aggregateTimeout: 50 }, (error, stats) => { + expect(error).toBeNull() + expect(stats?.hasErrors()).toBe(false) + builds++ + onBuilt() + }) + await initial + const rebuilt = built() + await writeFile( + join(directory, 'leaf-7-1.js'), + `export const value1 = () => 2`, + ) + await rebuilt + expect(builds).toBe(2) + expect(compilation.builtModules.size).toBeLessThan( + compilation.modules.size / 100, + ) + + const fresh = normalizeRspackClientBuild(compilation) + expect(fresh.chunksByFileName.size).toBe(routes + 1) + const { capture } = createCapture() + const first = capture() + expect(first).toEqual(fresh) + expect(capture()).toBe(first) + expect(Object.keys(buildManifest(first).routes)).toHaveLength(routes + 1) + }) + + afterAll(async () => { + if (watching) { + await new Promise((resolve) => watching!.close(() => resolve())) + } + if (directory) { + await rm(directory, { recursive: true, force: true }) + } + }) + + test('normalize and build the manifest on every rebuild (before)', async ({ + bench, + }) => { + await bench('normalize + manifest', () => { + buildManifest(normalizeRspackClientBuild(compilation)) + }).run({ time: 3000, warmupIterations: 50 }) + }) + + test('capture the first development compilation, then build the manifest', async ({ + bench, + }) => { + await bench('first capture + manifest', () => { + buildManifest(createCapture().capture()) + }).run({ time: 3000, warmupIterations: 50 }) + }) + + test('capture after a code-only rebuild, reusing the manifest', async ({ + bench, + }) => { + const { capture } = createCapture() + capture() + await bench('capture with reuse', () => { + capture() + }).run({ time: 3000, warmupIterations: 50 }) + }) +}) diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index f563c57bf7d..17a70fc6b2f 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { promisify } from 'node:util' import { rspack } from '@rsbuild/core' @@ -9,7 +9,11 @@ import { registerClientBuildCapture, } from '../../src/rsbuild/normalized-client-build' import { buildStartManifest } from '../../src/start-manifest-plugin/manifestBuilder' -import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' +import type { + ModifyRspackConfigFn, + RsbuildPluginAPI, + Rspack, +} from '@rsbuild/core' import type { GetConfigFn, NormalizedClientBuild } from '../../src/types' async function buildClientFixture( @@ -580,7 +584,11 @@ describe('normalizeRspackClientBuild', () => { startConfig: { server: { build: { inlineCss: { enabled } } } }, }) const { getClientBuild } = registerClientBuildCapture( - { context: { action }, processAssets } as unknown as RsbuildPluginAPI, + { + context: { action }, + processAssets, + modifyRspackConfig: vi.fn(), + } as unknown as RsbuildPluginAPI, getConfig as unknown as GetConfigFn, ) @@ -641,3 +649,165 @@ describe('normalizeRspackClientBuild', () => { expect(manifest.routes['/posts']?.preloads).toEqual(['/assets/posts.js']) }) }) + +describe('registerClientBuildCapture development rebuilds', () => { + test.each([false, true])( + 'reuses the client build only when a rebuild cannot change it (hot=%s)', + async (hot) => { + const directory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-dev-')) + const write = (name: string, source: string) => + writeFile(join(directory, name), source) + await write( + 'index.js', + `globalThis.routes = [() => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component')]`, + ) + const alpha = (imports: string, body = `'alpha'`) => + `${imports}\nimport { leaf } from './leaf.js'\nexport const render = () => ${body} + leaf()` + await write('alpha.js', alpha('')) + await write( + 'beta.js', + `import { leaf } from './leaf.js'\nexport const render = () => leaf()`, + ) + await write('leaf.js', `export const leaf = () => 'leaf'`) + await write('alpha.css', '.alpha { color: red; }') + await write('hydrated.js', `export const hydrated = () => 'hydrated'`) + await write('lazy.js', `export const lazy = 'lazy'`) + + const processAssets = vi.fn() + const modifyRspackConfig = + vi.fn<(handler: ModifyRspackConfigFn) => void>() + const { getClientBuild } = registerClientBuildCapture( + { + context: { action: 'dev' }, + processAssets, + modifyRspackConfig, + } as unknown as RsbuildPluginAPI, + (() => ({}) as ReturnType) as GetConfigFn, + ) + const [, capture] = processAssets.mock.calls[0]! + const builds: Array<{ + captured: NormalizedClientBuild + fresh: NormalizedClientBuild + }> = [] + const config: Rspack.Configuration = { + context: directory, + mode: 'development', + devtool: false, + entry: { index: './index.js' }, + output: { path: join(directory, 'dist') }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [ + ...(hot ? [new rspack.HotModuleReplacementPlugin()] : []), + { + apply(compiler: Rspack.Compiler) { + compiler.hooks.thisCompilation.tap('Capture', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'Capture', + stage: rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + capture({ compilation, compiler } as Parameters< + typeof capture + >[0]) + builds.push({ + captured: getClientBuild()!, + fresh: normalizeRspackClientBuild(compilation), + }) + }, + ) + }) + }, + }, + ], + } + const [configure] = modifyRspackConfig.mock.calls[0]! + await configure( + config as Parameters[0], + { environment: { name: 'client' } } as Parameters[1], + ) + + const compiler = rspack(config) + let onCompiled: (() => void) | undefined + const compiled = () => + new Promise((resolve) => { + onCompiled = resolve + }) + const firstCompile = compiled() + const watching = compiler.watch({ aggregateTimeout: 50 }, (error) => { + expect(error).toBeNull() + onCompiled?.() + }) + const rebuild = async (name: string, source: string) => { + const next = compiled() + await write(name, source) + await next + const previous = builds.at(-2)!.captured + const { captured, fresh } = builds.at(-1)! + expect(captured).toEqual(fresh) + return captured === previous + } + + try { + await firstCompile + expect(builds[0]!.captured).toEqual(builds[0]!.fresh) + + expect(await rebuild('alpha.js', alpha('', `'edited'`))).toBe(true) + expect( + await rebuild('leaf.js', `export const leaf = () => 'edited'`), + ).toBe(true) + // leaf.js is in both route chunks with the same runtime. + expect( + await rebuild( + 'leaf.js', + `import { hydrated } from './hydrated.js?tss-hydrate=leaf'\nexport const leaf = () => hydrated()`, + ), + ).toBe(false) + expect( + builds.at(-1)!.captured.chunksByFileName.get('beta.js'), + ).toMatchObject({ hydrationIds: ['leaf'] }) + expect( + await rebuild( + 'leaf.js', + `import { hydrated } from './hydrated.js?tss-hydrate=leaf'\nexport const leaf = () => 'edited ' + hydrated()`, + ), + ).toBe(true) + expect(await rebuild('alpha.js', alpha(`import './alpha.css'`))).toBe( + false, + ) + expect( + await rebuild( + 'alpha.js', + alpha( + `import './alpha.css'\nimport { hydrated } from './hydrated.js?tss-hydrate=one'\nglobalThis.hydrated = hydrated`, + ), + ), + ).toBe(false) + expect( + await rebuild( + 'alpha.js', + alpha( + `import './alpha.css'\nimport { hydrated } from './hydrated.js?tss-hydrate=two'\nglobalThis.hydrated = hydrated`, + ), + ), + ).toBe(false) + const { hydrationIds } = builds + .at(-1)! + .captured.chunksByFileName.get('alpha.js')! + expect(hydrationIds).toContain('two') + expect(hydrationIds).not.toContain('one') + expect( + await rebuild( + 'alpha.js', + alpha( + `import './alpha.css'\nimport { hydrated } from './hydrated.js?tss-hydrate=two'\nglobalThis.hydrated = hydrated\nglobalThis.lazy = () => import('./lazy.js')`, + ), + ), + ).toBe(false) + } finally { + await promisify(watching.close.bind(watching))() + await rm(directory, { recursive: true, force: true }) + } + }, + ) +}) From a173546fee66d70e30c885f50e56755a2254a80f Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 19:29:09 +0000 Subject: [PATCH 2/5] fix(start-plugin-core): include dynamic imports in Rsbuild dev reuse signatures A rebuilt module could point a dynamic import at a module already in the graph while the chunk kept its name and module count, so the previous manifest was reused with the old route or hydration metadata. --- .../src/rsbuild/normalized-client-build.ts | 19 ++++++++++++--- .../rsbuild/normalized-client-build.test.ts | 23 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index ae658e6e572..f2c74ff07d4 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -597,9 +597,9 @@ function getChunkGraphFingerprint(compilation: RspackCompilation): string { /** * Reads, per module, the identifiers of its active static imports for each - * runtime it is emitted in; identifiers carry the queries that hold route - * metadata. Dependency modules are not rebuilt in development, so their - * signatures are neither recorded nor reusable. + * runtime it is emitted in, and of its dynamic imports; identifiers carry the + * queries that hold route metadata. Dependency modules are not rebuilt in + * development, so their signatures are neither recorded nor reusable. */ function createEdgeSignatureReader(compilation: RspackCompilation) { const identifiers = new Map() @@ -655,6 +655,19 @@ function createEdgeSignatureReader(compilation: RspackCompilation) { } } + // A new dynamic import target can keep the chunk's name and module count. + const blocks = [...module.blocks] + for (const block of blocks) { + blocks.push(...block.blocks) + for (const dependency of block.dependencies) { + const importedModule = + compilation.moduleGraph.getConnection(dependency)?.module + if (importedModule) { + parts.push(`async:${getIdentifier(importedModule)}`) + } + } + } + return parts.join('\n') }, } diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index 17a70fc6b2f..640efc1b5f7 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -657,10 +657,9 @@ describe('registerClientBuildCapture development rebuilds', () => { const directory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-dev-')) const write = (name: string, source: string) => writeFile(join(directory, name), source) - await write( - 'index.js', - `globalThis.routes = [() => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component')]`, - ) + const index = (beta: string, extra = '') => + `globalThis.routes = [() => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), () => import(/* webpackChunkName: "beta" */ './${beta}.js?tsr-split=component')]${extra}` + await write('index.js', index('beta')) const alpha = (imports: string, body = `'alpha'`) => `${imports}\nimport { leaf } from './leaf.js'\nexport const render = () => ${body} + leaf()` await write('alpha.js', alpha('')) @@ -668,6 +667,14 @@ describe('registerClientBuildCapture development rebuilds', () => { 'beta.js', `import { leaf } from './leaf.js'\nexport const render = () => leaf()`, ) + await write( + 'gamma.js', + `import { leaf } from './leaf.js'\nexport const render = () => leaf()`, + ) + await write( + 'other.js', + `export { render } from './gamma.js?tsr-split=component'`, + ) await write('leaf.js', `export const leaf = () => 'leaf'`) await write('alpha.css', '.alpha { color: red; }') await write('hydrated.js', `export const hydrated = () => 'hydrated'`) @@ -804,6 +811,14 @@ describe('registerClientBuildCapture development rebuilds', () => { ), ), ).toBe(false) + + const other = `\nglobalThis.other = () => import(/* webpackChunkName: "other" */ './other.js')` + expect(await rebuild('index.js', index('beta', other))).toBe(false) + // gamma.js is already built, and the beta chunk keeps its name and size. + expect(await rebuild('index.js', index('gamma', other))).toBe(false) + expect( + builds.at(-1)!.captured.chunksByFileName.get('beta.js'), + ).toMatchObject({ routeFilePaths: [join(directory, 'gamma.js')] }) } finally { await promisify(watching.close.bind(watching))() await rm(directory, { recursive: true, force: true }) From f4075bee31fffd7f6be2a28fee1fb98e5c5dde25 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 19:30:50 +0000 Subject: [PATCH 3/5] perf(start-plugin-core): store digests of Rsbuild dev reuse signatures Module identifiers include full loader chains, so keeping the joined identifiers retained about 7.9 KB per first-party module for the life of the dev server. Keys and signatures are now SHA-1 digests. --- .../src/rsbuild/normalized-client-build.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index f2c74ff07d4..36111b2998e 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -1,3 +1,4 @@ +import { hash } from 'node:crypto' import { tsrSplit } from '@tanstack/router-plugin' import { tssHydrate } from '../hydration-constants' import { getCssAssetSource } from '../start-manifest-plugin/inlineCss' @@ -552,6 +553,7 @@ export function registerClientBuildCapture( interface DevClientBuildSnapshot { clientBuild: NormalizedClientBuild chunkGraph: string + // Digests: identifiers include loader chains hundreds of characters long. edgesByModule: Map } @@ -617,7 +619,9 @@ function createEdgeSignatureReader(compilation: RspackCompilation) { } return { - getIdentifier, + getKey(module: RspackModule) { + return hash('sha1', getIdentifier(module), 'base64') + }, isDependency(module: RspackModule) { const identifier = getIdentifier(module) return dependencyPathRegex.test( @@ -668,7 +672,7 @@ function createEdgeSignatureReader(compilation: RspackCompilation) { } } - return parts.join('\n') + return hash('sha1', parts.join('\n'), 'base64') }, } } @@ -680,7 +684,7 @@ function getModuleEdgeSignatures( const signatures = new Map() for (const module of compilation.modules) { if (!reader.isDependency(module)) { - signatures.set(reader.getIdentifier(module), reader.getSignature(module)) + signatures.set(reader.getKey(module), reader.getSignature(module)) } } return signatures @@ -701,10 +705,10 @@ function updateRebuiltEdgeSignatures( changed = true continue } - const identifier = reader.getIdentifier(module) + const key = reader.getKey(module) const signature = reader.getSignature(module) - if (edgesByModule.get(identifier) !== signature) { - edgesByModule.set(identifier, signature) + if (edgesByModule.get(key) !== signature) { + edgesByModule.set(key, signature) changed = true } } From 9ad9a6e782826032d7bc42ba8b169ab1518192ec Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 15:01:59 +0000 Subject: [PATCH 4/5] fix(start-plugin-core): don't reuse the Rsbuild dev client build after resolution changes A changed package.json import map, exports map or tsconfig paths can re-resolve imports without rebuilding any module, so the edge check saw nothing change. Reuse now also requires that every changed file is a rebuilt module and none is a package.json or tsconfig file. --- .../src/rsbuild/normalized-client-build.ts | 8 ++-- .../src/rsbuild/rebuilt-modules.ts | 37 +++++++++++++++++++ .../rsbuild/normalized-client-build.test.ts | 27 +++++++++++++- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index 36111b2998e..5f29038242d 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -3,7 +3,10 @@ import { tsrSplit } from '@tanstack/router-plugin' import { tssHydrate } from '../hydration-constants' import { getCssAssetSource } from '../start-manifest-plugin/inlineCss' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' -import { getRebuiltModulesAndImporters } from './rebuilt-modules' +import { + getRebuiltModulesAndImporters, + mayHaveReResolvedImports, +} from './rebuilt-modules' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' import type { GetConfigFn, @@ -523,9 +526,8 @@ export function registerClientBuildCapture( const { compilation, compiler } = context const chunkGraph = getChunkGraphFingerprint(compilation) - // Removing a file can re-resolve imports in modules that are not rebuilt. const previous = - snapshot?.previous && !compiler.removedFiles?.size + snapshot?.previous && !mayHaveReResolvedImports(compiler, compilation) ? snapshot : undefined const edgesByModule = diff --git a/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts index 0578886bcb9..1b710218118 100644 --- a/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts @@ -1,5 +1,8 @@ import type { Rspack } from '@rsbuild/core' +const resolutionConfigFileRegex = + /(?:^|[\\/])(?:package|[jt]sconfig(?:\.[^\\/]*)?)\.json$/ + /** * A rebuilt dependency can change what its importer sees without the importer * itself being rebuilt. @@ -20,3 +23,37 @@ export function getRebuiltModulesAndImporters( } return Array.from(modules) } + +/** + * A changed or removed file that no rebuilt module was built from, such as + * package.json or tsconfig.json, can re-resolve imports in modules that were + * not rebuilt. + */ +export function mayHaveReResolvedImports( + compiler: Rspack.Compiler, + compilation: Rspack.Compilation, +): boolean { + if (compiler.removedFiles?.size) { + return true + } + if (!compiler.modifiedFiles?.size) { + return false + } + + const rebuiltFiles = new Set() + for (const module of compilation.builtModules) { + const file = ( + module as Rspack.Module & { resourceResolveData?: { path?: string } } + ).resourceResolveData?.path + if (file) { + rebuiltFiles.add(file) + } + } + for (const file of compiler.modifiedFiles) { + // An imported package.json is rebuilt, but it still configures resolution. + if (!rebuiltFiles.has(file) || resolutionConfigFileRegex.test(file)) { + return true + } + } + return false +} diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index 640efc1b5f7..9eee9fe9491 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { promisify } from 'node:util' import { rspack } from '@rsbuild/core' @@ -679,6 +679,24 @@ describe('registerClientBuildCapture development rebuilds', () => { await write('alpha.css', '.alpha { color: red; }') await write('hydrated.js', `export const hydrated = () => 'hydrated'`) await write('lazy.js', `export const lazy = 'lazy'`) + const importMap = (a: string, b: string) => + JSON.stringify({ imports: { '#a': `./${a}.js`, '#b': `./${b}.js` } }) + await write('package.json', importMap('one', 'two')) + for (const [name, value] of [ + ['a', 'one'], + ['b', 'two'], + ]) { + await write(`${value}.js`, `export const value = '${value}'`) + await write( + `swap-${name}.js`, + `import { value } from '#${name}'\nexport const run = () => value`, + ) + } + // The watcher reports files written just before it starts as modified. + const past = new Date(Date.now() - 10_000) + for (const file of await readdir(directory)) { + await utimes(join(directory, file), past, past) + } const processAssets = vi.fn() const modifyRspackConfig = @@ -819,6 +837,13 @@ describe('registerClientBuildCapture development rebuilds', () => { expect( builds.at(-1)!.captured.chunksByFileName.get('beta.js'), ).toMatchObject({ routeFilePaths: [join(directory, 'gamma.js')] }) + + const swaps = `${other}\nglobalThis.swaps = [() => import(/* webpackChunkName: "swap-a" */ './swap-a.js'), () => import(/* webpackChunkName: "swap-b" */ './swap-b.js')]` + expect(await rebuild('index.js', index('gamma', swaps))).toBe(false) + // Swapping the import map rebuilds no module and keeps every chunk's size. + expect(await rebuild('package.json', importMap('two', 'one'))).toBe( + false, + ) } finally { await promisify(watching.close.bind(watching))() await rm(directory, { recursive: true, force: true }) From ac1e563b0d9aebafaca1e79ae138ff0585cd3107 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 15:33:22 +0000 Subject: [PATCH 5/5] fix(start-plugin-core): ignore changed watched directories when checking Rsbuild re-resolution --- .../src/rsbuild/rebuilt-modules.ts | 7 ++- .../tests/rsbuild/rebuilt-modules.test.ts | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/start-plugin-core/tests/rsbuild/rebuilt-modules.test.ts diff --git a/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts index 1b710218118..9c425185c88 100644 --- a/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts @@ -51,7 +51,12 @@ export function mayHaveReResolvedImports( } for (const file of compiler.modifiedFiles) { // An imported package.json is rebuilt, but it still configures resolution. - if (!rebuiltFiles.has(file) || resolutionConfigFileRegex.test(file)) { + if (resolutionConfigFileRegex.test(file)) { + return true + } + // A watched directory, such as a Tailwind content path, changes with any + // file inside it; resolution only reads files, which are listed too. + if (!rebuiltFiles.has(file) && !compilation.contextDependencies.has(file)) { return true } } diff --git a/packages/start-plugin-core/tests/rsbuild/rebuilt-modules.test.ts b/packages/start-plugin-core/tests/rsbuild/rebuilt-modules.test.ts new file mode 100644 index 00000000000..df9b3906431 --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/rebuilt-modules.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest' +import { mayHaveReResolvedImports } from '../../src/rsbuild/rebuilt-modules' +import type { Rspack } from '@rsbuild/core' + +function check(options: { + modified?: Array + removed?: Array + rebuilt?: Array + contextDependencies?: Array +}) { + return mayHaveReResolvedImports( + { + modifiedFiles: new Set(options.modified ?? []), + removedFiles: new Set(options.removed ?? []), + } as unknown as Rspack.Compiler, + { + builtModules: new Set( + (options.rebuilt ?? []).map((path) => ({ + resourceResolveData: { path }, + })), + ), + contextDependencies: new Set(options.contextDependencies ?? []), + } as unknown as Rspack.Compilation, + ) +} + +describe('mayHaveReResolvedImports', () => { + test('is false when every changed file was rebuilt', () => { + expect( + check({ modified: ['/app/src/a.ts'], rebuilt: ['/app/src/a.ts'] }), + ).toBe(false) + }) + + test('ignores watched directories that changed with a rebuilt file', () => { + expect( + check({ + modified: ['/app/src/a.ts', '/app/src'], + rebuilt: ['/app/src/a.ts', '/app/src/styles.css'], + contextDependencies: ['/app/src'], + }), + ).toBe(false) + }) + + test.each([ + { modified: ['/app/src/new-entry.ts'], rebuilt: [] }, + { modified: ['/app/package.json'], rebuilt: [] }, + { modified: ['/app/package.json'], rebuilt: ['/app/package.json'] }, + { modified: ['/app/tsconfig.app.json'], rebuilt: [] }, + { removed: ['/app/src/a.ts'], rebuilt: [] }, + ])('is true for $modified $removed', (options) => { + expect(check(options)).toBe(true) + }) +})