Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions docs/start/framework/react/guide/import-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,14 @@ 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:
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
Copy Markdown
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
Copy Markdown
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