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
59 changes: 47 additions & 12 deletions docs/start/framework/react/guide/import-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ Import protection is enabled out of the box with these defaults:

- Files matching `**/*.server.*`
- The specifier `@tanstack/react-start/server`
- Excluded from file checks: `**/node_modules/**`

**Server environment denials:**

- 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).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify that excludeFiles currently suppresses marker checks too.

Line 41 and the option table describe excludeFiles as skipping only file-pattern checks, but runtime logic also bypasses marker-based denial for excluded resolved files (see packages/start-plugin-core/src/import-protection-plugin/plugin.ts, Lines 1358-1421). Please align wording to avoid user surprise.

📝 Suggested doc patch
-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`).

-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:

-| `client.excludeFiles` | `Pattern[]`          | `['**/node_modules/**']`          | Resolved files matching these patterns skip file-pattern checks (replaces defaults) |
+| `client.excludeFiles` | `Pattern[]`          | `['**/node_modules/**']`          | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
-| `server.excludeFiles` | `Pattern[]`          | `['**/node_modules/**']`          | Resolved files matching these patterns skip file-pattern checks (replaces defaults) |
+| `server.excludeFiles` | `Pattern[]`          | `['**/node_modules/**']`          | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |

Also applies to: 139-150, 450-454

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/start/framework/react/guide/import-protection.md` at line 41, Update the
docs text for the excludeFiles option to clearly state that excluded resolved
files are skipped for both file-pattern checks and marker-based denials (i.e.,
runtime marker checks in the import-protection-plugin), and apply this wording
change to all occurrences of the option in the guide so the docs match the
actual logic in import-protection-plugin where marker-based denial is bypassed
for excluded files.


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 @@ -130,6 +134,29 @@ 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:

```ts
importProtection: {
server: {
// Re-enable file-pattern checking for node_modules in the server environment
excludeFiles: [],
},
}
```

When you provide `excludeFiles`, it **fully replaces** the default (`['**/node_modules/**']`). To exclude additional paths while still skipping `node_modules`, include both:

```ts
importProtection: {
client: {
excludeFiles: ['**/node_modules/**', '**/vendor/**'],
},
}
```

## Scoping and Exclusions

By default, import protection only checks files inside Start's `srcDirectory`. You can change the scope with `include`, `exclude`, and `ignoreImporters`:
Expand Down Expand Up @@ -395,26 +422,34 @@ interface ImportProtectionOptions {
client?: {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
excludeFiles?: Array<string | RegExp>
}
server?: {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
excludeFiles?: Array<string | RegExp>
}
onViolation?: (
info: ViolationInfo,
) => boolean | void | Promise<boolean | void>
}
```

| 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 |
| `server` | `object` | See defaults above | Additional deny rules for the server environment |
| `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 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 |
3 changes: 3 additions & 0 deletions e2e/react-start/import-protection/error-dev-result.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions e2e/react-start/import-protection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@tanstack/react-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-tweet": "^3.3.0",
"vite": "^7.3.1"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions e2e/react-start/import-protection/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ function RootComponent() {
<Link to="/component-server-leak">Component Server Leak</Link>
{' | '}
<Link to="/barrel-false-positive">Barrel False Positive</Link>
{' | '}
<Link to="/noexternal-client-pkg">noExternal Client Pkg</Link>
</nav>
<Outlet />
<Scripts />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createFileRoute } from '@tanstack/react-router'
import { Tweet } from 'react-tweet'

export const Route = createFileRoute('/noexternal-client-pkg')({
component: NoExternalClientPkg,
})

function NoExternalClientPkg() {
return (
<div>
<h1 data-testid="noexternal-heading">noExternal .client Package</h1>
<div data-testid="noexternal-tweet">
<Tweet id="2023917847961821693" />
</div>
</div>
)
}
29 changes: 29 additions & 0 deletions e2e/react-start/import-protection/tests/import-protection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,3 +630,32 @@ test('no false positive for barrel-reexport marker pattern in build', async () =

expect(markerHits).toEqual([])
})

