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
16 changes: 13 additions & 3 deletions docs/start/framework/react/guide/import-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,21 @@ The same idea applies to `createIsomorphicFn()`: the compiler removes the non-ta

If you see an import-protection violation for a file you expected to be "compiled away", check whether the import is referenced outside a compiler-recognized environment boundary (or is otherwise kept live by surviving code).

## False Positives: Dev vs Build

In **build mode**, the plugin defers violation checks until after tree-shaking. If an import is eliminated from the final bundle (e.g., a barrel re-exports a `.server` module but no client code actually uses that export), no violation is reported. This means build-time violations are definitive — if the build flags it, the import truly survived.

In **dev mode**, there is no tree-shaking. The plugin uses graph reachability to filter violations, but it cannot determine whether individual bindings are unused. This means barrel re-exports of `.server` or marker-protected modules may produce warnings even when the server-only exports would be tree-shaken away in production. These dev warnings are informational — run a build to confirm whether the violation is real.

The same applies to marker-protected files (`import '@tanstack/react-start/server-only'`). If a marked file is re-exported through a barrel but never consumed by client code, the build correctly suppresses the violation while dev may still warn.

## The `onViolation` Callback

You can hook into violations for custom reporting or to override the verdict:

```ts
importProtection: {
onViolation: (info) => {
onViolation: async (info) => {
// info.env -- environment name (e.g. 'client', 'ssr', ...)
// info.envType -- 'client' or 'server'
// info.type -- 'specifier', 'file', or 'marker'
Expand All @@ -352,7 +360,7 @@ importProtection: {
// info.snippet -- { lines, location } with the source code snippet (if available)
// info.message -- the formatted diagnostic message

// Return false to allow this specific import (override the denial)
// Return false (or Promise<false>) to allow this specific import (override the denial)
if (info.specifier === 'some-special-case') {
return false
}
Expand Down Expand Up @@ -392,7 +400,9 @@ interface ImportProtectionOptions {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
}
onViolation?: (info: ViolationInfo) => boolean | void
onViolation?: (
info: ViolationInfo,
) => boolean | void | Promise<boolean | void>
}
```

Expand Down
4 changes: 4 additions & 0 deletions e2e/react-start/import-protection/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ function RootComponent() {
<Link to="/client-only-jsx">Client-Only JSX</Link>
{' | '}
<Link to="/beforeload-leak">Beforeload Leak</Link>
{' | '}
<Link to="/component-server-leak">Component Server Leak</Link>
{' | '}
<Link to="/barrel-false-positive">Barrel False Positive</Link>
</nav>
<Outlet />
<Scripts />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
// Import from the barrel — NOT directly from .server.
// `getUsers` (from ./db.server) is only used inside a server fn (compiler strips it).
// `userColumns` (from ./shared) is a plain object used in JSX — not server-only.
// Tree-shaking should eliminate the ./db.server dependency entirely from the
// client bundle, so no import-protection violation should fire for it.
import { getUsers, userColumns, type User } from '../violations/barrel-reexport'
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

Split the type-only import and keep value imports sorted.

ESLint flags inline type specifiers and ordering here. Separate User into a type-only import to satisfy import/consistent-type-specifier-style and sort-imports.

🧹 Suggested fix
-import { getUsers, userColumns, type User } from '../violations/barrel-reexport'
+import { getUsers, userColumns } from '../violations/barrel-reexport'
+import type { User } from '../violations/barrel-reexport'
🧰 Tools
🪛 ESLint

[error] 8-8: Member 'User' of the import declaration should be sorted alphabetically.

(sort-imports)


[error] 8-8: Prefer using a top-level type-only import instead of inline type specifiers.

(import/consistent-type-specifier-style)

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

In `@e2e/react-start/import-protection/src/routes/barrel-false-positive.tsx` at
line 8, The import mixes value and type imports which violates
import/consistent-type-specifier-style and sort-imports; split into two imports
from '../violations/barrel-reexport': keep the value imports "getUsers" and
"userColumns" together (sorted) in one statement and add a separate type-only
import "import type { User } from '../violations/barrel-reexport'"; ensure the
value import appears before the type-only import to satisfy ordering.


const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})

export const Route = createFileRoute('/barrel-false-positive')({
loader: () => fetchUsers(),
component: BarrelFalsePositive,
})

