From d35208049425565400d521faf086d06ab77e1e1f Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 15:30:17 +0000 Subject: [PATCH 1/6] perf(start-plugin-core): scan only rebuilt modules for Rsbuild import protection in dev After a clean scan, the next development compilation checks rebuilt modules, their direct importers (a rebuilt dependency can gain a marker without its importer changing), and entry modules. Any candidate, removed file, or compilation that skipped the report stage falls back to the full scan, so violations are still reported from the complete module graph. --- .../rsbuild-incremental-import-protection.md | 5 + .../tests/import-protection-hmr.spec.ts | 150 ++++++++++++++++++ .../rsbuild/INTERNALS-import-protection.md | 6 + .../src/rsbuild/import-protection.ts | 51 ++++++ .../src/rsbuild/rebuilt-modules.ts | 22 +++ 5 files changed, 234 insertions(+) create mode 100644 .changeset/rsbuild-incremental-import-protection.md create mode 100644 e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts create mode 100644 packages/start-plugin-core/src/rsbuild/rebuilt-modules.ts diff --git a/.changeset/rsbuild-incremental-import-protection.md b/.changeset/rsbuild-incremental-import-protection.md new file mode 100644 index 00000000000..5d0d14010aa --- /dev/null +++ b/.changeset/rsbuild-incremental-import-protection.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Speed up Rsbuild development rebuilds by checking import protection only for rebuilt modules and their importers when the previous scan was clean. diff --git a/e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts b/e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts new file mode 100644 index 00000000000..cfd43a0b94b --- /dev/null +++ b/e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts @@ -0,0 +1,150 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { createRsbuild, loadConfig, mergeRsbuildConfig } from '@rsbuild/core' +import { createHmrFileEditor } from '@tanstack/router-e2e-utils' +import { expect, test } from '@playwright/test' +import type { RsbuildPlugin } from '@rsbuild/core' + +const fixtureRoot = path.resolve(import.meta.dirname, '..') +const sourceDir = path.join(fixtureRoot, 'src/import-protection-hmr') +const targetFile = path.join(sourceDir, 'target.ts') +const markedFile = path.join(sourceDir, 'marked.ts') +const perfFile = path.join( + tmpdir(), + `tss-import-protection-hmr-${process.pid}.json`, +) + +const target = (label: string, imports = '') => `${imports} +import { marked } from './marked' + +export const label = () => \`${label} \${marked()}\` +` + +test('checks rebuilt modules and their importers during development rebuilds', async () => { + test.setTimeout(120_000) + 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 writeFile(file, source) + await compiled + } + const readIncrementalModules = async () => + ( + JSON.parse(await readFile(perfFile, 'utf8')) as { + counters: Record + } + ).counters['processAssets.incremental.modules'] ?? 0 + + await mkdir(sourceDir, { recursive: true }) + await writeFile(targetFile, target('safe')) + await writeFile(markedFile, `export const marked = () => 'plain'\n`) + await writeFile( + path.join(sourceDir, 'secret.server.ts'), + `export const secret = () => 'secret'\n`, + ) + await editor.replaceText( + 'index', + `import { createFileRoute } from '@tanstack/react-router'`, + `import { createFileRoute } from '@tanstack/react-router' +import { label } from '../import-protection-hmr/target'`, + ) + await editor.replaceText( + 'index', + '

Error overlay fixture

', + '

Error overlay fixture

\n

{label()}

', + ) + process.env.TSR_IMPORT_PROTECTION_PERF = '1' + process.env.TSR_IMPORT_PROTECTION_PERF_FILE = perfFile + + try { + const { content } = await loadConfig({ cwd: fixtureRoot }) + const rsbuild = await createRsbuild({ + cwd: fixtureRoot, + rsbuildConfig: mergeRsbuildConfig(content, { + root: fixtureRoot, + plugins: [ + { + 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 }) + try { + await initialCompile + expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') + + await editAndCompile(targetFile, target('edited')) + expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') + expect(await readIncrementalModules()).toBeGreaterThan(0) + + await editAndCompile( + targetFile, + target( + 'secret', + `import { secret } from './secret.server'\nexport const leak = () => secret()\n`, + ), + ) + expect(clientWarnings.join('\n')).toContain('secret.server') + + await editAndCompile(targetFile, target('restored')) + 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 { + restoreEnv('TSR_IMPORT_PROTECTION_PERF', previousPerf) + restoreEnv('TSR_IMPORT_PROTECTION_PERF_FILE', previousPerfFile) + await editor.restoreFiles() + await rm(sourceDir, { recursive: true, force: true }) + await rm(perfFile, { force: true }) + } +}) + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} 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..f9dfb49d8f7 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -129,6 +129,12 @@ When violations exist, the adapter replays the snapshot to build `ImportGraph`; it does not query outgoing connections again. A clean compilation avoids entry traversal, graph indexes, and module-source loading. +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. Any candidate, a removed file, or a skipped compilation falls +back to the full scan, so violations are always reported from complete data. + 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..f8a197900f5 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -43,6 +43,7 @@ import { isFileExcluded, normalizeFilePath, } from '../import-protection/utils' +import { getRebuiltModulesAndImporters } from './rebuilt-modules' import type { ImportProtectionBehavior, ImportProtectionOptions, @@ -1342,6 +1343,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, @@ -1480,6 +1483,7 @@ export function registerImportProtection( extensionlessResolver.clear() shouldCheckImporterCache.clear() envStates.clear() + scanStateByEnv.clear() if (perf) { perf.time('onBeforeBuild', startedAt) } @@ -1547,6 +1551,11 @@ export function registerImportProtection( compiler.hooks.thisCompilation.tap( 'TanStackStartImportProtectionVirtualModulesReady', () => { + if (scanStateByEnv.get(envName) === 'clean') { + scanStateByEnv.set(envName, 'incremental') + } else { + scanStateByEnv.delete(envName) + } shared.readyVmPlugins[envName] = true flushPendingWrites(shared, envName) }, @@ -1577,6 +1586,47 @@ export function registerImportProtection( const envType = getImportProtectionEnvType(config, envName) const envState = getOrCreateEnvState(envStates, envName) const matchers = getRulesForEnvironment(config, envName) + + // Removing a file can re-resolve imports in modules that are not rebuilt. + const canScanIncrementally = + scanStateByEnv.get(envName) === 'incremental' && + api.context.action === 'dev' && + !context.compiler.removedFiles?.size + 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, + ) + 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.length === 0) { + scanStateByEnv.set(envName, 'clean') + return + } + } + const allModules = Array.from(context.compilation.modules) perf?.count('processAssets.modules.total', allModules.length) @@ -1620,6 +1670,7 @@ export function registerImportProtection( } if (candidates.length === 0) { + scanStateByEnv.set(envName, 'clean') return } 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) +} From 52d96d7d21f3d2a46097b4d47564c8d8e76e16f0 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 14:20:12 +0000 Subject: [PATCH 2/6] perf(start-plugin-core): read connections only for Rsbuild importers that can violate Import protection now reads outgoing connections only for parseable source files that pass the importer checks; other modules cannot produce a violation. When a scan finds a candidate, the remaining modules are read before building the import graph, so traces still start at the entry. The incremental development scan uses the same filter. Moves the development rebuild test into the import-protection e2e suite and adds a check that Rsbuild client violation traces start at the client entry. Suggested by @agneym. --- .../rsbuild-incremental-import-protection.md | 2 +- .../tests/import-protection.spec.ts | 171 +++++++++++++++++- .../tests/import-protection-hmr.spec.ts | 150 --------------- .../rsbuild/INTERNALS-import-protection.md | 25 ++- .../src/rsbuild/import-protection.ts | 42 +++-- 5 files changed, 215 insertions(+), 175 deletions(-) delete mode 100644 e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts diff --git a/.changeset/rsbuild-incremental-import-protection.md b/.changeset/rsbuild-incremental-import-protection.md index 5d0d14010aa..c465011d366 100644 --- a/.changeset/rsbuild-incremental-import-protection.md +++ b/.changeset/rsbuild-incremental-import-protection.md @@ -2,4 +2,4 @@ '@tanstack/start-plugin-core': patch --- -Speed up Rsbuild development rebuilds by checking import protection only for rebuilt modules and their importers when the previous scan was clean. +Speed up Rsbuild import protection. Scans read the imports of only those modules that can report a violation, and development rebuilds after a clean scan 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..b61fad832e5 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,157 @@ test.beforeEach(({}, testInfo) => { } }) +test('development rebuilds check rebuilt modules and their importers', 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 perfFile = test.info().outputPath('import-protection-perf.json') + const target = (label: string, imports = '') => `${imports} +import { marked } from './marked' + +export const label = () => \`${label} \${marked()}\` +` + + 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 readIncrementalModules = async () => + ( + JSON.parse(await fs.promises.readFile(perfFile, 'utf8')) as { + counters: Record + } + ).counters['processAssets.incremental.modules'] ?? 0 + + await fs.promises.mkdir(sourceDir, { recursive: true }) + await fs.promises.writeFile(targetFile, target('safe')) + await fs.promises.writeFile( + markedFile, + `export const marked = () => 'plain'\n`, + ) + await fs.promises.writeFile( + path.join(sourceDir, 'secret.server.ts'), + `export const secret = () => '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'`, + ) + 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 + + try { + const rsbuild = await createRsbuild({ + cwd: fixtureRoot, + rsbuildConfig: { + root: fixtureRoot, + plugins: [ + pluginReact(), + // The fixture's own violations would keep every scan from being clean. + tanstackStart({ + importProtection: { include: ['src/import-protection-hmr/**'] }, + }), + { + 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 }) + try { + await initialCompile + expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') + + await editAndCompile(targetFile, target('edited')) + expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') + expect(await readIncrementalModules()).toBeGreaterThan(0) + + await editAndCompile( + targetFile, + target( + 'secret', + `import { secret } from './secret.server'\nexport const leak = () => secret()\n`, + ), + ) + expect(clientWarnings.join('\n')).toContain('secret.server') + + await editAndCompile(targetFile, target('restored')) + 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 +386,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/e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts b/e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts deleted file mode 100644 index cfd43a0b94b..00000000000 --- a/e2e/react-start/rsbuild/tests/import-protection-hmr.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { createRsbuild, loadConfig, mergeRsbuildConfig } from '@rsbuild/core' -import { createHmrFileEditor } from '@tanstack/router-e2e-utils' -import { expect, test } from '@playwright/test' -import type { RsbuildPlugin } from '@rsbuild/core' - -const fixtureRoot = path.resolve(import.meta.dirname, '..') -const sourceDir = path.join(fixtureRoot, 'src/import-protection-hmr') -const targetFile = path.join(sourceDir, 'target.ts') -const markedFile = path.join(sourceDir, 'marked.ts') -const perfFile = path.join( - tmpdir(), - `tss-import-protection-hmr-${process.pid}.json`, -) - -const target = (label: string, imports = '') => `${imports} -import { marked } from './marked' - -export const label = () => \`${label} \${marked()}\` -` - -test('checks rebuilt modules and their importers during development rebuilds', async () => { - test.setTimeout(120_000) - 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 writeFile(file, source) - await compiled - } - const readIncrementalModules = async () => - ( - JSON.parse(await readFile(perfFile, 'utf8')) as { - counters: Record - } - ).counters['processAssets.incremental.modules'] ?? 0 - - await mkdir(sourceDir, { recursive: true }) - await writeFile(targetFile, target('safe')) - await writeFile(markedFile, `export const marked = () => 'plain'\n`) - await writeFile( - path.join(sourceDir, 'secret.server.ts'), - `export const secret = () => 'secret'\n`, - ) - await editor.replaceText( - 'index', - `import { createFileRoute } from '@tanstack/react-router'`, - `import { createFileRoute } from '@tanstack/react-router' -import { label } from '../import-protection-hmr/target'`, - ) - await editor.replaceText( - 'index', - '

Error overlay fixture

', - '

Error overlay fixture

\n

{label()}

', - ) - process.env.TSR_IMPORT_PROTECTION_PERF = '1' - process.env.TSR_IMPORT_PROTECTION_PERF_FILE = perfFile - - try { - const { content } = await loadConfig({ cwd: fixtureRoot }) - const rsbuild = await createRsbuild({ - cwd: fixtureRoot, - rsbuildConfig: mergeRsbuildConfig(content, { - root: fixtureRoot, - plugins: [ - { - 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 }) - try { - await initialCompile - expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') - - await editAndCompile(targetFile, target('edited')) - expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') - expect(await readIncrementalModules()).toBeGreaterThan(0) - - await editAndCompile( - targetFile, - target( - 'secret', - `import { secret } from './secret.server'\nexport const leak = () => secret()\n`, - ), - ) - expect(clientWarnings.join('\n')).toContain('secret.server') - - await editAndCompile(targetFile, target('restored')) - 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 { - restoreEnv('TSR_IMPORT_PROTECTION_PERF', previousPerf) - restoreEnv('TSR_IMPORT_PROTECTION_PERF_FILE', previousPerfFile) - await editor.restoreFiles() - await rm(sourceDir, { recursive: true, force: true }) - await rm(perfFile, { force: true }) - } -}) - -function restoreEnv(name: string, value: string | undefined) { - if (value === undefined) { - delete process.env[name] - } else { - process.env[name] = value - } -} 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 f9dfb49d8f7..f62399c1f6a 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -108,11 +108,13 @@ 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 +5. 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,20 +122,23 @@ 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. 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. Any candidate, a removed file, or a skipped compilation falls -back to the full scan, so violations are always reported from complete data. +entry modules, with the same eligibility filter. Any candidate, a removed file, +or a skipped compilation falls back to the full scan, so violations are always +reported from complete data. Diagnostic enrichment is lazy. The transform-result provider reads `module.originalSource().sourceAndMap()` when available. It gets original code diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index f8a197900f5..f8a8708c3ed 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -1476,6 +1476,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() @@ -1607,7 +1615,7 @@ export function registerImportProtection( }) const changedModules = getRebuiltModulesAndImporters( context.compilation, - ) + ).filter(canReportViolation) forEachModules({ compilation: context.compilation, modules: changedModules, @@ -1641,25 +1649,23 @@ 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 @@ -1675,9 +1681,19 @@ export function registerImportProtection( } 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) From 27a9d684b3b7a720afa300780112da66afe0b2fa Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 14:52:17 +0000 Subject: [PATCH 3/6] fix(start-plugin-core): rescan Rsbuild imports that change resolution, skip reported violations A changed package.json import map, exports map or tsconfig paths can re-resolve imports in modules that are not rebuilt, so the incremental scan missed the new violation. Fall back to the full scan when a changed or removed file is not a rebuilt module, or is a package.json or tsconfig file. With log: 'once', a reported violation is never reported again, but every scan still rebuilt its trace and snippet before discarding it, and any violation kept development rebuilds off the fast path. Skip already-reported violations before enrichment, and treat a scan that leaves nothing to report as clean. --- .../rsbuild-incremental-import-protection.md | 2 +- .../tests/import-protection.spec.ts | 74 +++++++--- .../rsbuild/INTERNALS-import-protection.md | 22 ++- .../src/rsbuild/import-protection.ts | 138 ++++++++++-------- .../src/rsbuild/rebuilt-modules.ts | 37 +++++ 5 files changed, 181 insertions(+), 92 deletions(-) diff --git a/.changeset/rsbuild-incremental-import-protection.md b/.changeset/rsbuild-incremental-import-protection.md index c465011d366..59d2063da0a 100644 --- a/.changeset/rsbuild-incremental-import-protection.md +++ b/.changeset/rsbuild-incremental-import-protection.md @@ -2,4 +2,4 @@ '@tanstack/start-plugin-core': patch --- -Speed up Rsbuild import protection. Scans read the imports of only those modules that can report a violation, and development rebuilds after a clean scan check only rebuilt modules and their importers. +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 b61fad832e5..0702f880f76 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -138,7 +138,7 @@ test.beforeEach(({}, testInfo) => { } }) -test('development rebuilds check rebuilt modules and their importers', async () => { +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 }] = @@ -150,12 +150,15 @@ test('development rebuilds check rebuilt modules and their importers', async () 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, imports = '') => `${imports} -import { marked } from './marked' + const target = (label: string) => `import { marked } from './marked' +import { value } from '#target' -export const label = () => \`${label} \${marked()}\` +export const label = () => \`${label} \${marked()} \${value}\` ` + const importMap = (file: string) => + JSON.stringify({ imports: { '#target': file } }) const editor = createHmrFileEditor({ rootDir: fixtureRoot, @@ -175,28 +178,34 @@ export const label = () => \`${label} \${marked()}\` await fs.promises.writeFile(file, source) await compiled } - const readIncrementalModules = async () => + const readCounter = async (name: string) => ( JSON.parse(await fs.promises.readFile(perfFile, 'utf8')) as { counters: Record } - ).counters['processAssets.incremental.modules'] ?? 0 + ).counters[name] ?? 0 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 secret = () => 'secret'\n`, + `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 { label } from '../import-protection-hmr/target' +import '../import-protection-hmr/secret.server'`, ) await editor.replaceText( 'index', @@ -213,10 +222,7 @@ import { label } from '../import-protection-hmr/target'`, root: fixtureRoot, plugins: [ pluginReact(), - // The fixture's own violations would keep every scan from being clean. - tanstackStart({ - importProtection: { include: ['src/import-protection-hmr/**'] }, - }), + tanstackStart(), { name: 'observe-import-protection-rebuilds', setup(api) { @@ -241,24 +247,46 @@ import { label } from '../import-protection-hmr/target'`, }) 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(clientWarnings.join('\n')).not.toContain('import-protection-hmr') + expect(targetWarnings()).toEqual([]) + // Already-reported fixture violations don't block the fast path. await editAndCompile(targetFile, target('edited')) - expect(clientWarnings.join('\n')).not.toContain('import-protection-hmr') - expect(await readIncrementalModules()).toBeGreaterThan(0) + 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) - await editAndCompile( - targetFile, - target( - 'secret', - `import { secret } from './secret.server'\nexport const leak = () => secret()\n`, + // 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'), ), - ) - expect(clientWarnings.join('\n')).toContain('secret.server') + ).toBe(true) - await editAndCompile(targetFile, target('restored')) await editAndCompile( markedFile, `import '@tanstack/react-start/server-only'\n\nexport const marked = () => 'server'\n`, 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 f62399c1f6a..571b0a5a9b3 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -112,8 +112,10 @@ It reconstructs the final view of the compilation from Rspack data by: 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. snapshotting the remaining modules and building the `ImportGraph` and +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 @@ -133,12 +135,16 @@ 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. -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. Any candidate, a removed file, -or a skipped compilation falls back to the full scan, so violations are always -reported from complete data. +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. Diagnostic enrichment is lazy. The transform-result provider reads `module.originalSource().sourceAndMap()` when available. It gets original code diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index f8a8708c3ed..84ac879c52a 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -43,7 +43,10 @@ import { isFileExcluded, normalizeFilePath, } from '../import-protection/utils' -import { getRebuiltModulesAndImporters } from './rebuilt-modules' +import { + getRebuiltModulesAndImporters, + mayHaveReResolvedImports, +} from './rebuilt-modules' import type { ImportProtectionBehavior, ImportProtectionOptions, @@ -352,6 +355,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, @@ -1594,12 +1628,17 @@ 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)), + ) - // Removing a file can re-resolve imports in modules that are not rebuilt. const canScanIncrementally = scanStateByEnv.get(envName) === 'incremental' && api.context.action === 'dev' && - !context.compiler.removedFiles?.size + !mayHaveReResolvedImports(context.compiler, context.compilation) scanStateByEnv.delete(envName) if (canScanIncrementally) { const incrementalStartedAt = perf ? performance.now() : 0 @@ -1629,7 +1668,7 @@ export function registerImportProtection( changedModules.length, ) } - if (incrementalCandidates.length === 0) { + if (!incrementalCandidates.some(isUnreported)) { scanStateByEnv.set(envName, 'clean') return } @@ -1669,7 +1708,7 @@ export function registerImportProtection( } 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) @@ -1717,61 +1756,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, @@ -1782,6 +1795,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 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 +} From 054b21d7cec8ed185c80b387128c52bd38ccabd5 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 15:22:15 +0000 Subject: [PATCH 4/6] fix(start-plugin-core): ignore changed watched directories when checking Rsbuild re-resolution --- .../rsbuild/INTERNALS-import-protection.md | 4 +- .../src/rsbuild/rebuilt-modules.ts | 7 ++- .../tests/rsbuild/rebuilt-modules.test.ts | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/start-plugin-core/tests/rsbuild/rebuilt-modules.test.ts 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 571b0a5a9b3..57dc9b7a0f5 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -144,7 +144,9 @@ 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. +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 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) + }) +}) From b55d9a34543b3f5a516ee6344c853d6ababa3bd4 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 15:49:35 +0000 Subject: [PATCH 5/6] test(react-start): restore import-protection fixture when rebuild test setup fails --- .../tests/import-protection.spec.ts | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) 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 0702f880f76..6800e95990b 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -185,37 +185,37 @@ export const label = () => \`${label} \${marked()} \${value}\` } ).counters[name] ?? 0 - 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' + 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 + ) + 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 - try { const rsbuild = await createRsbuild({ cwd: fixtureRoot, rsbuildConfig: { From 30c51fbf9e070cebc24db0169e59de61530d8129 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 28 Oct 2026 19:51:58 +0000 Subject: [PATCH 6/6] refactor(start-plugin-core): advance Rsbuild import-protection scan state per environment compile --- .../src/rsbuild/INTERNALS-import-protection.md | 5 +++-- .../src/rsbuild/import-protection.ts | 16 +++++++++++----- .../tests/rsbuild/import-protection.test.ts | 2 ++ 3 files changed, 16 insertions(+), 7 deletions(-) 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 57dc9b7a0f5..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. diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 84ac879c52a..12cde40c33c 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -95,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, @@ -1546,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() @@ -1593,11 +1604,6 @@ export function registerImportProtection( compiler.hooks.thisCompilation.tap( 'TanStackStartImportProtectionVirtualModulesReady', () => { - if (scanStateByEnv.get(envName) === 'clean') { - scanStateByEnv.set(envName, 'incremental') - } else { - scanStateByEnv.delete(envName) - } shared.readyVmPlugins[envName] = true flushPendingWrites(shared, envName) }, 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