From 07d478eec4941b922ca6e776a1e71f3c771de5f4 Mon Sep 17 00:00:00 2001
From: Manuel Schiller
Date: Wed, 25 Feb 2026 01:22:47 +0100
Subject: [PATCH 1/6] fix: don't rely on syntheticNamedExports in import
protection
this is not supported by rolldown so we need to rewrite mock imports
---
e2e/react-start/import-protection/.gitignore | 2 +
.../import-protection/error-dev-result.json | 3 -
.../src/import-protection-plugin/INTERNALS.md | 42 +++--
.../src/import-protection-plugin/plugin.ts | 161 +++++++-----------
.../virtualModules.ts | 104 +++++++++--
.../tests/importProtection/transform.test.ts | 1 -
.../importProtection/virtualModules.test.ts | 22 ++-
7 files changed, 203 insertions(+), 132 deletions(-)
delete mode 100644 e2e/react-start/import-protection/error-dev-result.json
diff --git a/e2e/react-start/import-protection/.gitignore b/e2e/react-start/import-protection/.gitignore
index 19519d5615b..568f652b450 100644
--- a/e2e/react-start/import-protection/.gitignore
+++ b/e2e/react-start/import-protection/.gitignore
@@ -28,3 +28,5 @@ port-*.txt
webserver-*.log
error-build-result.json
error-build.log
+error-dev-result.json
+error-dev.log
diff --git a/e2e/react-start/import-protection/error-dev-result.json b/e2e/react-start/import-protection/error-dev-result.json
deleted file mode 100644
index fb32ddb126d..00000000000
--- a/e2e/react-start/import-protection/error-dev-result.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "combined": "\u001b[2m12:12:51 AM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m Re-optimizing dependencies because vite config has changed\n\n \u001b[32m\u001b[1mVITE\u001b[22m v7.3.1\u001b[39m \u001b[2mready in \u001b[0m\u001b[1m360\u001b[22m\u001b[2m\u001b[0m ms\u001b[22m\n\n \u001b[32m➜\u001b[39m \u001b[1mLocal\u001b[22m: \u001b[36mhttp://localhost:\u001b[1m55114\u001b[22m/\u001b[39m\n\u001b[2m \u001b[32m➜\u001b[39m \u001b[1mNetwork\u001b[22m\u001b[2m: use \u001b[22m\u001b[1m--host\u001b[22m\u001b[2m to expose\u001b[22m\n[vite] connected.\n\u001b[2m12:12:51 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31m\u001b[2m(client)\u001b[22m\u001b[39m Pre-transform error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n\u001b[2m12:12:51 AM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32m✨ new dependencies optimized: \u001b[33mtiny-invariant, tiny-warning, seroval-plugins/web, seroval, @tanstack/store, cookie-es\u001b[32m\u001b[39m\n\u001b[2m12:12:51 AM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32m✨ optimized dependencies changed. reloading\u001b[39m\n\u001b[2m12:12:51 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31mInternal server error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\u001b[39m\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12)\n\u001b[2m12:12:52 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31mInternal server error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\u001b[39m\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12)\n\u001b[2m12:12:52 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31m\u001b[2m(client)\u001b[22m\u001b[39m Pre-transform error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\nError: \n[import-protection] Import denied in server environment\n\n Denied by file pattern: **/*.client.*\n Importer: src/routes/client-only-violations.tsx:17:39\n Import: \"../violations/browser-api.client\"\n Resolved: src/violations/browser-api.client.ts\n\n Trace:\n 1. src/router.tsx:2:27 (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:15:58 (import \"./routes/client-only-violations\")\n 3. src/routes/client-only-violations.tsx:17:39 (import \"../violations/browser-api.client\")\n\n Code:\n 15 | \n 16 |
Client-Only Violations
\n > 17 |
{getBrowserTitle()}
\n | ^\n 18 |
{getClientOnlyDataViaEdge()}
\n 19 |
\n\n src/routes/client-only-violations.tsx:17:39\n\n Suggestions:\n - Wrap the JSX in }>... so it only renders in the browser after hydration\n - Use createClientOnlyFn(() => ...) to mark it as client-only (returns undefined on the server)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the client-only import out of this file into a separate .client.ts module that is not imported by any server code\n\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n\u001b[90m at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\u001b[39m\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12) {\n plugin: \u001b[32m'vite:import-analysis'\u001b[39m,\n pos: \u001b[33m81\u001b[39m,\n id: \u001b[32m'/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-violations.tsx?tsr-split=component'\u001b[39m,\n pluginCode: \u001b[32m'import { jsxDEV } from \"react/jsx-dev-runtime\";\\n'\u001b[39m +\n \u001b[32m'import { getBrowserTitle } from \"../violations/browser-api.client\";\\n'\u001b[39m +\n \u001b[32m'import { getClientOnlyDataViaEdge } from \"../violations/marked-client-only-edge\";\\n'\u001b[39m +\n \u001b[32m'function ClientOnlyViolations() {\\n'\u001b[39m +\n \u001b[32m' return /* @__PURE__ */ jsxDEV(\"div\", { children: [\\n'\u001b[39m +\n \u001b[32m' /* @__PURE__ */ jsxDEV(\"h1\", { \"data-testid\": \"client-only-heading\", children: \"Client-Only Violations\" }, void 0, false, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-violations.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 9,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 7\\n'\u001b[39m +\n \u001b[32m' }, this),\\n'\u001b[39m +\n \u001b[32m' /* @__PURE__ */ jsxDEV(\"p\", { \"data-testid\": \"browser-title\", children: getBrowserTitle() }, void 0, false, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-violations.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 10,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 7\\n'\u001b[39m +\n \u001b[32m' }, this),\\n'\u001b[39m +\n \u001b[32m' /* @__PURE__ */ jsxDEV(\"p\", { \"data-testid\": \"client-only-data\", children: getClientOnlyDataViaEdge() }, void 0, false, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-violations.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 11,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 7\\n'\u001b[39m +\n \u001b[32m' }, this)\\n'\u001b[39m +\n \u001b[32m' ] }, void 0, true, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-violations.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 8,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 10\\n'\u001b[39m +\n \u001b[32m' }, this);\\n'\u001b[39m +\n \u001b[32m'}\\n'\u001b[39m +\n \u001b[32m'export { ClientOnlyViolations as component };\\n'\u001b[39m,\n loc: {\n file: \u001b[32m'/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-violations.tsx?tsr-split=component'\u001b[39m,\n line: \u001b[33m4\u001b[39m,\n column: \u001b[33m32\u001b[39m\n },\n frame: \u001b[32m'1 | import { jsxDEV } from \"react/jsx-dev-runtime\";\\n'\u001b[39m +\n \u001b[32m'2 | import { getBrowserTitle } from \"../violations/browser-api.client\";\\n'\u001b[39m +\n \u001b[32m' | ^\\n'\u001b[39m +\n \u001b[32m'3 | import { getClientOnlyDataViaEdge } from \"../violations/marked-client-only-edge\";\\n'\u001b[39m +\n \u001b[32m'4 | function ClientOnlyViolations() {'\u001b[39m,\n runnerError: Error: RunnerError\n at reviveInvokeError (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:476:64)\n at Object.invoke (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:549:11)\n \u001b[90m at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\u001b[39m\n at async ModuleRunner.getModuleInformation (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:1086:7)\n at async request (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:1103:83)\n at async Promise.all (index 0)\n}\n\u001b[2m12:12:53 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31mInternal server error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\u001b[39m\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12)\nError: \n[import-protection] Import denied in server environment\n\n Denied by file pattern: **/*.client.*\n Importer: src/routes/client-only-jsx.tsx:19:12\n Import: \"../violations/window-size.client\"\n Resolved: src/violations/window-size.client.tsx\n\n Trace:\n 1. src/router.tsx:2:27 (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:16:51 (import \"./routes/client-only-jsx\")\n 3. src/routes/client-only-jsx.tsx:19:12 (import \"../violations/window-size.client\")\n\n Code:\n 17 | Window:{' '}\n 18 | \n > 19 | \n | ^\n 20 | \n 21 |
\n\n src/routes/client-only-jsx.tsx:19:12\n\n Suggestions:\n - Wrap the JSX in }>... so it only renders in the browser after hydration\n - Use createClientOnlyFn(() => ...) to mark it as client-only (returns undefined on the server)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the client-only import out of this file into a separate .client.ts module that is not imported by any server code\n\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n\u001b[90m at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\u001b[39m\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12) {\n plugin: \u001b[32m'vite:import-analysis'\u001b[39m,\n pos: \u001b[33m76\u001b[39m,\n id: \u001b[32m'/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component'\u001b[39m,\n pluginCode: \u001b[32m'import { jsxDEV } from \"react/jsx-dev-runtime\";\\n'\u001b[39m +\n \u001b[32m'import { WindowSize } from \"../violations/window-size.client\";\\n'\u001b[39m +\n \u001b[32m'function ClientOnlyJsx() {\\n'\u001b[39m +\n \u001b[32m' return /* @__PURE__ */ jsxDEV(\"div\", { children: [\\n'\u001b[39m +\n \u001b[32m' /* @__PURE__ */ jsxDEV(\"h1\", { \"data-testid\": \"client-only-jsx-heading\", children: \"Client-Only JSX\" }, void 0, false, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 8,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 7\\n'\u001b[39m +\n \u001b[32m' }, this),\\n'\u001b[39m +\n \u001b[32m' /* @__PURE__ */ jsxDEV(\"p\", { \"data-testid\": \"window-size\", children: [\\n'\u001b[39m +\n \u001b[32m' \"Window:\",\\n'\u001b[39m +\n \u001b[32m' \" \",\\n'\u001b[39m +\n \u001b[32m' /* @__PURE__ */ jsxDEV(\"strong\", { children: /* @__PURE__ */ jsxDEV(WindowSize, {}, void 0, false, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 12,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 11\\n'\u001b[39m +\n \u001b[32m' }, this) }, void 0, false, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 11,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 9\\n'\u001b[39m +\n \u001b[32m' }, this)\\n'\u001b[39m +\n \u001b[32m' ] }, void 0, true, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 9,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 7\\n'\u001b[39m +\n \u001b[32m' }, this)\\n'\u001b[39m +\n \u001b[32m' ] }, void 0, true, {\\n'\u001b[39m +\n \u001b[32m' fileName: \"/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component\",\\n'\u001b[39m +\n \u001b[32m' lineNumber: 7,\\n'\u001b[39m +\n \u001b[32m' columnNumber: 10\\n'\u001b[39m +\n \u001b[32m' }, this);\\n'\u001b[39m +\n \u001b[32m'}\\n'\u001b[39m +\n \u001b[32m'export { ClientOnlyJsx as component };\\n'\u001b[39m,\n loc: {\n file: \u001b[32m'/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/routes/client-only-jsx.tsx?tsr-split=component'\u001b[39m,\n line: \u001b[33m6\u001b[39m,\n column: \u001b[33m27\u001b[39m\n },\n frame: \u001b[32m'1 | import { jsxDEV } from \"react/jsx-dev-runtime\";\\n'\u001b[39m +\n \u001b[32m'2 | import { WindowSize } from \"../violations/window-size.client\";\\n'\u001b[39m +\n \u001b[32m' | ^\\n'\u001b[39m +\n \u001b[32m'3 | function ClientOnlyJsx() {\\n'\u001b[39m +\n \u001b[32m'4 | return /* @__PURE__ */ jsxDEV(\"div\", { children: ['\u001b[39m,\n runnerError: Error: RunnerError\n at reviveInvokeError (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:476:64)\n at Object.invoke (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:549:11)\n \u001b[90m at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\u001b[39m\n at async ModuleRunner.getModuleInformation (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:1086:7)\n at async request (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/\u001b[4m.pnpm\u001b[24m/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/\u001b[4mvite\u001b[24m/dist/node/module-runner.js:1103:83)\n at async Promise.all (index 0)\n}\n\u001b[2m12:12:53 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31mInternal server error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\u001b[39m\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12)\n\u001b[2m12:12:54 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31mInternal server error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\u001b[39m\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12)\n[db] connecting to postgres://admin:s3cret@localhost:5432/myapp\n\u001b[2m12:12:54 AM\u001b[22m \u001b[31m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[31mInternal server error: \n[import-protection] Import denied in client environment\n\n Denied by specifier pattern: @tanstack/react-start/server\n Importer: src/violations/beforeload-server-leak.ts:8:15\n Import: \"@tanstack/react-start/server\"\n\n Trace:\n 1. src/router.tsx (entry) (import \"./routeTree.gen\")\n 2. src/routeTree.gen.ts:11:52 (import \"./routes/beforeload-leak\")\n 3. src/routes/beforeload-leak.tsx:2:39 (import \"../violations/beforeload-server-leak\")\n 4. src/violations/beforeload-server-leak.ts:8:15 (import \"@tanstack/react-start/server\")\n\n Code:\n 6 | // Using this module in `beforeLoad` is therefore a TRUE POSITIVE violation.\n 7 | export function getSessionFromRequest() {\n > 8 | const req = getRequest()\n | ^\n 9 | return { sessionId: req.headers.get('x-session-id') }\n 10 | }\n\n src/violations/beforeload-server-leak.ts:8:15\n\n Suggestions:\n - Use createServerFn().handler(() => ...) to keep the logic on the server and call it from the client via an RPC bridge\n - Use createServerOnlyFn(() => ...) to mark it as server-only (it will throw if accidentally called from the client)\n - Use createIsomorphicFn().client(() => ...).server(() => ...) to provide separate client and server implementations\n - Move the server-only import out of this file into a separate .server.ts module that is not imported by any client code\n\u001b[39m\n Plugin: \u001b[35mvite:import-analysis\u001b[39m\n File: \u001b[36m/Users/caligano/source/import-protection/router-import-protection/e2e/react-start/import-protection/src/violations/beforeload-server-leak.ts\u001b[39m:1:27\n\u001b[33m 1 | import { getRequest } from \"@tanstack/react-start/server\";\n | ^\n 2 | export function getSessionFromRequest() {\n 3 | const req = getRequest();\u001b[39m\n at ResolveIdContext._formatLog (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28999:43)\n at ResolveIdContext.error (file:///Users/caligano/source/import-protection/router-import-protection/node_modules/.pnpm/vite@7.3.1_@types+node@25.0.9_jiti@2.6.1_lightningcss@1.30.2_sass-embedded@1.97.2_sass@_1d37aa1356b156747ad30a49305c26bc/node_modules/vite/dist/node/chunks/config.js:28996:14)\n at handleViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:413:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)\n at async reportOrDeferViolation (file:///Users/caligano/source/import-protection/router-import-protection/packages/start-plugin-core/dist/esm/import-protection-plugin/plugin.js:455:12)\n"
-}
diff --git a/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md b/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md
index 8c1a2b9a15f..acb0410b8fb 100644
--- a/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md
+++ b/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md
@@ -78,9 +78,9 @@ analysing a module's exports. These are tracked via `serverFnLookupModules` and
### Central functions
- **`handleViolation()`**: Formats + reports (or silences) the violation. Returns
- a mock module ID (or `{ id, syntheticNamedExports }` in build) so `resolveId`
- can substitute the offending import. May also return `undefined` (suppressed by
- `onViolation` or silent+error in dev) or throw via `ctx.error()` (dev+error).
+ a mock-edge module ID (string) so `resolveId` can substitute the offending
+ import. May also return `undefined` (suppressed by `onViolation` or
+ silent+error in dev) or throw via `ctx.error()` (dev+error).
- **`reportOrDeferViolation()`**: Dispatch layer. Either defers (stores for later
verification) or reports immediately, depending on `shouldDefer`.
@@ -138,8 +138,11 @@ In dev, each violation gets a **per-importer mock edge module** that:
- Delegates to a **runtime mock module** that contains a recursive Proxy and
optional runtime diagnostics (console warnings when mocked values are used).
-This differs from build mode, where `syntheticNamedExports: true` lets Rollup
-handle named export resolution from the silent mock.
+This differs from build mode, where each violation gets a **per-violation mock
+edge module** wrapping a unique base mock module
+(`\0tanstack-start-import-protection:mock:build:N`). The edge module re-exports
+the named exports the importer expects, just like in dev, ensuring compatibility
+with both Rollup and Rolldown (which doesn't support `syntheticNamedExports`).
## Build Mode Strategy
@@ -148,15 +151,19 @@ handle named export resolution from the silent mock.
Both mock and error build modes follow the same pattern:
1. **`resolveId`**: Call `handleViolation({ silent: true })`. Generate a
- **unique per-violation mock module ID** (`\0tanstack-start-import-protection:mock:build:N`).
- Store the violation + mock ID in `env.deferredBuildViolations`. Return the
- mock ID so Rollup substitutes the offending import.
+ **unique per-violation mock-edge module** that wraps a base mock module
+ (`\0tanstack-start-import-protection:mock:build:N`) and provides explicit
+ named exports matching the importer's import bindings. Store the violation +
+ mock-edge ID in `env.deferredBuildViolations`. Return the mock-edge ID so the
+ bundler substitutes the offending import.
-2. **`load`**: Return a silent Proxy-based mock module (same code as
- `RESOLVED_MOCK_MODULE_ID`) with `syntheticNamedExports: true`.
+2. **`load`**: For the base mock module, return a silent Proxy-based mock. For
+ the mock-edge module, return code that imports from the base mock and
+ re-exports the expected named bindings (e.g. `export const Foo = mock.Foo`).
-3. **Tree-shaking**: Rollup processes the bundle normally. If no binding from
- the mock module is actually used at runtime, the mock module is eliminated.
+3. **Tree-shaking**: The bundler processes the bundle normally. If no binding from
+ the mock-edge module is actually used at runtime, both the edge and base
+ modules are eliminated.
4. **`generateBundle`**: Inspect the output chunks. For each deferred violation,
check whether its unique mock module ID appears in any chunk's `modules`.
@@ -171,15 +178,16 @@ Both mock and error build modes follow the same pattern:
The original `RESOLVED_MOCK_MODULE_ID` is a single shared virtual module used
for all mock-mode violations. If multiple violations are deferred, we need to
know _which specific ones_ survived tree-shaking. A shared ID would tell us
-"something survived" but not which violation it corresponds to. The unique IDs
-(`...mock:build:0`, `...mock:build:1`, etc.) provide this granularity.
+"something survived" but not which violation it corresponds to. Each violation
+gets a unique mock-edge module (wrapping a unique base mock
+`...mock:build:0`, `...mock:build:1`, etc.) to provide this granularity.
### Why mocking doesn't affect tree-shaking
From the consumer's perspective, the import bindings are identical whether they
-point to the real module or the mock. Rollup tree-shakes based on binding usage,
-not module content. If a binding from the barrel's re-export of `.server` is
-unused after the Start compiler strips server fn handlers, tree-shaking
+point to the real module or the mock. The bundler tree-shakes based on binding
+usage, not module content. If a binding from the barrel's re-export of `.server`
+is unused after the Start compiler strips server fn handlers, tree-shaking
eliminates it regardless of whether it points to real DB code or a Proxy mock.
### Per-environment operation
diff --git a/packages/start-plugin-core/src/import-protection-plugin/plugin.ts b/packages/start-plugin-core/src/import-protection-plugin/plugin.ts
index 6bcae44ae37..9adec4b7430 100644
--- a/packages/start-plugin-core/src/import-protection-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/import-protection-plugin/plugin.ts
@@ -21,21 +21,13 @@ import {
} from './utils'
import { collectMockExportNamesBySource } from './rewriteDeniedImports'
import {
- MARKER_PREFIX,
- MOCK_EDGE_PREFIX,
- MOCK_MODULE_ID,
- MOCK_RUNTIME_PREFIX,
- RESOLVED_MARKER_PREFIX,
- RESOLVED_MOCK_BUILD_PREFIX,
- RESOLVED_MOCK_EDGE_PREFIX,
- RESOLVED_MOCK_MODULE_ID,
- RESOLVED_MOCK_RUNTIME_PREFIX,
- loadMarkerModule,
- loadMockEdgeModule,
- loadMockRuntimeModule,
- loadSilentMockModule,
+ MOCK_BUILD_PREFIX,
+ getResolvedVirtualModuleMatchers,
+ loadResolvedVirtualModule,
makeMockEdgeModuleId,
mockRuntimeModuleIdFromViolation,
+ resolveInternalVirtualModuleId,
+ resolvedMarkerVirtualModuleId,
} from './virtualModules'
import {
ImportLocCache,
@@ -62,9 +54,6 @@ import type {
import type { CompileStartFrameworkOptions, GetConfigFn } from '../types'
const SERVER_FN_LOOKUP_QUERY = '?' + SERVER_FN_LOOKUP
-const RESOLVED_MARKER_SERVER_ONLY = resolveViteId(`${MARKER_PREFIX}server-only`)
-const RESOLVED_MARKER_CLIENT_ONLY = resolveViteId(`${MARKER_PREFIX}client-only`)
-
const IMPORT_PROTECTION_DEBUG =
process.env.TSR_IMPORT_PROTECTION_DEBUG === '1' ||
process.env.TSR_IMPORT_PROTECTION_DEBUG === 'true'
@@ -82,7 +71,6 @@ function matchesDebugFilter(...values: Array): boolean {
return values.some((v) => v.includes(IMPORT_PROTECTION_DEBUG_FILTER))
}
-export { RESOLVED_MOCK_MODULE_ID } from './virtualModules'
export { rewriteDeniedImports } from './rewriteDeniedImports'
export { dedupePatterns, extractImportSources } from './utils'
export type { Pattern } from './utils'
@@ -216,6 +204,16 @@ interface DeferredBuildViolation {
info: ViolationInfo
/** Unique mock module ID assigned to this violation. */
mockModuleId: string
+
+ /**
+ * Module ID to check for tree-shaking survival in `generateBundle`.
+ *
+ * For most violations we check the unique mock module ID.
+ * For `marker` violations the import is a bare side-effect import that often
+ * gets optimized away regardless of whether the importer survives, so we
+ * instead check whether the importer module itself survived.
+ */
+ checkModuleId?: string
}
/**
@@ -818,27 +816,18 @@ export function importProtectionPlugin(
env: EnvState,
importerFile: string,
info: ViolationInfo,
- mockReturnValue:
- | { id: string; syntheticNamedExports: boolean }
- | string
- | undefined,
+ mockReturnValue: string | undefined,
): void {
getOrCreate(env.pendingViolations, importerFile, () => []).push({
info,
- mockReturnValue:
- typeof mockReturnValue === 'string'
- ? mockReturnValue
- : (mockReturnValue?.id ?? ''),
+ mockReturnValue: mockReturnValue ?? '',
})
}
/** Counter for generating unique per-violation mock module IDs in build mode. */
let buildViolationCounter = 0
- type HandleViolationResult =
- | { id: string; syntheticNamedExports: boolean }
- | string
- | undefined
+ type HandleViolationResult = string | undefined
async function handleViolation(
ctx: ViolationReporter,
@@ -909,8 +898,22 @@ export function importProtectionPlugin(
// Build: unique per-violation mock IDs so generateBundle can check
// which violations survived tree-shaking (both mock and error mode).
- const mockId = `${RESOLVED_MOCK_BUILD_PREFIX}${buildViolationCounter++}`
- return { id: mockId, syntheticNamedExports: true }
+ // We wrap the base mock in a mock-edge module that provides explicit
+ // named exports — Rolldown doesn't support Rollup's
+ // syntheticNamedExports, so without this named imports like
+ // `import { Foo } from "denied-pkg"` would fail with MISSING_EXPORT.
+ //
+ // Use the unresolved MOCK_BUILD_PREFIX (without \0) as the runtimeId
+ // so the mock-edge module's `import mock from "..."` goes through
+ // resolveId, which adds the \0 prefix. Using the resolved ID directly
+ // would cause Rollup/Vite to skip resolveId and fail to find the module.
+ const baseMockId = `${MOCK_BUILD_PREFIX}${buildViolationCounter++}`
+ const importerFile = normalizeFilePath(info.importer)
+ const exports =
+ env.mockExportsByImporter.get(importerFile)?.get(info.specifier) ?? []
+ return resolveViteId(
+ makeMockEdgeModuleId(exports, info.specifier, baseMockId),
+ )
}
/**
@@ -940,9 +943,14 @@ export function importProtectionPlugin(
if (config.command === 'build') {
// Build mode: store for generateBundle tree-shaking check.
- // The unique mock ID is inside `result`.
- const mockId = typeof result === 'string' ? result : (result?.id ?? '')
- env.deferredBuildViolations.push({ info, mockModuleId: mockId })
+ // The mock-edge module ID is returned as a plain string.
+ const mockId = result ?? ''
+ env.deferredBuildViolations.push({
+ info,
+ mockModuleId: mockId,
+ // For marker violations, check importer survival instead of mock.
+ checkModuleId: info.type === 'marker' ? info.importer : undefined,
+ })
} else {
// Dev mock: store for graph-reachability check.
deferViolation(env, importerFile, info, result)
@@ -1182,18 +1190,8 @@ export function importProtectionPlugin(
}
// Internal virtual modules
- if (source === MOCK_MODULE_ID) {
- return RESOLVED_MOCK_MODULE_ID
- }
- if (source.startsWith(MOCK_EDGE_PREFIX)) {
- return resolveViteId(source)
- }
- if (source.startsWith(MOCK_RUNTIME_PREFIX)) {
- return resolveViteId(source)
- }
- if (source.startsWith(MARKER_PREFIX)) {
- return resolveViteId(source)
- }
+ const internalVirtualId = resolveInternalVirtualModuleId(source)
+ if (internalVirtualId) return internalVirtualId
if (!importer) {
env.graph.addEntry(source)
@@ -1287,8 +1285,8 @@ export function importProtectionPlugin(
}
return markerKind === 'server'
- ? RESOLVED_MARKER_SERVER_ONLY
- : RESOLVED_MARKER_CLIENT_ONLY
+ ? resolvedMarkerVirtualModuleId('server')
+ : resolvedMarkerVirtualModuleId('client')
}
// Check if the importer is within our scope
@@ -1427,15 +1425,7 @@ export function importProtectionPlugin(
load: {
filter: {
id: new RegExp(
- [
- RESOLVED_MOCK_MODULE_ID,
- RESOLVED_MOCK_BUILD_PREFIX,
- RESOLVED_MARKER_PREFIX,
- RESOLVED_MOCK_EDGE_PREFIX,
- RESOLVED_MOCK_RUNTIME_PREFIX,
- ]
- .map(escapeRegExp)
- .join('|'),
+ getResolvedVirtualModuleMatchers().map(escapeRegExp).join('|'),
),
},
handler(id) {
@@ -1448,32 +1438,7 @@ export function importProtectionPlugin(
}
}
- if (id === RESOLVED_MOCK_MODULE_ID) {
- return loadSilentMockModule()
- }
-
- // Per-violation build mock modules — same silent mock code
- if (id.startsWith(RESOLVED_MOCK_BUILD_PREFIX)) {
- return loadSilentMockModule()
- }
-
- if (id.startsWith(RESOLVED_MOCK_EDGE_PREFIX)) {
- return loadMockEdgeModule(
- id.slice(RESOLVED_MOCK_EDGE_PREFIX.length),
- )
- }
-
- if (id.startsWith(RESOLVED_MOCK_RUNTIME_PREFIX)) {
- return loadMockRuntimeModule(
- id.slice(RESOLVED_MOCK_RUNTIME_PREFIX.length),
- )
- }
-
- if (id.startsWith(RESOLVED_MARKER_PREFIX)) {
- return loadMarkerModule()
- }
-
- return undefined
+ return loadResolvedVirtualModule(id)
},
},
@@ -1492,11 +1457,16 @@ export function importProtectionPlugin(
}
}
- // Check each deferred violation: if its unique mock module survived
+ // Check each deferred violation: if its check module survived
// in the bundle, the import was NOT tree-shaken — real leak.
const realViolations: Array = []
- for (const { info, mockModuleId } of env.deferredBuildViolations) {
- if (!survivingModules.has(mockModuleId)) continue
+ for (const {
+ info,
+ mockModuleId,
+ checkModuleId,
+ } of env.deferredBuildViolations) {
+ const checkId = checkModuleId ?? mockModuleId
+ if (!survivingModules.has(checkId)) continue
if (config.onViolation) {
const result = await config.onViolation(info)
@@ -1648,18 +1618,17 @@ export function importProtectionPlugin(
name: 'tanstack-start-core:import-protection-mock-rewrite',
enforce: 'pre',
- // Only needed during dev. In build, we rely on Rollup's syntheticNamedExports.
- apply: 'serve',
-
applyToEnvironment(env) {
if (!config.enabled) return false
- // Only needed in mock mode — when not mocking, there is nothing to
- // record. applyToEnvironment runs after configResolved, so
- // config.effectiveBehavior is already set.
- if (config.effectiveBehavior !== 'mock') return false
- // We record expected named exports per importer in all Start Vite
- // environments during dev so mock-edge modules can provide explicit
- // ESM named exports.
+ // We record expected named exports per importer so mock-edge modules
+ // can provide explicit ESM named exports. This is needed in both dev
+ // and build: native ESM (dev) requires real named exports, and
+ // Rolldown (used in Vite 6+) doesn't support Rollup's
+ // syntheticNamedExports flag which was previously relied upon in build.
+ //
+ // In build+error mode we still emit mock modules for deferred
+ // violations (checked at generateBundle time), so we always need the
+ // export name data when import protection is active.
return environmentNames.has(env.name)
},
diff --git a/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts b/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts
index b8a82ecd86f..c64134aa0b7 100644
--- a/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts
+++ b/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts
@@ -6,7 +6,7 @@ import { relativizePath } from './utils'
import type { ViolationInfo } from './trace'
export const MOCK_MODULE_ID = 'tanstack-start-import-protection:mock'
-export const RESOLVED_MOCK_MODULE_ID = resolveViteId(MOCK_MODULE_ID)
+const RESOLVED_MOCK_MODULE_ID = resolveViteId(MOCK_MODULE_ID)
/**
* Per-violation mock prefix used in build+error mode.
@@ -14,17 +14,69 @@ export const RESOLVED_MOCK_MODULE_ID = resolveViteId(MOCK_MODULE_ID)
* survived tree-shaking in `generateBundle`.
*/
export const MOCK_BUILD_PREFIX = 'tanstack-start-import-protection:mock:build:'
-export const RESOLVED_MOCK_BUILD_PREFIX = resolveViteId(MOCK_BUILD_PREFIX)
+const RESOLVED_MOCK_BUILD_PREFIX = resolveViteId(MOCK_BUILD_PREFIX)
export const MOCK_EDGE_PREFIX = 'tanstack-start-import-protection:mock-edge:'
-export const RESOLVED_MOCK_EDGE_PREFIX = resolveViteId(MOCK_EDGE_PREFIX)
+const RESOLVED_MOCK_EDGE_PREFIX = resolveViteId(MOCK_EDGE_PREFIX)
export const MOCK_RUNTIME_PREFIX =
'tanstack-start-import-protection:mock-runtime:'
-export const RESOLVED_MOCK_RUNTIME_PREFIX = resolveViteId(MOCK_RUNTIME_PREFIX)
+const RESOLVED_MOCK_RUNTIME_PREFIX = resolveViteId(MOCK_RUNTIME_PREFIX)
export const MARKER_PREFIX = 'tanstack-start-import-protection:marker:'
-export const RESOLVED_MARKER_PREFIX = resolveViteId(MARKER_PREFIX)
+const RESOLVED_MARKER_PREFIX = resolveViteId(MARKER_PREFIX)
+
+const RESOLVED_MARKER_SERVER_ONLY = resolveViteId(`${MARKER_PREFIX}server-only`)
+const RESOLVED_MARKER_CLIENT_ONLY = resolveViteId(`${MARKER_PREFIX}client-only`)
+
+export function resolvedMarkerVirtualModuleId(
+ kind: 'server' | 'client',
+): string {
+ return kind === 'server'
+ ? RESOLVED_MARKER_SERVER_ONLY
+ : RESOLVED_MARKER_CLIENT_ONLY
+}
+
+/**
+ * Convenience list for plugin `load` filters/handlers.
+ *
+ * Vite/Rollup call `load(id)` with the *resolved* virtual id (prefixed by `\0`).
+ * `resolveId(source)` sees the *unresolved* id/prefix (without `\0`).
+ */
+/**
+ * Convenience list for plugin `load` filters/handlers.
+ *
+ * Vite/Rollup call `load(id)` with the *resolved* virtual id (prefixed by `\0`).
+ * `resolveId(source)` sees the *unresolved* id/prefix (without `\0`).
+ */
+export function getResolvedVirtualModuleMatchers(): ReadonlyArray {
+ return RESOLVED_VIRTUAL_MODULE_MATCHERS
+}
+
+const RESOLVED_VIRTUAL_MODULE_MATCHERS = [
+ RESOLVED_MOCK_MODULE_ID,
+ RESOLVED_MOCK_BUILD_PREFIX,
+ RESOLVED_MOCK_EDGE_PREFIX,
+ RESOLVED_MOCK_RUNTIME_PREFIX,
+ RESOLVED_MARKER_PREFIX,
+] as const
+
+/**
+ * Resolve import-protection's internal virtual module IDs.
+ *
+ * `resolveId(source)` sees *unresolved* ids/prefixes (no `\0`).
+ * Returning a resolved id (with `\0`) ensures Vite/Rollup route it to `load`.
+ */
+export function resolveInternalVirtualModuleId(
+ source: string,
+): string | undefined {
+ if (source === MOCK_MODULE_ID) return RESOLVED_MOCK_MODULE_ID
+ if (source.startsWith(MOCK_EDGE_PREFIX)) return resolveViteId(source)
+ if (source.startsWith(MOCK_RUNTIME_PREFIX)) return resolveViteId(source)
+ if (source.startsWith(MOCK_BUILD_PREFIX)) return resolveViteId(source)
+ if (source.startsWith(MARKER_PREFIX)) return resolveViteId(source)
+ return undefined
+}
function toBase64Url(input: string): string {
return Buffer.from(input, 'utf8').toString('base64url')
@@ -87,7 +139,8 @@ export function makeMockEdgeModuleId(
* (property access for primitive coercion, calls, construction, sets).
*
* When `diagnostics` is omitted, the mock is completely silent — suitable
- * for the shared `MOCK_MODULE_ID` that uses `syntheticNamedExports`.
+ * for base mock modules (e.g. `MOCK_MODULE_ID` or per-violation build mocks)
+ * that are consumed by mock-edge modules providing explicit named exports.
*/
function generateMockCode(diagnostics?: {
meta: {
@@ -170,7 +223,8 @@ function __report(action, accessPath) {
: ''
return `
-${preamble}function ${fnName}(name) {
+${preamble}/* @__NO_SIDE_EFFECTS__ */
+function ${fnName}(name) {
const fn = function () {};
fn.prototype.name = name;
const children = Object.create(null);
@@ -197,16 +251,13 @@ ${preamble}function ${fnName}(name) {
});
return proxy;
}
-const mock = ${fnName}('mock');
+const mock = /* @__PURE__ */ ${fnName}('mock');
export default mock;
`
}
-export function loadSilentMockModule(): {
- syntheticNamedExports: boolean
- code: string
-} {
- return { syntheticNamedExports: true, code: generateMockCode() }
+export function loadSilentMockModule(): { code: string } {
+ return { code: generateMockCode() }
}
export function loadMockEdgeModule(encodedPayload: string): { code: string } {
@@ -292,3 +343,30 @@ const MARKER_MODULE_RESULT = { code: 'export {}' } as const
export function loadMarkerModule(): { code: string } {
return MARKER_MODULE_RESULT
}
+
+export function loadResolvedVirtualModule(
+ id: string,
+): { code: string } | undefined {
+ if (id === RESOLVED_MOCK_MODULE_ID) {
+ return loadSilentMockModule()
+ }
+
+ // Per-violation build mock modules — same silent mock code
+ if (id.startsWith(RESOLVED_MOCK_BUILD_PREFIX)) {
+ return loadSilentMockModule()
+ }
+
+ if (id.startsWith(RESOLVED_MOCK_EDGE_PREFIX)) {
+ return loadMockEdgeModule(id.slice(RESOLVED_MOCK_EDGE_PREFIX.length))
+ }
+
+ if (id.startsWith(RESOLVED_MOCK_RUNTIME_PREFIX)) {
+ return loadMockRuntimeModule(id.slice(RESOLVED_MOCK_RUNTIME_PREFIX.length))
+ }
+
+ if (id.startsWith(RESOLVED_MARKER_PREFIX)) {
+ return loadMarkerModule()
+ }
+
+ return undefined
+}
diff --git a/packages/start-plugin-core/tests/importProtection/transform.test.ts b/packages/start-plugin-core/tests/importProtection/transform.test.ts
index 46cb6dd79f4..399c7e4ed4d 100644
--- a/packages/start-plugin-core/tests/importProtection/transform.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/transform.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, test } from 'vitest'
import { rewriteDeniedImports } from '../../src/import-protection-plugin/rewriteDeniedImports'
-import { RESOLVED_MOCK_MODULE_ID } from '../../src/import-protection-plugin/virtualModules'
const MOCK_SUBSTR = 'tanstack-start-import-protection:mock'
diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
index 8327c173d9e..046f7f1e49a 100644
--- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
@@ -13,12 +13,30 @@ import {
import type { ViolationInfo } from '../../src/import-protection-plugin/trace'
describe('loadSilentMockModule', () => {
- test('returns code with syntheticNamedExports', () => {
+ test('returns mock code', () => {
const result = loadSilentMockModule()
- expect(result.syntheticNamedExports).toBe(true)
expect(result.code).toContain('export default mock')
expect(result.code).toContain('createMock')
expect(result.code).toContain('Proxy')
+ expect(result.code).toContain('@__NO_SIDE_EFFECTS__')
+ expect(result.code).toContain('@__PURE__')
+ })
+})
+
+describe('loadMockEdgeModule', () => {
+ test('does not add PURE annotations to property reads', () => {
+ const encodedPayload = Buffer.from(
+ JSON.stringify({ exports: ['foo', 'bar'], runtimeId: 'x' }),
+ )
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '')
+
+ const result = loadMockEdgeModule(encodedPayload)
+ expect(result.code).toContain('export const foo = mock.foo')
+ expect(result.code).toContain('export const bar = mock.bar')
+ expect(result.code).not.toContain('@__PURE__ */ mock.')
})
})
From 356f9314a42dbd8943050557f533ae677d5834a5 Mon Sep 17 00:00:00 2001
From: Manuel Schiller
Date: Wed, 25 Feb 2026 00:49:07 +0100
Subject: [PATCH 2/6] docs
---
.../react/guide/import-protection.md | 40 +++++++++----------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/docs/start/framework/react/guide/import-protection.md b/docs/start/framework/react/guide/import-protection.md
index f70bea07ed2..88426bb2529 100644
--- a/docs/start/framework/react/guide/import-protection.md
+++ b/docs/start/framework/react/guide/import-protection.md
@@ -38,7 +38,7 @@ Import protection is enabled out of the box with these defaults:
- Files matching `**/*.client.*`
- Excluded from file checks: `**/node_modules/**`
-By default, files inside `node_modules` are excluded from file-pattern checks via the `excludeFiles` option. This prevents false positives from third-party packages whose resolved filenames contain `.client.` or `.server.`. If you need to check third-party files, set `excludeFiles: []` on the relevant environment — see [Configuring Deny Rules](#configuring-deny-rules).
+By default, files inside `node_modules` are excluded from resolved-target deny checks via the `excludeFiles` option. This prevents false positives from third-party packages whose resolved filenames contain `.client.` or `.server.`. If you need to check third-party files, set `excludeFiles: []` on the relevant environment — see [Configuring Deny Rules](#configuring-deny-rules).
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.
@@ -136,7 +136,7 @@ export default defineConfig({
### Checking third-party packages
-By default, resolved files inside `node_modules` are excluded from file-pattern checks. This avoids false positives from packages that happen to use `.client.` or `.server.` in their distribution filenames. If you want to re-enable checking for a specific environment, set `excludeFiles` to an empty array:
+By default, resolved files inside `node_modules` are excluded from resolved-target deny checks (file-pattern and marker checks). This avoids false positives from packages that happen to use `.client.` or `.server.` in their distribution filenames. If you want to re-enable checking for a specific environment, set `excludeFiles` to an empty array:
```ts
importProtection: {
@@ -435,21 +435,21 @@ interface ImportProtectionOptions {
}
```
-| Option | Type | Default | Description |
-| --------------------- | -------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
-| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin |
-| `behavior` | `string \| object` | `{ dev: 'mock', build: 'error' }` | What to do on violation |
-| `log` | `'once' \| 'always'` | `'once'` | Whether to deduplicate repeated violations |
-| `include` | `Pattern[]` | Start's `srcDirectory` | Only check importers matching these patterns |
-| `exclude` | `Pattern[]` | `[]` | Skip importers matching these patterns |
-| `ignoreImporters` | `Pattern[]` | `[]` | Ignore violations from these importers |
-| `maxTraceDepth` | `number` | `20` | Maximum depth for import traces |
-| `client` | `object` | See defaults above | Additional deny rules for the client environment |
-| `client.specifiers` | `Pattern[]` | Framework server specifiers | Specifier patterns denied in the client environment (additive with defaults) |
-| `client.files` | `Pattern[]` | `['**/*.server.*']` | File patterns denied in the client environment (replaces defaults) |
-| `client.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip file-pattern checks (replaces defaults) |
-| `server` | `object` | See defaults above | Additional deny rules for the server environment |
-| `server.specifiers` | `Pattern[]` | `[]` | Specifier patterns denied in the server environment (replaces defaults) |
-| `server.files` | `Pattern[]` | `['**/*.client.*']` | File patterns denied in the server environment (replaces defaults) |
-| `server.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip file-pattern checks (replaces defaults) |
-| `onViolation` | `function` | `undefined` | Callback invoked on every violation |
+| Option | Type | Default | Description |
+| --------------------- | -------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- |
+| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin |
+| `behavior` | `string \| object` | `{ dev: 'mock', build: 'error' }` | What to do on violation |
+| `log` | `'once' \| 'always'` | `'once'` | Whether to deduplicate repeated violations |
+| `include` | `Pattern[]` | Start's `srcDirectory` | Only check importers matching these patterns |
+| `exclude` | `Pattern[]` | `[]` | Skip importers matching these patterns |
+| `ignoreImporters` | `Pattern[]` | `[]` | Ignore violations from these importers |
+| `maxTraceDepth` | `number` | `20` | Maximum depth for import traces |
+| `client` | `object` | See defaults above | Additional deny rules for the client environment |
+| `client.specifiers` | `Pattern[]` | Framework server specifiers | Specifier patterns denied in the client environment (additive with defaults) |
+| `client.files` | `Pattern[]` | `['**/*.server.*']` | File patterns denied in the client environment (replaces defaults) |
+| `client.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
+| `server` | `object` | See defaults above | Additional deny rules for the server environment |
+| `server.specifiers` | `Pattern[]` | `[]` | Specifier patterns denied in the server environment (replaces defaults) |
+| `server.files` | `Pattern[]` | `['**/*.client.*']` | File patterns denied in the server environment (replaces defaults) |
+| `server.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
+| `onViolation` | `function` | `undefined` | Callback invoked on every violation |
From e2fbc52e524ee5a4dffcdad9178bb83a5a9ffba2 Mon Sep 17 00:00:00 2001
From: Manuel Schiller
Date: Wed, 25 Feb 2026 01:24:39 +0100
Subject: [PATCH 3/6] remove test
---
.../importProtection/virtualModules.test.ts | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
index 046f7f1e49a..c88886de30c 100644
--- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
+++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
@@ -23,23 +23,6 @@ describe('loadSilentMockModule', () => {
})
})
-describe('loadMockEdgeModule', () => {
- test('does not add PURE annotations to property reads', () => {
- const encodedPayload = Buffer.from(
- JSON.stringify({ exports: ['foo', 'bar'], runtimeId: 'x' }),
- )
- .toString('base64')
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/g, '')
-
- const result = loadMockEdgeModule(encodedPayload)
- expect(result.code).toContain('export const foo = mock.foo')
- expect(result.code).toContain('export const bar = mock.bar')
- expect(result.code).not.toContain('@__PURE__ */ mock.')
- })
-})
-
describe('loadMarkerModule', () => {
test('returns empty module', () => {
const result = loadMarkerModule()
From afc005b0348915c69d01dde6a4520f9173a5a5a0 Mon Sep 17 00:00:00 2001
From: Manuel Schiller
Date: Wed, 25 Feb 2026 01:39:36 +0100
Subject: [PATCH 4/6] cleanup comment
---
.../src/import-protection-plugin/virtualModules.ts | 6 ------
1 file changed, 6 deletions(-)
diff --git a/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts b/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts
index c64134aa0b7..c2e163edafe 100644
--- a/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts
+++ b/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts
@@ -37,12 +37,6 @@ export function resolvedMarkerVirtualModuleId(
: RESOLVED_MARKER_CLIENT_ONLY
}
-/**
- * Convenience list for plugin `load` filters/handlers.
- *
- * Vite/Rollup call `load(id)` with the *resolved* virtual id (prefixed by `\0`).
- * `resolveId(source)` sees the *unresolved* id/prefix (without `\0`).
- */
/**
* Convenience list for plugin `load` filters/handlers.
*
From f63dcc84e5ca34ce2ec45f9bce75a7f526617800 Mon Sep 17 00:00:00 2001
From: Manuel Schiller
Date: Wed, 25 Feb 2026 01:40:43 +0100
Subject: [PATCH 5/6] docs
---
.../react/guide/import-protection.md | 36 +++++++++----------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/docs/start/framework/react/guide/import-protection.md b/docs/start/framework/react/guide/import-protection.md
index 88426bb2529..685e85bb4d9 100644
--- a/docs/start/framework/react/guide/import-protection.md
+++ b/docs/start/framework/react/guide/import-protection.md
@@ -435,21 +435,21 @@ interface ImportProtectionOptions {
}
```
-| Option | Type | Default | Description |
-| --------------------- | -------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- |
-| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin |
-| `behavior` | `string \| object` | `{ dev: 'mock', build: 'error' }` | What to do on violation |
-| `log` | `'once' \| 'always'` | `'once'` | Whether to deduplicate repeated violations |
-| `include` | `Pattern[]` | Start's `srcDirectory` | Only check importers matching these patterns |
-| `exclude` | `Pattern[]` | `[]` | Skip importers matching these patterns |
-| `ignoreImporters` | `Pattern[]` | `[]` | Ignore violations from these importers |
-| `maxTraceDepth` | `number` | `20` | Maximum depth for import traces |
-| `client` | `object` | See defaults above | Additional deny rules for the client environment |
-| `client.specifiers` | `Pattern[]` | Framework server specifiers | Specifier patterns denied in the client environment (additive with defaults) |
-| `client.files` | `Pattern[]` | `['**/*.server.*']` | File patterns denied in the client environment (replaces defaults) |
-| `client.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
-| `server` | `object` | See defaults above | Additional deny rules for the server environment |
-| `server.specifiers` | `Pattern[]` | `[]` | Specifier patterns denied in the server environment (replaces defaults) |
-| `server.files` | `Pattern[]` | `['**/*.client.*']` | File patterns denied in the server environment (replaces defaults) |
-| `server.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
-| `onViolation` | `function` | `undefined` | Callback invoked on every violation |
+| Option | Type | Default | Description |
+| --------------------- | -------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin |
+| `behavior` | `string \| object` | `{ dev: 'mock', build: 'error' }` | What to do on violation |
+| `log` | `'once' \| 'always'` | `'once'` | Whether to deduplicate repeated violations |
+| `include` | `Pattern[]` | Start's `srcDirectory` | Only check importers matching these patterns |
+| `exclude` | `Pattern[]` | `[]` | Skip importers matching these patterns |
+| `ignoreImporters` | `Pattern[]` | `[]` | Ignore violations from these importers |
+| `maxTraceDepth` | `number` | `20` | Maximum depth for import traces |
+| `client` | `object` | See defaults above | Additional deny rules for the client environment |
+| `client.specifiers` | `Pattern[]` | Framework server specifiers | Specifier patterns denied in the client environment (additive with defaults) |
+| `client.files` | `Pattern[]` | `['**/*.server.*']` | File patterns denied in the client environment (replaces defaults) |
+| `client.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
+| `server` | `object` | See defaults above | Additional deny rules for the server environment |
+| `server.specifiers` | `Pattern[]` | `[]` | Specifier patterns denied in the server environment (replaces defaults; defaults for `server.specifiers` are `[]`, so unlike `client.specifiers` this isn't additive) |
+| `server.files` | `Pattern[]` | `['**/*.client.*']` | File patterns denied in the server environment (replaces defaults) |
+| `server.excludeFiles` | `Pattern[]` | `['**/node_modules/**']` | Resolved files matching these patterns skip resolved-target checks (file-pattern + marker) (replaces defaults) |
+| `onViolation` | `function` | `undefined` | Callback invoked on every violation |
From f0eda8f55d5b5c2f65675aa219e334a4033d05a3 Mon Sep 17 00:00:00 2001
From: Manuel Schiller
Date: Wed, 25 Feb 2026 01:54:28 +0100
Subject: [PATCH 6/6] fix tests
---
.../tests/error-mode.setup.ts | 5 +--
.../tests/violations.setup.ts | 35 ++++++++++++++++---
2 files changed, 34 insertions(+), 6 deletions(-)
diff --git a/e2e/react-start/import-protection/tests/error-mode.setup.ts b/e2e/react-start/import-protection/tests/error-mode.setup.ts
index 620360fc5a9..d90f3c0f622 100644
--- a/e2e/react-start/import-protection/tests/error-mode.setup.ts
+++ b/e2e/react-start/import-protection/tests/error-mode.setup.ts
@@ -144,8 +144,9 @@ async function captureDev(cwd: string): Promise {
for (const route of routes) {
try {
await page.goto(`${baseURL}${route}`, {
- waitUntil: 'networkidle',
- timeout: 15_000,
+ // Vite dev keeps long-lived connections; 'networkidle' can hang.
+ waitUntil: 'load',
+ timeout: 30_000,
})
} catch {
// expected — modules fail with 500 in error mode
diff --git a/e2e/react-start/import-protection/tests/violations.setup.ts b/e2e/react-start/import-protection/tests/violations.setup.ts
index 7ffe5fd1ef2..d005718a242 100644
--- a/e2e/react-start/import-protection/tests/violations.setup.ts
+++ b/e2e/react-start/import-protection/tests/violations.setup.ts
@@ -83,6 +83,16 @@ const routes = [
'/barrel-false-positive',
]
+const routeReadyTestIds: Record = {
+ '/': 'heading',
+ '/leaky-server-import': 'leaky-heading',
+ '/client-only-violations': 'client-only-heading',
+ '/client-only-jsx': 'client-only-jsx-heading',
+ '/beforeload-leak': 'beforeload-leak-heading',
+ '/component-server-leak': 'component-leak-heading',
+ '/barrel-false-positive': 'barrel-heading',
+}
+
async function navigateAllRoutes(
baseURL: string,
browser: Awaited>,
@@ -92,10 +102,27 @@ async function navigateAllRoutes(
for (const route of routes) {
try {
- await page.goto(`${baseURL}${route}`, {
- waitUntil: 'networkidle',
- timeout: 15_000,
- })
+ // Prefer 'networkidle' (ensures route chunks are actually fetched), but
+ // fall back if it hangs in certain CI environments.
+ try {
+ await page.goto(`${baseURL}${route}`, {
+ waitUntil: 'networkidle',
+ timeout: 15_000,
+ })
+ } catch {
+ await page.goto(`${baseURL}${route}`, {
+ waitUntil: 'load',
+ timeout: 30_000,
+ })
+ }
+
+ const testId = routeReadyTestIds[route]
+ if (testId) {
+ await page.getByTestId(testId).waitFor({ timeout: 10_000 })
+ }
+
+ // Allow any deferred transforms/logging to flush.
+ await new Promise((r) => setTimeout(r, 750))
} catch {
// ignore navigation errors — we only care about server logs
}