diff --git a/.changeset/rsbuild-incremental-import-protection.md b/.changeset/rsbuild-incremental-import-protection.md new file mode 100644 index 00000000000..59d2063da0a --- /dev/null +++ b/.changeset/rsbuild-incremental-import-protection.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Speed up Rsbuild import protection. Scans read the imports of only those modules that can report a violation, skip violations that were already reported, and development rebuilds check only rebuilt modules and their importers. diff --git a/e2e/react-start/import-protection/tests/import-protection.spec.ts b/e2e/react-start/import-protection/tests/import-protection.spec.ts index 16b5b073eb6..6800e95990b 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -1,10 +1,11 @@ import path from 'node:path' import fs from 'node:fs' import { expect } from '@playwright/test' -import { test } from '@tanstack/router-e2e-utils' +import { createHmrFileEditor, test } from '@tanstack/router-e2e-utils' import { getViolationArtifactName } from './violations.utils' import type { Violation } from './violations.utils' import type { Page } from '@playwright/test' +import type { RsbuildPlugin } from '@rsbuild/core' const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' const fixtureRoot = path.resolve(import.meta.dirname, '..') @@ -137,6 +138,185 @@ test.beforeEach(({}, testInfo) => { } }) +test('development rebuilds check changed modules, importers and re-resolved imports', async () => { + test.skip(toolchain !== 'rsbuild', 'Rsbuild incremental scan') + test.setTimeout(120_000) + const [{ createRsbuild }, { pluginReact }, { tanstackStart }] = + await Promise.all([ + import('@rsbuild/core'), + import('@rsbuild/plugin-react'), + import('@tanstack/react-start/plugin/rsbuild'), + ]) + const sourceDir = path.join(fixtureRoot, 'src/import-protection-hmr') + const targetFile = path.join(sourceDir, 'target.ts') + const markedFile = path.join(sourceDir, 'marked.ts') + const packageFile = path.join(sourceDir, 'package.json') + const perfFile = test.info().outputPath('import-protection-perf.json') + const target = (label: string) => `import { marked } from './marked' +import { value } from '#target' + +export const label = () => \`${label} \${marked()} \${value}\` +` + const importMap = (file: string) => + JSON.stringify({ imports: { '#target': file } }) + + const editor = createHmrFileEditor({ + rootDir: fixtureRoot, + files: { index: 'src/routes/index.tsx' }, + }) + await editor.capturePromise + const previousPerf = process.env.TSR_IMPORT_PROTECTION_PERF + const previousPerfFile = process.env.TSR_IMPORT_PROTECTION_PERF_FILE + const clientWarnings: Array = [] + let compileWaiters: Array<() => void> = [] + const nextCompile = () => + new Promise((resolve) => { + compileWaiters.push(resolve) + }) + const editAndCompile = async (file: string, source: string) => { + const compiled = nextCompile() + await fs.promises.writeFile(file, source) + await compiled + } + const readCounter = async (name: string) => + ( + JSON.parse(await fs.promises.readFile(perfFile, 'utf8')) as { + counters: Record + } + ).counters[name] ?? 0 + + try { + await fs.promises.mkdir(sourceDir, { recursive: true }) + await fs.promises.writeFile(packageFile, importMap('./safe.ts')) + await fs.promises.writeFile(targetFile, target('safe')) + await fs.promises.writeFile( + markedFile, + `export const marked = () => 'plain'\n`, + ) + await fs.promises.writeFile( + path.join(sourceDir, 'safe.ts'), + `export const value = 'safe'\n`, + ) + await fs.promises.writeFile( + path.join(sourceDir, 'secret.server.ts'), + `export const value = 'secret'\n`, + ) + await editor.replaceText( + 'index', + `import { createFileRoute, useHydrated } from '@tanstack/react-router'`, + `import { createFileRoute, useHydrated } from '@tanstack/react-router' +import { label } from '../import-protection-hmr/target' +import '../import-protection-hmr/secret.server'`, + ) + await editor.replaceText( + 'index', + '

Import Protection E2E

', + '

Import Protection E2E

\n

{label()}

