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
5 changes: 5 additions & 0 deletions .changeset/quiet-type-only-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/start-plugin-core': patch
---

Ignore fully type-only imports and re-exports when collecting import-protection sources so type-only references to protected modules do not trigger violations.
18 changes: 18 additions & 0 deletions docs/start/framework/react/guide/import-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ By default, files inside `node_modules` are excluded from resolved-target deny c

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.

## Type-Only Imports

Type-only imports and re-exports are ignored by import protection because they are erased from the runtime bundle and cannot leak environment-specific code.

```ts
import type { User } from './db.server'
import { type RequestHandler } from '@tanstack/react-start/server'

export type { User } from './db.server'
```

Mixed imports still count when they include at least one runtime value. Split the type and value imports if only the type is safe to cross the environment boundary.

```ts
// This is still checked because `getUsers` is a runtime value.
import { type User, getUsers } from './db.server'
```

## File Markers

You can explicitly mark a module as server-only or client-only by adding a side-effect import at the top of the file:
Expand Down
1 change: 1 addition & 0 deletions e2e/react-start/import-protection/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const navLinks = linkOptions([
{ to: '/noexternal-client-pkg', label: 'noExternal Client Pkg' },
{ to: '/alias-path-leak', label: 'Alias Path Leak' },
{ to: '/non-alias-namespace-leak', label: 'Non-Alias Namespace Leak' },
{ to: '/type-only-protected-import', label: 'Type-Only Protected Import' },
])

function RootComponent() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createFileRoute } from '@tanstack/react-router'
import { type RequestHandler } from '@tanstack/react-start/server'
import type { TypeOnlySecret } from '../violations/type-only.server'

type TypeOnlyStatus = TypeOnlySecret & {
requestHandler?: RequestHandler<Record<string, never>>
}

const status: TypeOnlyStatus = {
message: 'type-only protected imports are safe',
}

function getStatusMessage(): string {
return status.message
}

export const Route = createFileRoute('/type-only-protected-import')({
component: TypeOnlyProtectedImport,
})

function TypeOnlyProtectedImport() {
return (
<div>
<h1 data-testid="type-only-protected-import-heading">
Type-Only Protected Import
</h1>
<p data-testid="type-only-protected-import-status">
{getStatusMessage()}
</p>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type TypeOnlySecret = {
message: string
}

export const runtimeSecret = 'this value must never be imported by the client'
35 changes: 35 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 @@ -103,6 +103,24 @@ function findBarrelMarkerHits(violations: Array<Violation>): Array<Violation> {
)
}

function findTypeOnlyProtectedImportHits(
violations: Array<Violation>,
): Array<Violation> {
return violations.filter(
(v) =>
v.envType === 'client' &&
(v.importer.includes('type-only-protected-import') ||
v.importer.includes('type-only.server') ||
v.specifier.includes('type-only.server') ||
v.resolved?.includes('type-only.server') ||
v.trace.some(
(s) =>
s.file.includes('type-only-protected-import') ||
s.file.includes('type-only.server'),
)),
)
}

test.use({
// The mock proxy returns undefined-ish values, which may cause
// React rendering warnings — whitelist those
Expand Down Expand Up @@ -883,6 +901,23 @@ test('non-alias-namespace-leak does not expose real secret after hydration', asy
).not.toContainText('super-secret-server-key-12345')
})

test('type-only protected import route loads in mock mode', async ({ page }) => {
await expectRouteHeading(
page,
'/type-only-protected-import',
'type-only-protected-import-heading',
'Type-Only Protected Import',
)
})

for (const mode of ['build', 'dev', 'dev.warm'] as const) {
test(`type-only protected imports do not trigger violations in ${mode}`, async () => {
const violations = await readViolations(mode)

expect(findTypeOnlyProtectedImportHits(violations)).toEqual([])
})
}

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)
Expand Down
4 changes: 4 additions & 0 deletions e2e/react-start/import-protection/tests/violations.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ const routeDefinitions = [
['/alias-path-leak', 'alias-path-leak-heading'],
['/alias-path-namespace-leak', 'alias-path-namespace-leak-heading'],
['/non-alias-namespace-leak', 'non-alias-namespace-leak-heading'],
[
'/type-only-protected-import',
'type-only-protected-import-heading',
],
] as const

const routes = routeDefinitions.map(([route]) => route)
Expand Down
40 changes: 34 additions & 6 deletions packages/start-plugin-core/src/import-protection/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ function getStringLiteralValueStart(node: t.StringLiteral): number {
return node.start
}

