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
2 changes: 2 additions & 0 deletions e2e/react-start/import-protection/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ port-*.txt
.tanstack

webserver-*.log
error-build-result.json
error-build.log
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 @@ -32,6 +32,8 @@ function RootComponent() {
<Link to="/client-only-violations">Client-Only Violations</Link>
{' | '}
<Link to="/client-only-jsx">Client-Only JSX</Link>
{' | '}
<Link to="/beforeload-leak">Beforeload Leak</Link>
</nav>
<Outlet />
<Scripts />
Expand Down
24 changes: 24 additions & 0 deletions e2e/react-start/import-protection/src/routes/beforeload-leak.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createFileRoute } from '@tanstack/react-router'
import { getSessionFromRequest } from '../violations/beforeload-server-leak'

export const Route = createFileRoute('/beforeload-leak')({
// beforeLoad is NOT stripped by the compiler on the client side.
// It is not in splitRouteIdentNodes or deleteNodes, so this import
// chain survives: beforeload-leak.tsx -> beforeload-server-leak.ts
// -> @tanstack/react-start/server
// This is a TRUE POSITIVE violation in the client environment.
beforeLoad: () => {
const session = getSessionFromRequest()
return { session }
},
component: BeforeloadLeakRoute,
})

function BeforeloadLeakRoute() {
return (
<div>
<h1 data-testid="beforeload-leak-heading">Beforeload Leak</h1>
<p data-testid="beforeload-leak-status">Route loaded</p>
</div>
)
}
16 changes: 16 additions & 0 deletions e2e/react-start/import-protection/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import {
safeServerFn,
safeServerOnly,
} from '../violations/boundary-safe'
import {
crossBoundarySafeServerFn,
crossBoundarySafeWithAuth,
} from '../violations/cross-boundary-safe/usage'
import { safeFn } from '../violations/cross-boundary-leak/safe-consumer'
import { leakyGetSharedData } from '../violations/cross-boundary-leak/leaky-consumer'

