Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rsbuild-import-protection-persistent-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/start-plugin-core': patch
---

Fix Rsbuild import protection failing to resolve its mock modules when Rspack's persistent cache restores the modules that import them.
114 changes: 113 additions & 1 deletion e2e/react-start/import-protection/tests/import-protection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import path from 'node:path'
import fs from 'node:fs'
import os from 'node:os'
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'
import { getViolationArtifactName } from './violations.utils'
import {
extractViolationsFromLog,
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, '..')
Expand Down Expand Up @@ -1024,3 +1029,110 @@ for (const mode of ['build', 'dev', 'dev.warm'] as const) {
expect(hit).toBeDefined()
})
}

test('persistent cache restores import-protection mocks and violations', async () => {
test.skip(toolchain !== 'rsbuild', 'Rspack persistent cache')
test.setTimeout(120_000)
const { createRsbuild, loadConfig, mergeRsbuildConfig } =
await import('@rsbuild/core')
const directory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'tss-import-protection-cache-'),
)

const build = async () => {
const results: Record<
string,
{
errors: Array<string>
warnings: Array<string>
importerBuilt?: boolean
}
> = {}
const { content } = await loadConfig({ cwd: fixtureRoot })
const rsbuild = await createRsbuild({
cwd: fixtureRoot,
rsbuildConfig: mergeRsbuildConfig(content, {
root: fixtureRoot,
output: { distPath: { root: path.join(directory, 'dist') } },
performance: {
buildCache: { cacheDirectory: path.join(directory, 'cache') },
},
plugins: [
{
name: 'collect-import-protection-diagnostics',
setup(api) {
api.onAfterEnvironmentCompile(({ environment, stats }) => {
if (stats) {
const modules = stats.toJson({
all: false,
modules: true,
nestedModules: true,
cachedModules: true,
orphanModules: true,
}).modules
const importer = modules
?.flatMap((module) => [module, ...(module.modules ?? [])])
.find((module) =>
module.nameForCondition
?.replaceAll('\\', '/')
.endsWith('/src/violations/leaky-server-import.ts'),
)
results[environment.name] = {
importerBuilt: importer?.built,
errors: stats.compilation.errors.map((e) => e.message),
warnings: stats.compilation.warnings
.map((w) => w.message)
.sort(),
}
}
})
},
} satisfies RsbuildPlugin,
],
}),
})
await rsbuild.build().catch(() => undefined)
return results
}