function BarrelFalsePositive() {
const users = Route.useLoaderData()

return (
<div>
<h1 data-testid="barrel-heading">Barrel False Positive</h1>
<table>
<thead>
<tr>
<th data-testid="col-name">{userColumns.name}</th>
<th data-testid="col-email">{userColumns.email}</th>
</tr>
</thead>
<tbody>
{users.map((user: User) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createFileRoute } from '@tanstack/react-router'
// This import is used ONLY inside the route component.
// The router plugin code-splits the component into a lazy chunk,
// moving this import into the split module.
// Import protection must still catch this as a violation.
import { getComponentSecret } from '../violations/db-credentials.server'

export const Route = createFileRoute('/component-server-leak')({
component: ComponentServerLeak,
})

function ComponentServerLeak() {
return (
<div>
<h1 data-testid="component-leak-heading">Component Server Leak</h1>
<p data-testid="component-leak-secret">{getComponentSecret()}</p>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Server-only database module.
* Only exports server-only values: getUsers (DB access) and User type.
* The side-effect (DATABASE_URL log) should NOT reach the client.
*/

const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgres://admin:s3cret@localhost:5432/myapp'

console.log(`[db] connecting to ${DATABASE_URL}`)
Comment on lines +7 to +10
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

Avoid logging credentials — use a redacted connection string in the side-effect log.

The default fallback on line 8 embeds plaintext credentials (admin:s3cret), and line 10 immediately logs the full DATABASE_URL (credentials and all). While these are fake credentials, this fixture is documentation-level example code. The pattern is problematic in two ways:

  1. If process.env.DATABASE_URL is populated with real credentials in CI, they would be emitted to build/test logs.
  2. It models the anti-pattern of logging raw connection strings to readers who may copy this pattern.

Since the only requirement of the side-effect is that it be detectable (i.e., proves the module was loaded), logging a redacted or static string is sufficient.

🔒 Proposed fix
-console.log(`[db] connecting to ${DATABASE_URL}`)
+// Log a redacted URL so credentials are never emitted even if the env var is real.
+console.log(`[db] connecting to ${DATABASE_URL.replace(/:\/\/[^@]+@/, '://<redacted>@')}`)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgres://admin:s3cret@localhost:5432/myapp'
console.log(`[db] connecting to ${DATABASE_URL}`)
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgres://admin:s3cret@localhost:5432/myapp'
// Log a redacted URL so credentials are never emitted even if the env var is real.
console.log(`[db] connecting to ${DATABASE_URL.replace(/:\/\/[^@]+@/, '://<redacted>@')}`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@e2e/react-start/import-protection/src/violations/barrel-reexport/db.server.ts`
around lines 7 - 10, The current side-effect log prints the full DATABASE_URL
(variable DATABASE_URL) which may contain credentials; change the console.log
call in this module to avoid exposing secrets by either logging a redacted
connection string (mask username/password from DATABASE_URL before logging) or
simply logging a static/redacted message (e.g., "[db] connecting (redacted)") so
the module load remains detectable without emitting sensitive data; update the
console.log usage accordingly and keep the DATABASE_URL value assignment
unchanged.


export interface User {
id: number
name: string
email: string
}

const FAKE_USERS: Array<User> = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]

export async function getUsers(): Promise<Array<User>> {
await new Promise((r) => setTimeout(r, 50))
return FAKE_USERS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Server-only module protected by the marker import pattern.
* Re-exported through the barrel (index.ts) but never imported by
* barrel-false-positive.tsx — tree-shaking eliminates it from the
* client bundle, so no import-protection violation should fire.
*/
import '@tanstack/react-start/server-only'

export function foo(): string {
return 'server-only value from foo'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Barrel file — NOT a .server file itself.
* Re-exports from a .server module, a marker-protected module, and a shared module.
*
* The key scenario: a consumer imports { getUsers, userColumns } from here.
* - getUsers comes from ./db.server (server-only via file suffix)
* - foo comes from ./foo (server-only via marker import)
* - userColumns comes from ./shared (client-safe)
*
* If getUsers is only used inside a createServerFn handler (compiler strips it),
* and foo is never imported by the consumer at all, tree-shaking should eliminate
* both ./db.server and ./foo from the client bundle — so the import-protection
* plugin should NOT fire violations for either.
*/
export { getUsers, type User } from './db.server'
export { foo } from './foo'
export { userColumns } from './shared'
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Shared (non-server) module with client-safe values.
* These are safe to use on the client.
*/
import type { User } from './db.server'

export const userColumns = {
name: 'Full Name',
email: 'Email Address',
} as const

/** Placeholder for when no users have loaded yet. */
export const emptyUser: User = { id: 0, name: '', email: '' }
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Server-only secret used exclusively inside a route component.
* The route component is code-split by the router plugin into a separate
* lazy chunk, so the import to this file ends up in the split module —
* NOT the original route file. Import protection must still detect this.
*/
export const COMPONENT_SECRET = 'component-only-server-secret-99999'

export function getComponentSecret() {
return COMPONENT_SECRET
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const routes = [
'/client-only-violations',
'/client-only-jsx',
'/beforeload-leak',
'/barrel-false-positive',
]

async function captureDev(cwd: string): Promise<void> {
Expand Down
90 changes: 90 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 @@ -540,3 +540,93 @@ for (const mode of ['dev', 'dev.warm'] as const) {
expect(lookupHits).toEqual([])
})
}

// Component-level server leak: the .server import is used exclusively inside
// the route component function, which is code-split by the router plugin into
// a separate lazy chunk. Import protection must still detect this.

test('component-server-leak route loads in mock mode', async ({ page }) => {
await page.goto('/component-server-leak')
await expect(page.getByTestId('component-leak-heading')).toContainText(
'Component Server Leak',
)
})

for (const mode of ['build', 'dev', 'dev.warm'] as const) {
test(`component-server-leak: .server import inside code-split component is caught in ${mode}`, async () => {
const violations = await readViolations(mode)

const hits = violations.filter(
(v) =>
v.envType === 'client' &&
(v.specifier.includes('db-credentials.server') ||
v.resolved?.includes('db-credentials.server') ||
v.importer.includes('db-credentials.server') ||
v.trace.some(
(s) =>
s.file.includes('db-credentials.server') ||
s.file.includes('component-server-leak'),
)),
)

expect(hits.length).toBeGreaterThanOrEqual(1)
})
}

// Barrel re-export false positive: the barrel re-exports from a .server
// module AND a marker-protected module (foo.ts with `import 'server-only'`),
// but the component only uses values that would be tree-shaken away
// (getUsers inside createServerFn) or originate from a safe source, and
// never imports foo at all. Both the .server module and the marker module
// should NOT survive tree-shaking in the client bundle, so import-protection
// must NOT flag violations for either in build mode.
// In dev mode there is no tree-shaking so the violation is expected (mock).

test('barrel-false-positive route loads in mock mode', async ({ page }) => {
await page.goto('/barrel-false-positive')
await expect(page.getByTestId('barrel-heading')).toContainText(
'Barrel False Positive',
)
})

test('no false positive for barrel-reexport .server pattern in build', async () => {
const violations = await readViolations('build')

const barrelHits = violations.filter(
(v) =>
v.envType === 'client' &&
(v.importer.includes('barrel-reexport') ||
v.importer.includes('barrel-false-positive') ||
v.specifier.includes('db.server') ||
v.resolved?.includes('barrel-reexport') ||
v.trace.some(
(s) =>
s.file.includes('barrel-reexport') ||
s.file.includes('barrel-false-positive'),
)),
)

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

test('no false positive for barrel-reexport marker pattern in build', async () => {
const violations = await readViolations('build')

// foo.ts uses `import '@tanstack/react-start/server-only'` marker and is
// re-exported through the barrel, but never imported by the route.
// Tree-shaking should eliminate it — no marker violation should fire.
const markerHits = violations.filter(
(v) =>
v.envType === 'client' &&
(v.specifier.includes('server-only') ||
v.specifier.includes('foo') ||
v.resolved?.includes('foo')) &&
v.trace.some(
(s) =>
s.file.includes('barrel-reexport') ||
s.file.includes('barrel-false-positive'),
),
)

expect(markerHits).toEqual([])
})
2 changes: 2 additions & 0 deletions e2e/react-start/import-protection/tests/violations.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ const routes = [
'/client-only-violations',
'/client-only-jsx',
'/beforeload-leak',
'/component-server-leak',
'/barrel-false-positive',
]

async function navigateAllRoutes(
Expand Down
Loading
Loading