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..5f29038242d 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -1,7 +1,12 @@ +import { hash } from 'node:crypto' 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, + mayHaveReResolvedImports, +} from './rebuilt-modules' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' import type { GetConfigFn, @@ -17,6 +22,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 +485,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 +516,34 @@ 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) + const previous = + snapshot?.previous && !mayHaveReResolvedImports(compiler, compilation) + ? 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 +551,168 @@ export function registerClientBuildCapture( getClientBuild: () => clientBuild, } } + +interface DevClientBuildSnapshot { + clientBuild: NormalizedClientBuild + chunkGraph: string + // Digests: identifiers include loader chains hundreds of characters long. + 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, 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() + 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 { + getKey(module: RspackModule) { + return hash('sha1', getIdentifier(module), 'base64') + }, + 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)) + } + } + } + + // 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 hash('sha1', parts.join('\n'), 'base64') + }, + } +} + +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.getKey(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 key = reader.getKey(module) + const signature = reader.getSignature(module) + if (edgesByModule.get(key) !== signature) { + edgesByModule.set(key, 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..9c425185c88 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts @@ -0,0 +1,64 @@ +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. + */ +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) +} + +/** + * 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 (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 + } + } + return false +} 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..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 } 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' @@ -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,205 @@ 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) + 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('')) + await write( + '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'`) + 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 = + 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) + + 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')] }) + + 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 }) + } + }, + ) +}) 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) + }) +})