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
10 changes: 10 additions & 0 deletions e2e/react-start/import-protection/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { getWrappedSecret1 } from '../violations/edge-1'
// index.tsx -> violations/marked-server-only-edge.ts -> violations/marked-server-only.ts
import { getServerOnlyDataViaEdge } from '../violations/marked-server-only-edge'
import { secretServerFn } from '../violations/compiler-leak'
import { factorySafeServerFn } from '../violations/factory-safe/usage'
import {
safeIsomorphic,
safeServerFn,
safeServerOnly,
} from '../violations/boundary-safe'

export const Route = createFileRoute('/')({
component: Home,
Expand All @@ -21,6 +27,10 @@ function Home() {
<p data-testid="secret-deep">{getWrappedSecret1()}</p>
<p data-testid="server-only-data">{getServerOnlyDataViaEdge()}</p>
<p data-testid="compiler-ok">{String(typeof secretServerFn)}</p>
<p data-testid="factory-safe">{String(typeof factorySafeServerFn)}</p>
<p data-testid="boundary-safe-so">{String(typeof safeServerOnly)}</p>
<p data-testid="boundary-safe-sf">{String(typeof safeServerFn)}</p>
<p data-testid="boundary-safe-iso">{String(typeof safeIsomorphic)}</p>
</div>
)
}
27 changes: 27 additions & 0 deletions e2e/react-start/import-protection/src/violations/boundary-safe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
createIsomorphicFn,
createServerFn,
createServerOnlyFn,
} from '@tanstack/react-start'

import { getSecret } from './secret.server'

// NOTE: `getSecret` is only referenced inside compiler-recognized boundaries.
// The Start compiler should prune the `./secret.server` import from the client
// build output.

export const safeServerOnly = createServerOnlyFn(() => {
return getSecret()
})

export const safeServerFn = createServerFn().handler(async () => {
return getSecret()
})