try {
const cold = await build()
const warm = await build()

for (const environment of ['client', 'ssr']) {
expect(cold[environment]?.errors).toEqual([])
expect(warm[environment]?.errors).toEqual([])
expect(warm[environment]?.warnings).toEqual(cold[environment]?.warnings)
}
expect(
warm.client?.warnings.some((warning) =>
warning.includes('secret.server'),
),
).toBe(true)
// A warm build must actually reuse the importer containing mock requests.
expect(cold.client?.importerBuilt).toBe(true)
expect(warm.client?.importerBuilt).toBe(false)

// These imports share a specifier but have different importer/usage metadata.
// Checking their presence prevents cold/warm equality hiding lost diagnostics.
const violations = extractViolationsFromLog(
warm.client!.warnings.join('\n'),
)
for (const importer of ['leaky-server-import', 'beforeload-server-leak']) {
const violation = violations.find(
(info) =>
info.type === 'specifier' &&
info.specifier === '@tanstack/react-start/server' &&
info.importer.includes(importer),
)
expect(violation).toBeDefined()
expect(violation!.importer).toMatch(/:\d+:\d+$/)
expect(
violation!.trace.some((step) => step.file.includes(importer)),
).toBe(true)
expect(violation!.snippet?.lines.join('\n')).toContain('getRequest()')
}
} finally {
await fs.promises.rm(directory, { recursive: true, force: true })
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import-protection core in `src/import-protection/INTERNALS.md`.
Rsbuild owns:

- post-transform enforcement through a Rspack post-loader
- virtual-module transport through `VirtualModulesPlugin`
- query-specific mock modules built through `mock-loader`
- compilation-truth reporting in `processAssets`
- final graph reconstruction from Rspack compilation data

Expand Down Expand Up @@ -36,8 +36,8 @@ object:
3. `modifyRspackConfig`
4. `processAssets(..., { stage: 'report' })`

`modifyRspackConfig` installs both the virtual-modules plugin and the
environment-scoped import-protection post-loader.
`modifyRspackConfig` installs the mock loader and the environment-scoped
import-protection post-loader.

## State Model

Expand All @@ -46,13 +46,6 @@ Per environment, Rsbuild keeps a smaller runtime state than Vite:
- `resolveCache`
- `seenViolations`

Shared adapter state contains:

- `virtualModules`
- `vmPlugins`
- `readyVmPlugins`
- `pendingWrites`

Notably absent compared to Vite:

- no `pendingViolations`
Expand Down Expand Up @@ -83,24 +76,25 @@ parse, or analyze original source. Imports removed by the Start compiler are no
longer part of this phase; imports with unsafe client/server usage remain in the
transformed code and are checked normally.

## Virtual Module Transport

Rsbuild reuses the shared mock generators, but not the Vite id transport.

Instead it writes environment-scoped virtual files under:
## Mock Module Transport

```text
<root>/node_modules/.virtual/import-protection/<env>/
```
Rsbuild reuses the shared mock generators through `mock-loader.ts`. The
import-protection post-loader only creates self-contained requests for the real,
package-owned `mock.js` entry, with the mock kind, environment, and encoded
payload in its query. The mock loader returns the module's source directly.

The important forms are:
Kinds are `silent`, `runtime`, and `edge`. Edge payloads retain the expected
exports, runtime request, and violation metadata for compilation-time reporting.
The mock entry is excluded from the import-protection post-loader.

- `mock-silent.mjs`
- `mock-runtime-<base64>.mjs`
- `mock-edge-<base64>.mjs`
Persistent cache can restore either the importer or the mock independently.
When a mock needs rebuilding, its real entry still resolves and its own loader
regenerates it from the query. No importer-loader side effect needs replaying.

Writes may happen before the `VirtualModulesPlugin` instance is ready, so the
adapter queues them and flushes during compilation setup.
Mock resources retain their complete query in the diagnostic graph and source
location lookup; using only the shared physical entry path would merge distinct
edges and lose their payloads. Ordinary resources keep their physical paths for
file rules and original-source lookup.

## Reporting Phase

Expand Down Expand Up @@ -198,6 +192,6 @@ When changing Rsbuild import protection, ask:

1. Can the change be expressed in shared analysis/rewrite/mock codegen instead?
2. If it is adapter-specific, is it really about post-transform enforcement,
virtual-file transport, or compilation-time reporting?
mock-module transport, or compilation-time reporting?
3. Can final compilation truth answer the question more simply than adding a new
pending state machine?
42 changes: 15 additions & 27 deletions packages/start-plugin-core/src/rsbuild/import-protection-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,17 @@ import {
} from '../import-protection/utils'
import {
IMPORT_PROTECTION_BUILD_INFO_FIELD,
ensureMockEdgeModule,
ensureRuntimeMockModule,
ensureSilentMockModule,
getOrCreateEnvState,
getRulesForEnvironment,
serializePattern,
shouldCheckImporterWithCache,
} from './import-protection'
import { createMockModuleId, createRuntimeMockModuleId } from './mock-module'
import type {
EnvRuntimeState,
ImportProtectionMarker,
PerfCollector,
PluginConfig,
SharedState,
} from './import-protection'
import type { ExtensionlessAbsoluteIdResolver } from '../import-protection/extensionlessAbsoluteIdResolver'
import type { TransformResult } from '../import-protection/sourceLocation'
Expand All @@ -49,7 +46,6 @@ export interface ImportProtectionLoaderOptions {
envStates: Map<string, EnvRuntimeState>
extensionlessResolver: ExtensionlessAbsoluteIdResolver
perf?: PerfCollector
shared: SharedState
shouldCheckImporterCache: Map<string, boolean>
}

Expand Down Expand Up @@ -123,7 +119,7 @@ async function transformImportProtection(
options: ImportProtectionLoaderOptions,
): Promise<ImportProtectionTransformResult> {
const startedAt = options.perf ? performance.now() : 0
const { config, envName, perf, shared } = options
const { config, envName, perf } = options
perf?.count('transform.calls')
perf?.count(`transform.env.${envName}`)

Expand Down Expand Up @@ -205,11 +201,9 @@ async function transformImportProtection(
return generateSelfContainedMockModule(exportNames)
}

const runtimeId = ensureRuntimeMockModule({
shared,
const runtimeId = createRuntimeMockModuleId({
envName,
mode: config.mockAccess,
env: envName,
importer: file,
specifier: relativeFile,
})
Expand Down Expand Up @@ -251,30 +245,24 @@ async function transformImportProtection(

const runtimeId =
config.command === 'build'
? ensureSilentMockModule(shared, envName)
: ensureRuntimeMockModule({
shared,
? createMockModuleId('silent', envName)
: createRuntimeMockModuleId({
envName,
mode: config.mockAccess,
env: envName,
importer: file,
specifier: source,
})

const replacement = ensureMockEdgeModule({
shared,
envName,
payload: {
exports: getExportsBySource().get(source) ?? [],
runtimeId,
violation: {
env: envName,
envType,
importer: file,
specifier: source,
...(resolved ? { resolved } : {}),
patternText: serializePattern(specifierMatch.pattern),
},
const replacement = createMockModuleId('edge', envName, {
exports: getExportsBySource().get(source) ?? [],
runtimeId,
violation: {
env: envName,
envType,
importer: file,
specifier: source,
...(resolved ? { resolved } : {}),
patternText: serializePattern(specifierMatch.pattern),
},
})

Expand Down
Loading