// noExternal .client package false positive: react-tweet's package.json
// exports resolve to `index.client.js` via the "default" condition.
// When listed in ssr.noExternal, Vite bundles it and the resolved path
// contains `.client.`, matching the default **/*.client.* deny pattern.
// Import-protection must NOT flag node_modules paths with file-based
// deny rules — these are third-party conventions, not user source code.

test('noexternal-client-pkg route loads in mock mode', async ({ page }) => {
await page.goto('/noexternal-client-pkg')
await expect(page.getByTestId('noexternal-heading')).toContainText(
'noExternal .client Package',
)
})

for (const mode of ['build', 'dev'] as const) {
test(`no false positive for noExternal react-tweet (.client entry) in ${mode}`, async () => {
const violations = await readViolations(mode)

const hits = violations.filter(
(v) =>
v.specifier.includes('react-tweet') ||
v.resolved?.includes('react-tweet') ||
v.importer.includes('noexternal-client-pkg'),
)

expect(hits).toEqual([])
})
}
6 changes: 6 additions & 0 deletions e2e/react-start/import-protection/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ export default defineConfig({
path.resolve(import.meta.dirname),
),
},
// react-tweet's package.json exports resolve to `index.client.js` which
// matches the default **/*.client.* deny pattern. Bundling it via
// noExternal must NOT trigger a false-positive import-protection violation.
ssr: {
noExternal: ['react-tweet'],
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ export function getDefaultImportProtectionRules(): DefaultImportProtectionRules
client: {
specifiers: clientSpecifiers,
files: ['**/*.server.*'],
excludeFiles: ['**/node_modules/**'],
},
server: {
specifiers: [],
files: ['**/*.client.*'],
excludeFiles: ['**/node_modules/**'],
},
}
}
Expand Down
112 changes: 68 additions & 44 deletions packages/start-plugin-core/src/import-protection-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,12 @@ interface PluginConfig {
client: {
specifiers: Array<CompiledMatcher>
files: Array<CompiledMatcher>
excludeFiles: Array<CompiledMatcher>
}
server: {
specifiers: Array<CompiledMatcher>
files: Array<CompiledMatcher>
excludeFiles: Array<CompiledMatcher>
}
}
includeMatchers: Array<CompiledMatcher>
Expand Down Expand Up @@ -350,8 +352,8 @@ export function importProtectionPlugin(
logMode: 'once',
maxTraceDepth: 20,
compiledRules: {
client: { specifiers: [], files: [] },
server: { specifiers: [], files: [] },
client: { specifiers: [], files: [], excludeFiles: [] },
server: { specifiers: [], files: [], excludeFiles: [] },
},
includeMatchers: [],
excludeMatchers: [],
Expand Down Expand Up @@ -540,6 +542,7 @@ export function importProtectionPlugin(
function getRulesForEnvironment(envName: string): {
specifiers: Array<CompiledMatcher>
files: Array<CompiledMatcher>
excludeFiles: Array<CompiledMatcher>
} {
const type = getEnvType(envName)
return type === 'client'
Expand Down Expand Up @@ -1021,20 +1024,28 @@ export function importProtectionPlugin(
const clientFiles = userOpts?.client?.files
? [...userOpts.client.files]
: [...defaults.client.files]
const clientExcludeFiles = userOpts?.client?.excludeFiles
? [...userOpts.client.excludeFiles]
: [...defaults.client.excludeFiles]
const serverSpecifiers = userOpts?.server?.specifiers
? dedupePatterns([...userOpts.server.specifiers])
: dedupePatterns([...defaults.server.specifiers])
const serverFiles = userOpts?.server?.files
? [...userOpts.server.files]
: [...defaults.server.files]
const serverExcludeFiles = userOpts?.server?.excludeFiles
? [...userOpts.server.excludeFiles]
: [...defaults.server.excludeFiles]

config.compiledRules.client = {
specifiers: compileMatchers(clientSpecifiers),
files: compileMatchers(clientFiles),
excludeFiles: compileMatchers(clientExcludeFiles),
}
config.compiledRules.server = {
specifiers: compileMatchers(serverSpecifiers),
files: compileMatchers(serverFiles),
excludeFiles: compileMatchers(serverExcludeFiles),
}

// Include/exclude
Expand Down Expand Up @@ -1344,56 +1355,69 @@ export function importProtectionPlugin(

env.graph.addEdge(resolved, normalizedImporter, source)

const fileMatch =
matchers.files.length > 0
? matchesAny(relativePath, matchers.files)
: undefined
// Skip file-based and marker-based denial for resolved paths that
// match the per-environment `excludeFiles` patterns. By default
// this includes `**/node_modules/**` so that third-party packages
// using `.client.` / `.server.` in their filenames (e.g. react-tweet
// exports `index.client.js`) are not treated as user-authored
// environment boundaries. Users can override `excludeFiles` per
// environment to narrow or widen this exclusion.
const isExcludedFile =
matchers.excludeFiles.length > 0 &&
matchesAny(relativePath, matchers.excludeFiles)

if (!isExcludedFile) {
const fileMatch =
matchers.files.length > 0
? matchesAny(relativePath, matchers.files)
: undefined

if (fileMatch) {
const info = await buildViolationInfo(
provider,
env,
envName,
envType,
importer,
normalizedImporter,
source,
{
type: 'file',
pattern: fileMatch.pattern,
resolved,
message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`,
},
)
return reportOrDeferViolation(
this,
env,
normalizedImporter,
info,
shouldDefer,
isPreTransformResolve,
)
}

if (fileMatch) {
const info = await buildViolationInfo(
const markerInfo = await buildMarkerViolationFromResolvedImport(
provider,
env,
envName,
envType,
importer,
normalizedImporter,
source,
{
type: 'file',
pattern: fileMatch.pattern,
resolved,
message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`,
},
)
return reportOrDeferViolation(
this,
env,
normalizedImporter,
info,
shouldDefer,
isPreTransformResolve,
)
}

const markerInfo = await buildMarkerViolationFromResolvedImport(
provider,
env,
envName,
envType,
importer,
source,
resolved,
relativePath,
)
if (markerInfo) {
return reportOrDeferViolation(
this,
env,
normalizedImporter,
markerInfo,
shouldDefer,
isPreTransformResolve,
resolved,
relativePath,
)
if (markerInfo) {
return reportOrDeferViolation(
this,
env,
normalizedImporter,
markerInfo,
shouldDefer,
isPreTransformResolve,
)
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/start-plugin-core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const importProtectionBehaviorSchema = z.enum(['error', 'mock'])
const importProtectionEnvRulesSchema = z.object({
specifiers: z.array(patternSchema).optional(),
files: z.array(patternSchema).optional(),
excludeFiles: z.array(patternSchema).optional(),
})

const importProtectionOptionsSchema = z
Expand Down
16 changes: 16 additions & 0 deletions packages/start-plugin-core/tests/importProtection/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ describe('getDefaultImportProtectionRules', () => {
)
})

test('returns client excludeFiles defaulting to node_modules', () => {
const rules = getDefaultImportProtectionRules()

expect(rules.client.excludeFiles).toEqual(
expect.arrayContaining(['**/node_modules/**']),
)
})

test('returns server rules', () => {
const rules = getDefaultImportProtectionRules()

Expand All @@ -30,6 +38,14 @@ describe('getDefaultImportProtectionRules', () => {
expect.arrayContaining(['**/*.client.*']),
)
})

test('returns server excludeFiles defaulting to node_modules', () => {
const rules = getDefaultImportProtectionRules()

expect(rules.server.excludeFiles).toEqual(
expect.arrayContaining(['**/node_modules/**']),
)
})
})

describe('getMarkerSpecifiers', () => {
Expand Down
Loading
Loading