export const safeIsomorphic = createIsomorphicFn()
.server(() => {
return getSecret()
})
.client(() => {
return 'client'
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'

import { getSecret } from '../secret.server'

// This file intentionally contains denied imports (./secret.server and
// @tanstack/react-start/server), but they are only referenced inside
// compiler-recognized server boundaries.

const secretMiddleware = createMiddleware({ type: 'function' }).server(
({ next }) => {
const req = getRequest()
const secret = getSecret()
return next({
context: { method: req.method, secret } as const,
})
},
)

export const createSecretFactoryServerFn = createServerFn().middleware([
secretMiddleware,
])
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createSecretFactoryServerFn } from './createSecretFactory'

export const factorySafeServerFn = createSecretFactoryServerFn().handler(
async ({ context, method }) => {
return {
name: 'factorySafeServerFn',
method,
context,
}
},
)
56 changes: 54 additions & 2 deletions e2e/react-start/import-protection/tests/import-protection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ test('all trace steps include line numbers', async () => {
// The entry (step 0) may not have one if it has no specifier pointing into it.
// All non-entry steps should have line numbers since they import something.
for (let i = 1; i < v!.trace.length; i++) {
const step = v!.trace[i]!
const step = v!.trace[i]
expect(
step.line,
`trace step ${i} (${step.file}) should have a line number`,
Expand All @@ -172,7 +172,7 @@ test('leaf trace step includes the denied import specifier', async () => {
expect(v).toBeDefined()

// The last trace step should be the leaf (edge-a) and include the specifier
const last = v!.trace[v!.trace.length - 1]!
const last = v!.trace[v!.trace.length - 1]
expect(last.file).toContain('edge-a')
expect(last.specifier).toContain('secret.server')
expect(last.line).toBeDefined()
Expand Down Expand Up @@ -267,6 +267,22 @@ test('dev violations include code snippets', async () => {
}
})

test('no violation for .server import used only inside compiler boundaries', async () => {
const violations = await readViolations('build')

// boundary-safe.ts imports secret.server.ts, but the import should be pruned
// from the client build because it is only referenced inside compiler
// boundaries (createServerFn/createServerOnlyFn/createIsomorphicFn).
const safeHits = violations.filter(
(v) =>
v.envType === 'client' &&
(v.importer.includes('boundary-safe') ||
v.trace.some((s) => s.file.includes('boundary-safe'))),
)

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

test('compiler-processed module has code snippet in dev', async () => {
const violations = await readViolations('dev')

Expand Down Expand Up @@ -365,3 +381,39 @@ test('build has violations in both client and SSR environments', async () => {
expect(clientViolations.length).toBeGreaterThanOrEqual(2)
expect(ssrViolations.length).toBeGreaterThanOrEqual(2)
})

test('no false positive for factory-safe middleware pattern in dev', async () => {
const violations = await readViolations('dev')

// createSecretFactory.ts uses @tanstack/react-start/server and ../secret.server
// ONLY inside createMiddleware().server() callbacks. The compiler strips these
// on the client, so import-protection must not fire for them.
const factoryHits = violations.filter(
(v) =>
v.envType === 'client' &&
(v.importer.includes('createSecretFactory') ||
v.importer.includes('factory-safe') ||
v.trace.some(
(s) =>
s.file.includes('createSecretFactory') ||
s.file.includes('factory-safe'),
)),
)

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

test('no false positive for boundary-safe pattern in dev', async () => {
const violations = await readViolations('dev')

// boundary-safe.ts imports secret.server.ts but only uses it inside
// compiler boundaries (createServerFn/createServerOnlyFn/createIsomorphicFn).
const safeHits = violations.filter(
(v) =>
v.envType === 'client' &&
(v.importer.includes('boundary-safe') ||
v.trace.some((s) => s.file.includes('boundary-safe'))),
)

expect(safeHits).toEqual([])
})
147 changes: 23 additions & 124 deletions e2e/react-start/import-protection/tests/violations.setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawn } from 'node:child_process'
import type { FullConfig } from '@playwright/test'
import { chromium, type FullConfig } from '@playwright/test'
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

Fix Playwright import style to satisfy lint rules.
The linter flags inline type specifiers and member ordering. Split the type-only import into its own line.

✅ Suggested fix
-import { chromium, type FullConfig } from '@playwright/test'
+import { chromium } from '@playwright/test'
+import type { FullConfig } from '@playwright/test'
📝 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
import { chromium, type FullConfig } from '@playwright/test'
import { chromium } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
🧰 Tools
🪛 ESLint

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

(sort-imports)


[error] 4-4: 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/tests/violations.setup.ts` at line 4, The
combined import "import { chromium, type FullConfig } from '@playwright/test'"
violates lint rules; separate the type-only specifier into its own import.
Replace the single import with two imports: one importing the runtime symbol
chromium and another importing the type FullConfig using "import type {
FullConfig } from '@playwright/test'"; update any usages of chromium and
FullConfig accordingly (symbols: chromium, FullConfig).

import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from '../package.json' with { type: 'json' }

Expand Down Expand Up @@ -77,96 +77,11 @@ const routes = [
'/client-only-jsx',
]

const extraModulePaths = [
'/src/routes/leaky-server-import.tsx',
'/src/violations/leaky-server-import.ts',
'/src/routes/client-only-violations.tsx',
'/src/routes/client-only-jsx.tsx',
'/src/violations/window-size.client.tsx',
// Index route violation modules: triggers client-env violations for
// file-based (edge-a, edge-3, compiler-leak → secret.server) and
// marker-based (marked-server-only) denials.
'/src/routes/index.tsx',
'/src/violations/edge-a.ts',
'/src/violations/edge-1.ts',
'/src/violations/edge-2.ts',
'/src/violations/edge-3.ts',
'/src/violations/compiler-leak.ts',
'/src/violations/marked-server-only-edge.ts',
'/src/violations/marked-server-only.ts',
]

/**
* Warmup pass: start a throwaway Vite dev server, hit all routes and module
* URLs so that Vite's dep optimization runs and populates the
* `node_modules/.vite` cache, then kill the server.
*
* On a cold start (no `.vite` cache), Vite's dep optimization runs lazily on
* first request. During that burst, `resolveId` can fire before the
* transform-cache plugin has stored `{code, map}` for importers, which means
* violations logged in this window may lack snippets.
*
* By running the warmup in a separate server instance we ensure the `.vite`
* dep cache exists before the real capture pass starts. The second server
* boots with a warm dep cache so all transforms are populated before
* `resolveId` fires for violation edges.
*/
async function warmupDepOptimization(
cwd: string,
port: number,
baseURL: string,
): Promise<void> {
const child = startDevServer(cwd, port)

// Drain output to /dev/null — we don't need warmup logs.
child.stdout?.resume()
child.stderr?.resume()

try {
await waitForHttpOk(baseURL, 30_000)

// Fetch HTML pages to trigger SSR transforms + dep discovery.
for (const route of routes) {
try {
await fetch(`${baseURL}${route}`, {
signal: AbortSignal.timeout(10_000),
})
} catch {
// ignore
}
}

// Fetch module URLs to trigger client-env transforms and ensure all
// external deps are discovered and optimized.
const moduleUrls = ['/@vite/client', ...extraModulePaths].map((u) =>
u.startsWith('http') ? u : `${baseURL}${u}`,
)
for (const url of moduleUrls) {
try {
await fetch(url, { signal: AbortSignal.timeout(10_000) })
} catch {
// ignore
}
}

// Wait for dep optimization to finish writing the .vite cache.
await new Promise((r) => setTimeout(r, 2000))
} finally {
await killChild(child)
}
}

async function captureDevViolations(cwd: string): Promise<void> {
const port = await getTestServerPort(`${packageJson.name}_dev`)
const baseURL = `http://localhost:${port}`
const logFile = path.resolve(cwd, 'webserver-dev.log')

// --- Warmup pass ----------------------------------------------------------
// Run a throwaway dev server to populate the .vite dep cache. This is a
// no-op when the cache already exists (warm start).
await warmupDepOptimization(cwd, port, baseURL)

// --- Capture pass (warm dep cache) ----------------------------------------
const out = fs.createWriteStream(logFile)
const child = startDevServer(cwd, port)

Expand All @@ -176,46 +91,30 @@ async function captureDevViolations(cwd: string): Promise<void> {
try {
await waitForHttpOk(baseURL, 30_000)

const htmlPages: Array<string> = []
for (const route of routes) {
try {
const htmlRes = await fetch(`${baseURL}${route}`, {
signal: AbortSignal.timeout(5000),
})
htmlPages.push(await htmlRes.text())
} catch {
// ignore
// Use a real browser to navigate to every route. This triggers SSR
// (server-env transforms + compiler cross-module resolution) AND client
// module loading (client-env transforms), exactly mirroring real usage.
// Direct HTTP fetches of module URLs do NOT trigger the compiler's
// cross-module resolution path that surfaces certain violations.
const browser = await chromium.launch()
try {
const context = await browser.newContext()
const page = await context.newPage()

for (const route of routes) {
try {
await page.goto(`${baseURL}${route}`, {
waitUntil: 'networkidle',
timeout: 15_000,
})
} catch {
// ignore navigation errors — we only care about server logs
}
}
}

const scriptSrcs = htmlPages
.flatMap((html) =>
Array.from(
html.matchAll(/<script[^>]*type="module"[^>]*src="([^"]+)"/g),
).map((m) => m[1]),
)
.filter(Boolean)
.slice(0, 10)

// Trigger module transforms by fetching module scripts referenced from
// HTML, plus known module paths to ensure code-split routes are
// transformed. Fetching these via HTTP triggers Vite's client-environment
// transform pipeline, which fires resolveId for each import and surfaces
// violations.
const moduleUrls = Array.from(
new Set(['/@vite/client', ...scriptSrcs, ...extraModulePaths]),
)
.map((u) => (u.startsWith('http') ? u : `${baseURL}${u}`))
.slice(0, 30)

for (const url of moduleUrls) {
try {
await fetch(url, {
signal: AbortSignal.timeout(10_000),
})
} catch {
// ignore
}
await context.close()
} finally {
await browser.close()
}

// Give the server a moment to flush logs.
Expand Down
Loading
Loading