Skip to content
Merged
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
40 changes: 20 additions & 20 deletions docs/start/framework/react/guide/import-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Import protection is enabled out of the box with these defaults:
- Files matching `**/*.client.*`
- Excluded from file checks: `**/node_modules/**`

By default, files inside `node_modules` are excluded from file-pattern checks via the `excludeFiles` option. This prevents false positives from third-party packages whose resolved filenames contain `.client.` or `.server.`. If you need to check third-party files, set `excludeFiles: []` on the relevant environment — see [Configuring Deny Rules](#configuring-deny-rules).
By default, files inside `node_modules` are excluded from resolved-target deny checks via the `excludeFiles` option. This prevents false positives from third-party packages whose resolved filenames contain `.client.` or `.server.`. If you need to check third-party files, set `excludeFiles: []` on the relevant environment — see [Configuring Deny Rules](#configuring-deny-rules).

These defaults mean you can use the `.server.ts` / `.client.ts` naming convention to restrict files to a single environment without any configuration. To also deny entire directories (e.g. `server/` or `client/`), add them via `files` in your [deny rules configuration](#configuring-deny-rules) — for example `files: ['**/*.server.*', '**/server/**']` for the client environment.

Expand Down Expand Up @@ -136,7 +136,7 @@ export default defineConfig({

### Checking third-party packages

By default, resolved files inside `node_modules` are excluded from file-pattern checks. This avoids false positives from packages that happen to use `.client.` or `.server.` in their distribution filenames. If you want to re-enable checking for a specific environment, set `excludeFiles` to an empty array:
By default, resolved files inside `node_modules` are excluded from resolved-target deny checks (file-pattern and marker checks). This avoids false positives from packages that happen to use `.client.` or `.server.` in their distribution filenames. If you want to re-enable checking for a specific environment, set `excludeFiles` to an empty array:

```ts
importProtection: {
Expand Down Expand Up @@ -435,21 +435,21 @@ interface ImportProtectionOptions {
}
```

| Option | Type | Default | Description |
| --------------------- | -------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin |
| `behavior` | `string \| object` | `{ dev: 'mock', build: 'error' }` | What to do on violation |
| `log` | `'once' \| 'always'` | `'once'` | Whether to deduplicate repeated violations |
| `include` | `Pattern[]` | Start's `srcDirectory` | Only check importers matching these patterns |
| `exclude` | `Pattern[]` | `[]` | Skip importers matching these patterns |
| `ignoreImporters` | `Pattern[]` | `[]` | Ignore violations from these importers |
| `maxTraceDepth` | `number` | `20` | Maximum depth for import traces |
| `client` | `object` | See defaults above | Additional deny rules for the client environment |
| `client.specifiers` | `Pattern[]` | Framework server specifiers | Specifier patterns denied in the client environment (additive with defaults) |
| `client.files` | `Pattern[]` | `['**/*.server.*']` | File patterns denied in the client environment (replaces defaults) |
| `client.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip file-pattern checks (replaces defaults) |
| `server` | `object` | See defaults above | Additional deny rules for the server environment |
| `server.specifiers` | `Pattern[]` | `[]` | Specifier patterns denied in the server environment (replaces defaults) |
| `server.files` | `Pattern[]` | `['**/*.client.*']` | File patterns denied in the server environment (replaces defaults) |
| `server.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip file-pattern checks (replaces defaults) |
| `onViolation` | `function` | `undefined` | Callback invoked on every violation |
| Option | Type | Default | Description |
| --------------------- | -------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin |
| `behavior` | `string \| object` | `{ dev: 'mock', build: 'error' }` | What to do on violation |
| `log` | `'once' \| 'always'` | `'once'` | Whether to deduplicate repeated violations |
| `include` | `Pattern[]` | Start's `srcDirectory` | Only check importers matching these patterns |
| `exclude` | `Pattern[]` | `[]` | Skip importers matching these patterns |
| `ignoreImporters` | `Pattern[]` | `[]` | Ignore violations from these importers |
| `maxTraceDepth` | `number` | `20` | Maximum depth for import traces |
| `client` | `object` | See defaults above | Additional deny rules for the client environment |
| `client.specifiers` | `Pattern[]` | Framework server specifiers | Specifier patterns denied in the client environment (additive with defaults) |
| `client.files` | `Pattern[]` | `['**/*.server.*']` | File patterns denied in the client environment (replaces defaults) |
| `client.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
| `server` | `object` | See defaults above | Additional deny rules for the server environment |
| `server.specifiers` | `Pattern[]` | `[]` | Specifier patterns denied in the server environment (replaces defaults; defaults for `server.specifiers` are `[]`, so unlike `client.specifiers` this isn't additive) |
| `server.files` | `Pattern[]` | `['**/*.client.*']` | File patterns denied in the server environment (replaces defaults) |
| `server.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
| `onViolation` | `function` | `undefined` | Callback invoked on every violation |
2 changes: 2 additions & 0 deletions e2e/react-start/import-protection/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ port-*.txt
webserver-*.log
error-build-result.json
error-build.log
error-dev-result.json
error-dev.log
3 changes: 0 additions & 3 deletions e2e/react-start/import-protection/error-dev-result.json

This file was deleted.

5 changes: 3 additions & 2 deletions e2e/react-start/import-protection/tests/error-mode.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ async function captureDev(cwd: string): Promise<void> {
for (const route of routes) {
try {
await page.goto(`${baseURL}${route}`, {
waitUntil: 'networkidle',
timeout: 15_000,
// Vite dev keeps long-lived connections; 'networkidle' can hang.
waitUntil: 'load',
timeout: 30_000,
})
} catch {
// expected — modules fail with 500 in error mode
Expand Down
35 changes: 31 additions & 4 deletions e2e/react-start/import-protection/tests/violations.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ const routes = [
'/barrel-false-positive',
]

const routeReadyTestIds: Record<string, string> = {
'/': 'heading',
'/leaky-server-import': 'leaky-heading',
'/client-only-violations': 'client-only-heading',
'/client-only-jsx': 'client-only-jsx-heading',
'/beforeload-leak': 'beforeload-leak-heading',
'/component-server-leak': 'component-leak-heading',
'/barrel-false-positive': 'barrel-heading',
}

async function navigateAllRoutes(
baseURL: string,
browser: Awaited<ReturnType<typeof chromium.launch>>,
Expand All @@ -92,10 +102,27 @@ async function navigateAllRoutes(

for (const route of routes) {
try {
await page.goto(`${baseURL}${route}`, {
waitUntil: 'networkidle',
timeout: 15_000,
})
// Prefer 'networkidle' (ensures route chunks are actually fetched), but
// fall back if it hangs in certain CI environments.
try {
await page.goto(`${baseURL}${route}`, {
waitUntil: 'networkidle',
timeout: 15_000,
})
} catch {
await page.goto(`${baseURL}${route}`, {
waitUntil: 'load',
timeout: 30_000,
})
}

const testId = routeReadyTestIds[route]
if (testId) {
await page.getByTestId(testId).waitFor({ timeout: 10_000 })
}

// Allow any deferred transforms/logging to flush.
await new Promise((r) => setTimeout(r, 750))
} catch {
// ignore navigation errors — we only care about server logs
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ analysing a module's exports. These are tracked via `serverFnLookupModules` and
### Central functions

- **`handleViolation()`**: Formats + reports (or silences) the violation. Returns
a mock module ID (or `{ id, syntheticNamedExports }` in build) so `resolveId`
can substitute the offending import. May also return `undefined` (suppressed by
`onViolation` or silent+error in dev) or throw via `ctx.error()` (dev+error).
a mock-edge module ID (string) so `resolveId` can substitute the offending
import. May also return `undefined` (suppressed by `onViolation` or
silent+error in dev) or throw via `ctx.error()` (dev+error).
- **`reportOrDeferViolation()`**: Dispatch layer. Either defers (stores for later
verification) or reports immediately, depending on `shouldDefer`.

Expand Down Expand Up @@ -138,8 +138,11 @@ In dev, each violation gets a **per-importer mock edge module** that:
- Delegates to a **runtime mock module** that contains a recursive Proxy and
optional runtime diagnostics (console warnings when mocked values are used).

This differs from build mode, where `syntheticNamedExports: true` lets Rollup
handle named export resolution from the silent mock.
This differs from build mode, where each violation gets a **per-violation mock
edge module** wrapping a unique base mock module
(`\0tanstack-start-import-protection:mock:build:N`). The edge module re-exports
the named exports the importer expects, just like in dev, ensuring compatibility
with both Rollup and Rolldown (which doesn't support `syntheticNamedExports`).

## Build Mode Strategy

Expand All @@ -148,15 +151,19 @@ handle named export resolution from the silent mock.
Both mock and error build modes follow the same pattern:

1. **`resolveId`**: Call `handleViolation({ silent: true })`. Generate a
**unique per-violation mock module ID** (`\0tanstack-start-import-protection:mock:build:N`).
Store the violation + mock ID in `env.deferredBuildViolations`. Return the
mock ID so Rollup substitutes the offending import.
**unique per-violation mock-edge module** that wraps a base mock module
(`\0tanstack-start-import-protection:mock:build:N`) and provides explicit
named exports matching the importer's import bindings. Store the violation +
mock-edge ID in `env.deferredBuildViolations`. Return the mock-edge ID so the
bundler substitutes the offending import.

2. **`load`**: Return a silent Proxy-based mock module (same code as
`RESOLVED_MOCK_MODULE_ID`) with `syntheticNamedExports: true`.
2. **`load`**: For the base mock module, return a silent Proxy-based mock. For
the mock-edge module, return code that imports from the base mock and
re-exports the expected named bindings (e.g. `export const Foo = mock.Foo`).

3. **Tree-shaking**: Rollup processes the bundle normally. If no binding from
the mock module is actually used at runtime, the mock module is eliminated.
3. **Tree-shaking**: The bundler processes the bundle normally. If no binding from
the mock-edge module is actually used at runtime, both the edge and base
modules are eliminated.

4. **`generateBundle`**: Inspect the output chunks. For each deferred violation,
check whether its unique mock module ID appears in any chunk's `modules`.
Expand All @@ -171,15 +178,16 @@ Both mock and error build modes follow the same pattern:
The original `RESOLVED_MOCK_MODULE_ID` is a single shared virtual module used
for all mock-mode violations. If multiple violations are deferred, we need to
know _which specific ones_ survived tree-shaking. A shared ID would tell us
"something survived" but not which violation it corresponds to. The unique IDs
(`...mock:build:0`, `...mock:build:1`, etc.) provide this granularity.
"something survived" but not which violation it corresponds to. Each violation
gets a unique mock-edge module (wrapping a unique base mock
`...mock:build:0`, `...mock:build:1`, etc.) to provide this granularity.

### Why mocking doesn't affect tree-shaking

From the consumer's perspective, the import bindings are identical whether they
point to the real module or the mock. Rollup tree-shakes based on binding usage,
not module content. If a binding from the barrel's re-export of `.server` is
unused after the Start compiler strips server fn handlers, tree-shaking
point to the real module or the mock. The bundler tree-shakes based on binding
usage, not module content. If a binding from the barrel's re-export of `.server`
is unused after the Start compiler strips server fn handlers, tree-shaking
eliminates it regardless of whether it points to real DB code or a Proxy mock.

### Per-environment operation
Expand Down
Loading
Loading