', + ) + process.env.TSR_IMPORT_PROTECTION_PERF = '1' + process.env.TSR_IMPORT_PROTECTION_PERF_FILE = perfFile + + const rsbuild = await createRsbuild({ + cwd: fixtureRoot, + rsbuildConfig: { + root: fixtureRoot, + plugins: [ + pluginReact(), + tanstackStart(), + { + name: 'observe-import-protection-rebuilds', + setup(api) { + api.onAfterEnvironmentCompile(({ environment, stats }) => { + if (environment.name === 'client' && stats) { + for (const warning of stats.compilation.warnings) { + clientWarnings.push(warning.message) + } + } + }) + api.onAfterDevCompile(() => { + const waiters = compileWaiters + compileWaiters = [] + for (const resolve of waiters) { + resolve() + } + }) + }, + } satisfies RsbuildPlugin, + ], + }, + }) + const initialCompile = nextCompile() + const server = await rsbuild.createDevServer({ getPortSilently: true }) + const targetWarnings = () => + clientWarnings.filter((warning) => + warning.includes('Importer: src/import-protection-hmr/target'), + ) + try { + await initialCompile + expect(targetWarnings()).toEqual([]) + + // Already-reported fixture violations don't block the fast path. + await editAndCompile(targetFile, target('edited')) + expect(targetWarnings()).toEqual([]) + expect( + await readCounter('processAssets.incremental.modules'), + ).toBeGreaterThan(0) + + // Rebuilding a module with reported violations doesn't enrich them again. + const enriched = await readCounter('violations.enriched') + const incremental = await readCounter('processAssets.incremental.modules') + const indexCompiled = nextCompile() + await editor.replaceText( + 'index', + 'Import Protection E2E', + 'Import Protection E2E!', + ) + await indexCompiled + expect(await readCounter('violations.enriched')).toBe(enriched) + expect( + await readCounter('processAssets.incremental.modules'), + ).toBeGreaterThan(incremental) + + // Re-pointing the import map rebuilds no module. + await editAndCompile(packageFile, importMap('./secret.server.ts')) + expect( + targetWarnings().some( + (warning) => + warning.includes('Import: "#target"') && + warning.includes('secret.server'), + ), + ).toBe(true) + + await editAndCompile( + markedFile, + `import '@tanstack/react-start/server-only'\n\nexport const marked = () => 'server'\n`, + ) + expect( + clientWarnings.some( + (warning) => + warning.includes('marked') && + warning.includes('@tanstack/react-start/server-only'), + ), + ).toBe(true) + } finally { + await server.close() + } + } finally { + for (const [name, value] of [ + ['TSR_IMPORT_PROTECTION_PERF', previousPerf], + ['TSR_IMPORT_PROTECTION_PERF_FILE', previousPerfFile], + ] as const) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } + await editor.restoreFiles() + await fs.promises.rm(sourceDir, { recursive: true, force: true }) + } +}) + test('app loads successfully with mock mode', async ({ page }) => { await page.goto('/') await expect(page.getByTestId('heading')).toContainText( @@ -234,6 +414,23 @@ for (const mode of ['build', 'dev'] as const) { }) } +for (const mode of ['build', 'dev'] as const) { + test(`client violation traces start at the client entry in ${mode}`, async () => { + // Rsbuild skips modules outside the app until a scan finds a violation. + test.skip(toolchain !== 'rsbuild', 'Rsbuild scan') + const violations = (await readViolations(mode)).filter( + (v) => v.envType === 'client', + ) + + expect(violations.length).toBeGreaterThan(0) + for (const violation of violations) { + expect(violation.trace[0]?.file.replace(/\\/g, '/')).toMatch( + /\/default-entry\/client\.tsx$/, + ) + } + }) +} + test('deep trace includes full chain', async () => { const violations = await readViolations('build') diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index eca4051281b..da0e6da39ff 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -33,8 +33,9 @@ object: 1. `onBeforeBuild` 2. `onBeforeDevCompile` -3. `modifyRspackConfig` -4. `processAssets(..., { stage: 'report' })` +3. `onBeforeEnvironmentCompile` +4. `modifyRspackConfig` +5. `processAssets(..., { stage: 'report' })` `modifyRspackConfig` installs both the virtual-modules plugin and the environment-scoped import-protection post-loader. @@ -108,11 +109,15 @@ adapter queues them and flushes during compilation setup. It reconstructs the final view of the compilation from Rspack data by: -1. snapshotting each module and its outgoing connections +1. snapshotting each module that can report a violation and its outgoing + connections 2. collecting specifier and file violations plus possible marker modules 3. deduplicating marker modules and validating their persisted metadata -4. returning early when no violations remain -5. building the `ImportGraph` and diagnostic indexes only when needed +4. with `log: 'once'`, dropping violations this environment already reported, + because reporting would discard them after enrichment +5. returning early when no violations remain +6. snapshotting the remaining modules and building the `ImportGraph` and + diagnostic indexes only when needed Each `RspackModuleGraphNode` stores a module and its imported modules. Missing and errored target modules are skipped. @@ -120,15 +125,30 @@ Connections are not filtered by `getActiveState()` because inactive connections can still carry diagnostic evidence. Duplicate connections to the same target module collapse to one. -Snapshotting does not apply source-file eligibility. Intermediate modules remain -available for entry-to-violation traces, while the scanner applies importer and -rule checks. Normalized resource ids are used for rules, traces, and diagnostics; -`resourceResolveData.path` is preferred for original-source lookup. +Detection snapshots only modules that can report a violation: parseable source +files that pass the importer checks. In large apps most modules live in +`node_modules` and fail them, so a clean compilation never reads their +connections. Normalized resource ids are used for rules, traces, and +diagnostics; `resourceResolveData.path` is preferred for original-source lookup. -When violations exist, the adapter replays the snapshot to build `ImportGraph`; -it does not query outgoing connections again. A clean compilation avoids entry +When violations exist, the adapter snapshots the remaining modules, because +entry-to-violation traces pass through them, and replays the full snapshot in +module order to build `ImportGraph`. A clean compilation avoids entry traversal, graph indexes, and module-source loading. +A scan is clean when it leaves no violation to report: none were found, or, with +`log: 'once'`, every one was already reported. In development, a compilation +that directly follows a clean scan of the same environment first scans only +`compilation.builtModules`, their direct importers (a rebuilt dependency can +gain a marker without its importer changing), and entry modules, with the same +eligibility filter. It falls back to the full scan when it finds an unreported +violation, when the previous compilation skipped the report stage, or when a +changed or removed file is not the resource of a rebuilt module. Such files, +for example `package.json` import maps or `tsconfig.json` paths, can re-resolve +imports in modules that were not rebuilt. Changed watched directories +(`compilation.contextDependencies`, such as Tailwind content paths) are ignored +because the changed files inside them are listed too. + Diagnostic enrichment is lazy. The transform-result provider reads `module.originalSource().sourceAndMap()` when available. It gets original code from sourcemap `sourcesContent`, then falls back to diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index f9d46505e37..12cde40c33c 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -43,6 +43,10 @@ import { isFileExcluded, normalizeFilePath, } from '../import-protection/utils' +import { + getRebuiltModulesAndImporters, + mayHaveReResolvedImports, +} from './rebuilt-modules' import type { ImportProtectionBehavior, ImportProtectionOptions, @@ -91,6 +95,9 @@ type ImportProtectionRsbuildPluginAPI = { context: Pick onBeforeBuild: (handler: () => void) => void onBeforeDevCompile: (handler: () => void) => void + onBeforeEnvironmentCompile: ( + handler: (params: { environment: { name: string } }) => void, + ) => void modifyRspackConfig: ( handler: ( config: ImportProtectionRspackConfig, @@ -351,6 +358,37 @@ function dedupeKey(info: ViolationInfo): string { return dedupeViolationKey(info) } +/** The fields of the reported violation that identify it for deduplication. */ +function getViolationIdentity(candidate: CompilationViolation): { + type: CompilationViolation['type'] + importer: string + specifier: string + resolved?: string +} { + if (candidate.type === 'specifier') { + const { violation } = candidate.payload + return { + type: 'specifier', + importer: violation.importer, + specifier: violation.specifier, + resolved: violation.resolved, + } + } + if (candidate.type === 'marker') { + return { + type: 'marker', + importer: getModuleResource(candidate.importer), + specifier: candidate.source, + } + } + return { + type: 'file', + importer: getModuleResource(candidate.edge.importer), + specifier: candidate.source, + resolved: getModuleResource(candidate.edge.module), + } +} + export function getRsbuildResolvedImportProtectionCheck( relativeResolved: string, matchers: FileMatchers, @@ -1342,6 +1380,8 @@ export function registerImportProtection( const perf = isPerfEnabled() ? createPerfCollector() : undefined const extensionlessResolver = new ExtensionlessAbsoluteIdResolver() const envStates = new Map() + // `incremental` marks a compilation that directly follows a clean scan. + const scanStateByEnv = new Map() const shouldCheckImporterCache = new Map() const config: PluginConfig = { enabled: true, @@ -1473,6 +1513,14 @@ export function registerImportProtection( }) } + // The scanner ignores every other importer, so skip reading its connections. + function canReportViolation(module: RspackModule): boolean { + const importer = getModuleResource(module) + return ( + isImportProtectionSourceFile(importer) && shouldCheckImporter(importer) + ) + } + api.onBeforeBuild(() => { const startedAt = perf ? performance.now() : 0 applyUserConfig() @@ -1480,6 +1528,7 @@ export function registerImportProtection( extensionlessResolver.clear() shouldCheckImporterCache.clear() envStates.clear() + scanStateByEnv.clear() if (perf) { perf.time('onBeforeBuild', startedAt) } @@ -1500,6 +1549,14 @@ export function registerImportProtection( } }) + api.onBeforeEnvironmentCompile(({ environment }) => { + if (scanStateByEnv.get(environment.name) === 'clean') { + scanStateByEnv.set(environment.name, 'incremental') + } else { + scanStateByEnv.delete(environment.name) + } + }) + api.modifyRspackConfig((rspackConfig, utils) => { const startedAt = perf ? performance.now() : 0 applyUserConfig() @@ -1577,6 +1634,52 @@ export function registerImportProtection( const envType = getImportProtectionEnvType(config, envName) const envState = getOrCreateEnvState(envStates, envName) const matchers = getRulesForEnvironment(config, envName) + // `log: 'once'` never reports a violation twice, so skip seen ones early. + const isUnreported = (candidate: CompilationViolation) => + config.logMode === 'always' || + !envState.seenViolations.has( + dedupeViolationKey(getViolationIdentity(candidate)), + ) + + const canScanIncrementally = + scanStateByEnv.get(envName) === 'incremental' && + api.context.action === 'dev' && + !mayHaveReResolvedImports(context.compiler, context.compilation) + scanStateByEnv.delete(envName) + if (canScanIncrementally) { + const incrementalStartedAt = perf ? performance.now() : 0 + const incrementalScanner = createCompilationViolationScanner({ + config, + envType, + matchers, + shouldCheckImporter, + }) + forEachEntryModule({ + compilation: context.compilation, + visitModule: incrementalScanner.visitEntry, + }) + const changedModules = getRebuiltModulesAndImporters( + context.compilation, + ).filter(canReportViolation) + forEachModules({ + compilation: context.compilation, + modules: changedModules, + visitNode: incrementalScanner.visitNode, + }) + const incrementalCandidates = incrementalScanner.finish() + if (perf) { + perf.time('processAssets.incremental', incrementalStartedAt) + perf.count( + 'processAssets.incremental.modules', + changedModules.length, + ) + } + if (!incrementalCandidates.some(isUnreported)) { + scanStateByEnv.set(envName, 'clean') + return + } + } + const allModules = Array.from(context.compilation.modules) perf?.count('processAssets.modules.total', allModules.length) @@ -1591,42 +1694,51 @@ export function registerImportProtection( visitModule: violationScanner.visitEntry, }) const forEachStartedAt = perf ? performance.now() : 0 - const moduleGraphNodes: Array = [] + const nodesByModule = new Map() forEachModules({ compilation: context.compilation, - modules: allModules, + modules: allModules.filter(canReportViolation), visitNode(node) { - moduleGraphNodes.push(node) + nodesByModule.set(node.module, node) violationScanner.visitNode(node) }, }) if (perf) { perf.time('processAssets.forEachModules', forEachStartedAt) - perf.count('processAssets.modules.collected', moduleGraphNodes.length) - perf.count( - 'processAssets.imports.collected', - moduleGraphNodes.reduce( - (total, node) => total + node.imports.length, - 0, - ), - ) + perf.count('processAssets.modules.collected', nodesByModule.size) + let imports = 0 + for (const node of nodesByModule.values()) { + imports += node.imports.length + } + perf.count('processAssets.imports.collected', imports) } const candidateStartedAt = perf ? performance.now() : 0 - const candidates = violationScanner.finish() + const candidates = violationScanner.finish().filter(isUnreported) if (perf) { perf.time('processAssets.candidates.finish', candidateStartedAt) perf.count('processAssets.candidates', candidates.length) } if (candidates.length === 0) { + scanStateByEnv.set(envName, 'clean') return } const graphStartedAt = perf ? performance.now() : 0 + // Traces run from entries through modules the scan skipped. + forEachModules({ + compilation: context.compilation, + modules: allModules.filter((module) => !nodesByModule.has(module)), + visitNode(node) { + nodesByModule.set(node.module, node) + }, + }) const { importGraph, edgeIndex } = buildCompilationImportGraph({ compilation: context.compilation, - nodes: moduleGraphNodes, + nodes: allModules.flatMap( + (module) => nodesByModule.get(module) ?? [], + ), }) if (perf) { perf.time('processAssets.importGraph.build', graphStartedAt) @@ -1650,61 +1762,35 @@ export function registerImportProtection( } for (const candidate of candidates) { - let info: ViolationInfo - - if (candidate.type === 'specifier') { - const { payload } = candidate - info = await buildViolationInfo({ - config, - provider: getProvider(), - importGraph, - edgeIndex, - perf, - envName, - envType, - importer: payload.violation.importer, - importerModule: candidate.edge.importer, - source: payload.violation.specifier, - resolved: payload.violation.resolved, - transformedSources: [getModuleResource(candidate.edge.module)], - type: 'specifier', - pattern: payload.violation.patternText, - }) - } else if (candidate.type === 'marker') { - const importer = getModuleResource(candidate.importer) - info = await buildViolationInfo({ - config, - provider: getProvider(), - importGraph, - edgeIndex, - perf, - envName, - envType, - importer, - importerModule: candidate.importer, - source: candidate.source, - type: 'marker', - }) - } else { - const { edge, source } = candidate - const importer = getModuleResource(edge.importer) - const resolved = getModuleResource(edge.module) - info = await buildViolationInfo({ - config, - provider: getProvider(), - importGraph, - edgeIndex, - perf, - envName, - envType, - importer, - importerModule: edge.importer, - source, - resolved, - type: 'file', - pattern: candidate.pattern, - }) - } + const { type, importer, specifier, resolved } = + getViolationIdentity(candidate) + const info = await buildViolationInfo({ + config, + provider: getProvider(), + importGraph, + edgeIndex, + perf, + envName, + envType, + type, + importer, + source: specifier, + resolved, + ...(candidate.type === 'specifier' + ? { + importerModule: candidate.edge.importer, + transformedSources: [ + getModuleResource(candidate.edge.module), + ], + pattern: candidate.payload.violation.patternText, + } + : candidate.type === 'marker' + ? { importerModule: candidate.importer } + : { + importerModule: candidate.edge.importer, + pattern: candidate.pattern, + }), + }) await reportViolation({ config, @@ -1715,6 +1801,11 @@ export function registerImportProtection( info, }) } + + // Every candidate is now reported, which `log: 'once'` never repeats. + if (config.logMode !== 'always') { + scanStateByEnv.set(envName, 'clean') + } } finally { if (perf) { perf.time('processAssets', startedAt) 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/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index c419b2f6e88..a731b1c01ef 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -121,6 +121,7 @@ describe('registerImportProtection loader registration', () => { context: { action: 'build' }, onBeforeBuild() {}, onBeforeDevCompile() {}, + onBeforeEnvironmentCompile() {}, modifyRspackConfig(handler) { modifyRspackConfig = handler }, @@ -204,6 +205,7 @@ describe('registerImportProtection marker scope', () => { beforeBuild = handler }, onBeforeDevCompile() {}, + onBeforeEnvironmentCompile() {}, modifyRspackConfig() {}, processAssets(_options, handler) { processAssetsHandler = handler 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) + }) +})