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 }