export const Route = createFileRoute('/')({
component: Home,
Expand All @@ -31,6 +37,16 @@ function Home() {
<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>
<p data-testid="cross-boundary-safe-sf">
{String(typeof crossBoundarySafeServerFn)}
</p>
<p data-testid="cross-boundary-safe-mw">
{String(typeof crossBoundarySafeWithAuth)}
</p>
<p data-testid="cross-boundary-leak-safe">{String(typeof safeFn)}</p>
<p data-testid="cross-boundary-leak-leaky">
{String(typeof leakyGetSharedData)}
</p>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getRequest } from '@tanstack/react-start/server'

// This utility wraps a denied server import and is used in a route's
// `beforeLoad` hook. `beforeLoad` is NOT in the compiler's
// splitRouteIdentNodes or deleteNodes lists, so it survives on the client.
// Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.
export function getSessionFromRequest() {
const req = getRequest()
return { sessionId: req.headers.get('x-session-id') }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getSharedData } from './shared-util'

// Leaky: uses the shared utility OUTSIDE any compiler boundary.
// This must still trigger a violation in the client environment
// even if safe-consumer.ts already loaded shared-util.ts via
// a fetchModule chain that silenced its resolveId.
export function leakyGetSharedData() {
return getSharedData()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createServerFn } from '@tanstack/react-start'
import { getSharedData } from './shared-util'

// Safe: uses the shared utility ONLY inside a compiler boundary.
// The compiler strips this from the client; fetchModule adds shared-util.ts
// to the serverFnLookupModules set.
export const safeFn = createServerFn().handler(async () => {
return getSharedData()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getRequest } from '@tanstack/react-start/server'

// Utility that wraps a denied server import. It is consumed by BOTH a
// safe consumer (inside compiler boundaries) AND a leaky consumer (outside
// any boundary). The leaky consumer must still trigger a violation even
// if the safe consumer's fetchModule chain silences the initial resolve.
export function getSharedData() {
const req = getRequest()
return { method: req.method }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { getSessionData } from './session-util'

// This middleware uses the session utility inside a compiler boundary.
// The compiler should strip the import of session-util from the client.
const authMiddleware = createMiddleware({ type: 'function' }).server(
({ next }) => {
const data = getSessionData()
return next({ context: { session: data } })
},
)

// Exports a pre-configured server fn with the middleware attached.
// This mirrors the real-world `createAuthServerFn` pattern.
export const createAuthServerFn = createServerFn().middleware([authMiddleware])
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getRequest } from '@tanstack/react-start/server'

// This utility wraps a denied server import but does NOT contain any
// compiler boundaries. All consumers use it ONLY inside compiler
// boundaries (createServerFn().handler, createMiddleware().server, etc.)
// so the compiler should prune this import from the client bundle.
//
// This mirrors the real-world pattern of a session utility that wraps
// `useSession` from `@tanstack/react-start/server`.
export function getSessionData() {
const req = getRequest()
return { method: req.method }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createServerFn } from '@tanstack/react-start'
import { getSessionData } from './session-util'
import { createAuthServerFn } from './auth-wrapper'

// Pattern 1: Direct use of session utility inside a server fn handler.
// This mirrors login.tsx importing useAppSession and using it in
// createServerFn().handler().
export const crossBoundarySafeServerFn = createServerFn().handler(async () => {
return getSessionData()
})

// Pattern 2: Using the pre-configured server fn from auth-wrapper.
// This mirrors user.tsx importing createAuthServerFn().handler().
export const crossBoundarySafeWithAuth = createAuthServerFn().handler(
async () => {
return { ok: true }
},
)
166 changes: 137 additions & 29 deletions e2e/react-start/import-protection/tests/error-mode.setup.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,71 @@
import fs from 'node:fs'
import path from 'node:path'
import { execSync } from 'node:child_process'
import { execSync, spawn } from 'node:child_process'
import { chromium } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from '../package.json' with { type: 'json' }
import type { FullConfig } from '@playwright/test'

/**
* Global setup for error-mode E2E tests.
*
* Runs `BEHAVIOR=error pnpm build` and captures the output + exit code.
* The build is *expected* to fail because `behavior: 'error'` causes the
* import-protection plugin to call `this.error()` on the first violation,
* which aborts the Vite/Rollup build with a non-zero exit code.
* 1. Runs `BEHAVIOR=error pnpm build` — expected to fail because the plugin
* calls `this.error()` on the first violation, aborting the Rollup build.
* Output is written to `error-build-result.json`.
*
* Results are written to `error-build-result.json` for the spec to read.
* 2. Starts a dev server with `BEHAVIOR=error`, navigates all violation
* routes, then captures the server log. In dev mode `this.error()` causes
* a module-level 500 (the server stays up). Output is written to
* `error-dev-result.json`.
*/
export default async function globalSetup(config: FullConfig) {
void config
const cwd = path.resolve(import.meta.dirname, '..')
const outFile = path.resolve(cwd, 'error-build-result.json')

// Clean up from previous runs.
async function waitForHttpOk(url: string, timeoutMs: number): Promise<void> {
const start = Date.now()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for ${url}`)
}
try {
const res = await fetch(url, { signal: AbortSignal.timeout(1000) })
if (res.ok) return
} catch {
// ignore
}
await new Promise((r) => setTimeout(r, 200))
}
}

async function killChild(child: ReturnType<typeof spawn>): Promise<void> {
if (child.exitCode !== null || child.killed) return
await new Promise<void>((resolve) => {
let resolved = false
const done = () => {
if (resolved) return
resolved = true
resolve()
}
child.once('exit', done)
child.once('error', done)
try {
child.kill('SIGTERM')
} catch {
done()
return
}
setTimeout(() => {
try {
child.kill('SIGKILL')
} catch {
// ignore
}
setTimeout(done, 500)
}, 3000)
})
}

function captureBuild(cwd: string): void {
const outFile = path.resolve(cwd, 'error-build-result.json')
for (const f of ['error-build-result.json', 'error-build.log']) {
const p = path.resolve(cwd, f)
if (fs.existsSync(p)) fs.unlinkSync(p)
Expand All @@ -31,38 +78,99 @@ export default async function globalSetup(config: FullConfig) {
try {
const output = execSync('pnpm build', {
cwd,
env: {
...process.env,
BEHAVIOR: 'error',
},
env: { ...process.env, BEHAVIOR: 'error' },
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000,
})
stdout = output
} catch (err: any) {
// execSync throws on non-zero exit code — this is the *expected* path.
exitCode = err.status ?? 1
stdout = err.stdout ?? ''
stderr = err.stderr ?? ''
Comment on lines 87 to 90
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check tsconfig for strict mode
echo "=== Checking tsconfig files for strict mode ==="
find . -name 'tsconfig*.json' -type f | head -20 | while read f; do
  echo "File: $f"
  cat "$f" | jq '.compilerOptions.strict' 2>/dev/null || grep -i '"strict"' "$f" || echo "Not found in $f"
done

echo ""
echo "=== Checking eslintrc for no-explicit-any ==="
find . -name '.eslintrc*' -o -name 'eslint.config.*' | head -10 | while read f; do
  if [ -f "$f" ]; then
    echo "File: $f"
    cat "$f" | grep -i "no-explicit-any" || echo "no-explicit-any rule not found in $f"
  fi
done

Repository: TanStack/router

Length of output: 2594


🏁 Script executed:

#!/bin/bash
# Read the actual file to understand context
echo "=== Reading error-mode.setup.ts around lines 87-90 ==="
if [ -f "e2e/react-start/import-protection/tests/error-mode.setup.ts" ]; then
  wc -l "e2e/react-start/import-protection/tests/error-mode.setup.ts"
  echo ""
  sed -n '70,100p' "e2e/react-start/import-protection/tests/error-mode.setup.ts"
else
  find . -name "error-mode.setup.ts" -type f
fi

Repository: TanStack/router

Length of output: 884


🏁 Script executed:

#!/bin/bash
# Find tsconfig for react-start project
echo "=== Looking for tsconfig in react-start ==="
find e2e/react-start -name 'tsconfig*.json' -type f

echo ""
echo "=== Looking for eslintrc in react-start ==="
find e2e/react-start -name '.eslintrc*' -o -name 'eslint.config.*'

echo ""
echo "=== Check parent/root tsconfig ==="
if [ -f "tsconfig.json" ]; then
  echo "Root tsconfig.json:"
  cat tsconfig.json | jq '.compilerOptions.strict' 2>/dev/null || grep -A5 '"compilerOptions"' tsconfig.json | head -20
fi

Repository: TanStack/router

Length of output: 1400


🏁 Script executed:

#!/bin/bash
# Verify ExecSyncException type - check Node.js types
echo "=== Checking Node version and available types ==="
node --version

echo ""
echo "=== Verify execSync throws ExecSyncException when encoding is specified ==="
# Create a simple test to understand the type
python3 << 'EOF'
import json

# From Node.js documentation, when execSync fails:
# - It throws an Error-like object with properties: status, signal, stdout, stderr, pid
# - With encoding: 'utf-8', stdout and stderr are strings
# - Without encoding, they are Buffers
# Let's verify the suggested fix makes sense

exec_sync_exception = {
    "extends": "Error",
    "properties": {
        "status": "number | null",
        "signal": "string | null", 
        "stdout": "string | Buffer",  # Can be either depending on encoding
        "stderr": "string | Buffer"    # Can be either depending on encoding
    }
}

print("ExecSyncException properties:")
print(json.dumps(exec_sync_exception, indent=2))

print("\nIn this code, encoding is 'utf-8', so stdout/stderr should be strings")
print("However, when catching as unknown/any, we should handle both types for safety")
EOF

Repository: TanStack/router

Length of output: 547


🏁 Script executed:

#!/bin/bash
# Look for the actual type definitions or usage patterns in the repo
echo "=== Checking if ExecSyncException is imported/used elsewhere ==="
rg -n 'ExecSyncException|execSync' --type ts --type tsx | head -20

echo ""
echo "=== Check the import statement for child_process ==="
sed -n '1,30p' e2e/react-start/import-protection/tests/error-mode.setup.ts

Repository: TanStack/router

Length of output: 1453


🏁 Script executed:

#!/bin/bash
# Check if ExecSyncException is available in Node.js types
echo "=== Checking Node.js child_process types ==="
node -e "
const cp = require('child_process');
console.log('child_process methods:', Object.keys(cp).slice(0, 15));
"

echo ""
echo "=== Check `@types/node` for ExecSyncException ==="
find . -path '*/node_modules/@types/node*' -name '*.d.ts' 2>/dev/null | head -5 | while read f; do
  if grep -l 'ExecSyncException' "$f" 2>/dev/null; then
    echo "Found ExecSyncException in: $f"
    grep -A8 'interface ExecSyncException\|class ExecSyncException' "$f"
  fi
done

echo ""
echo "=== Alternative: Check what the proper narrowing should be ==="
echo "Since ExecSyncException may not be directly exported, let's see available options:"
rg -n 'ExecException|ExecFileException|SpawnException' . --type ts 2>/dev/null | head -10

Repository: TanStack/router

Length of output: 451


🏁 Script executed:

#!/bin/bash
# Verify the actual type handling needed
echo "=== Checking how stdout/stderr are actually assigned with encoding ==="
echo "The code has: encoding: 'utf-8'"
echo "This means stdout should be string when successful"
echo "When execSync throws, err.stdout and err.stderr are Buffer | string"
echo ""
echo "The suggested fix attempts to handle both Buffer and string:"
echo "- Type narrowing approach is correct"
echo "- Using 'unknown' in catch is correct for TypeScript strictness"
echo "- Need to verify the exact type import/usage"
echo ""
echo "=== Check if there's a pattern in the codebase for error handling ==="
rg -B3 -A5 'catch.*err.*unknown' . --type ts | head -30

Repository: TanStack/router

Length of output: 2190


🏁 Script executed:

#!/bin/bash
# Final check - verify the best way to reference ExecSyncException
echo "=== How to properly type the error ==="
python3 << 'EOF'
# In TypeScript with `@types/node`, ExecSyncException is in the child_process module
# The import syntax used in the suggestion is valid:
# import('node:child_process').ExecSyncException

# Or alternatively:
# 1. Direct import at top: import type { ExecSyncException } from 'node:child_process'
# 2. Or cast as Error and access properties

# The suggested approach is valid but let's verify it works
print("Suggested approach: import('node:child_process').ExecSyncException")
print("This is the inline import type syntax, which is valid in TypeScript")
print("")
print("The suggested fix is correct:")
print("1. Catch as 'unknown' for strictness")
print("2. Cast/narrow to ExecSyncException")
print("3. Type-check stdout/stderr before using (since they could be Buffer | string)")
EOF

Repository: TanStack/router

Length of output: 415


Replace any in the catch block with proper type narrowing to match strict mode requirements.

With strict: true enabled in tsconfig, the catch parameter should use unknown and be narrowed to the appropriate error type. ExecSyncException has stdout and stderr as Buffer | string, so they need type checking before use.

♻️ Suggested refactor
-  } catch (err: any) {
-    exitCode = err.status ?? 1
-    stdout = err.stdout ?? ''
-    stderr = err.stderr ?? ''
-  }
+  } catch (err: unknown) {
+    const execErr = err as import('node:child_process').ExecSyncException
+    exitCode = execErr.status ?? 1
+    stdout =
+      typeof execErr.stdout === 'string'
+        ? execErr.stdout
+        : execErr.stdout?.toString() ?? ''
+    stderr =
+      typeof execErr.stderr === 'string'
+        ? execErr.stderr
+        : execErr.stderr?.toString() ?? ''
+  }
📝 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
} catch (err: any) {
// execSync throws on non-zero exit code — this is the *expected* path.
exitCode = err.status ?? 1
stdout = err.stdout ?? ''
stderr = err.stderr ?? ''
} catch (err: unknown) {
const execErr = err as import('node:child_process').ExecSyncException
exitCode = execErr.status ?? 1
stdout =
typeof execErr.stdout === 'string'
? execErr.stdout
: execErr.stdout?.toString() ?? ''
stderr =
typeof execErr.stderr === 'string'
? execErr.stderr
: execErr.stderr?.toString() ?? ''
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/react-start/import-protection/tests/error-mode.setup.ts` around lines 87
- 90, The catch currently types the error as any; change the catch parameter to
unknown and narrow it to ExecSyncException (or check for the presence of
stdout/stderr) before assigning exitCode, stdout, and stderr: use "catch (err:
unknown)" then if (isExecSyncException(err)) or check typeof (err === 'object'
&& err !== null && 'status' in err) to extract status, stdout, stderr safely,
converting Buffer values to strings (e.g., Buffer.isBuffer(value) ?
value.toString() : String(value)) and defaulting to 1/'' when properties are
absent; reference the catch variable err and the assignment targets exitCode,
stdout, stderr and the ExecSyncException shape when implementing the narrowing.

}

const combined = `${stdout}\n${stderr}`

// Persist the log for debugging.
fs.writeFileSync(path.resolve(cwd, 'error-build.log'), combined)

fs.writeFileSync(
outFile,
JSON.stringify(
{
exitCode,
stdout,
stderr,
combined,
},
null,
2,
),
JSON.stringify({ exitCode, stdout, stderr, combined }, null, 2),
)
}

const routes = [
'/',
'/leaky-server-import',
'/client-only-violations',
'/client-only-jsx',
'/beforeload-leak',
]

async function captureDev(cwd: string): Promise<void> {
const outFile = path.resolve(cwd, 'error-dev-result.json')
for (const f of ['error-dev-result.json', 'error-dev.log']) {
const p = path.resolve(cwd, f)
if (fs.existsSync(p)) fs.unlinkSync(p)
}

const port = await getTestServerPort(`${packageJson.name}_error_dev`)
const baseURL = `http://localhost:${port}`
const logFile = path.resolve(cwd, 'error-dev.log')

const out = fs.createWriteStream(logFile)
const child = spawn('pnpm', ['exec', 'vite', 'dev', '--port', String(port)], {
cwd,
env: {
...process.env,
BEHAVIOR: 'error',
PORT: String(port),
VITE_SERVER_PORT: String(port),
VITE_NODE_ENV: 'test',
},
stdio: ['ignore', 'pipe', 'pipe'],
})

child.stdout?.on('data', (d: Buffer) => out.write(d))
child.stderr?.on('data', (d: Buffer) => out.write(d))

try {
await waitForHttpOk(baseURL, 30_000)

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 {
// expected — modules fail with 500 in error mode
}
}
await context.close()
} finally {
await browser.close()
}

await new Promise((r) => setTimeout(r, 750))
} finally {
await killChild(child)
await new Promise<void>((resolve) => out.end(resolve))
}

const combined = fs.existsSync(logFile)
? fs.readFileSync(logFile, 'utf-8')
: ''
fs.writeFileSync(outFile, JSON.stringify({ combined }, null, 2))
}

export default async function globalSetup(config: FullConfig) {
void config
const cwd = path.resolve(import.meta.dirname, '..')

captureBuild(cwd)
await captureDev(cwd)
}
Loading
Loading