function isTypeOnlyImportDeclaration(node: t.ImportDeclaration): boolean {
if (node.importKind === 'type') return true
if (node.specifiers.length === 0) return false

return node.specifiers.every(
(specifier) =>
t.isImportSpecifier(specifier) && specifier.importKind === 'type',
)
}

function isTypeOnlyExportNamedDeclaration(
node: t.ExportNamedDeclaration,
): boolean {
if (node.exportKind === 'type') return true
if (!node.source || node.declaration || node.specifiers.length === 0) {
return false
}

return node.specifiers.every(
(specifier) =>
t.isExportSpecifier(specifier) && specifier.exportKind === 'type',
)
}

function collectIdentifiersFromPattern(
pattern: t.LVal,
add: (name: string) => void,
Expand Down Expand Up @@ -149,8 +173,9 @@ function buildImportAnalysis(result: TransformResult): ImportAnalysis {

const visit = (node: t.Node): void => {
if (t.isImportDeclaration(node)) {
addSpecifierLocation(node.source)
if (node.importKind !== 'type') {
const isTypeOnly = isTypeOnlyImportDeclaration(node)
if (!isTypeOnly) {
addSpecifierLocation(node.source)
const source = node.source.value
const bindingInfo = getBindingInfo(source)
for (const specifier of node.specifiers) {
Expand All @@ -177,11 +202,12 @@ function buildImportAnalysis(result: TransformResult): ImportAnalysis {
}
}
} else if (t.isExportNamedDeclaration(node)) {
if (node.source && t.isStringLiteral(node.source)) {
const isTypeOnly = isTypeOnlyExportNamedDeclaration(node)
if (!isTypeOnly && node.source && t.isStringLiteral(node.source)) {
addSpecifierLocation(node.source)
}

if (node.exportKind !== 'type' && node.source?.value) {
if (!isTypeOnly && node.source?.value) {
const source = node.source.value
for (const specifier of node.specifiers) {
if (!t.isExportSpecifier(specifier)) continue
Expand All @@ -190,7 +216,7 @@ function buildImportAnalysis(result: TransformResult): ImportAnalysis {
}
}

if (node.exportKind !== 'type') {
if (!isTypeOnly) {
if (node.declaration) {
const decl = node.declaration
if (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) {
Expand All @@ -210,7 +236,9 @@ function buildImportAnalysis(result: TransformResult): ImportAnalysis {
}
}
} else if (t.isExportAllDeclaration(node)) {
addSpecifierLocation(node.source)
if (node.exportKind !== 'type') {
addSpecifierLocation(node.source)
}
} else if (t.isImportExpression(node)) {
if (t.isStringLiteral(node.source)) {
addSpecifierLocation(node.source)
Expand Down
32 changes: 32 additions & 0 deletions packages/start-plugin-core/tests/importProtection/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,43 @@ describe('getImportSources', () => {
expect(getImportSources(code)).toEqual(['bar', 'qux'])
})

test('skips type-only static import sources', () => {
const code = [
`import type { Foo } from './foo.server'`,
`import { type Bar } from './bar.server'`,
].join('\n')
expect(getImportSources(code)).toEqual([])
})

test('keeps mixed type and value static import sources', () => {
const code = `import { type Foo, bar } from './mixed.server'`
expect(getImportSources(code)).toEqual(['./mixed.server'])
})

test('keeps side-effect import sources', () => {
const code = `import './setup.server'`
expect(getImportSources(code)).toEqual(['./setup.server'])
})

test('extracts re-export sources', () => {
const code = `export { a } from './mod'\nexport * from "./other"`
expect(getImportSources(code)).toEqual(['./mod', './other'])
})

test('skips type-only re-export sources', () => {
const code = [
`export type { Foo } from './foo.server'`,
`export { type Bar } from './bar.server'`,
`export type * from './baz.server'`,
].join('\n')
expect(getImportSources(code)).toEqual([])
})

test('keeps mixed type and value re-export sources', () => {
const code = `export { type Foo, bar } from './mixed.server'`
expect(getImportSources(code)).toEqual(['./mixed.server'])
})

test('extracts dynamic import sources', () => {
const code = `const m = import('./lazy')\nconst n = import("./lazy2")`
expect(getImportSources(code)).toEqual(['./lazy', './lazy2'])
Expand Down
Loading