Skip to content

Start: mock-edge virtual module ENOENT crash in build mode (cleanId strips \0 before this.load()) #7725

Description

@johnhenry

Describe the bug

In build mode, when createServerFn's static analysis (the start-compiler-plugin) needs to recursively load a module to detect what it references, and that module resolves to import-protection's mock-edge virtual module (i.e. some import in the dependency chain is denied in the client environment and gets swapped for a generated mock), the build crashes with:

[tanstack-start-core::server-fn:client] Could not load tanstack-start-import-protection:mock-edge:<base64>: ENOENT: no such file or directory, open 'tanstack-start-import-protection:mock-edge:<base64>'

Root cause

In packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts, the resolveId callback passed into StartCompiler does:

resolveId: async (source, importer) => {
  const r = await this.resolve(source, importer)
  if (r) {
    if (!r.external) return cleanId(r.id)
  }
  return null
}

cleanId() strips the leading \0 that marks a Rollup-internal virtual module id. That's correct for normal filesystem-backed ids, but when r.id is one of import-protection's own virtual ids (e.g. \0tanstack-start-import-protection:mock-edge:...), stripping the \0 produces a bare string like tanstack-start-import-protection:mock-edge:....

That cleaned id is then passed to loadModule, which in build mode calls:

const loaded = await this.load({ id: id2 })

Rollup's load hook dispatch for import-protection's own load handler filters on id: new RegExp(getResolvedVirtualModuleMatchers().map(escapeRegExp).join("|")), and every entry in getResolvedVirtualModuleMatchers() is \0-prefixed. Since the id passed to this.load() here lost its \0 prefix, the filter never matches, so Rollup falls through to vite:load-fallback, which tries to literally fs.readFile() the string and throws ENOENT.

Reproduction

Minimal repro: a basic TanStack Start app (native-Vite mode, not vinxi) with a single route that imports createServerFn and calls it in a route loader, e.g.:

import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";

const getData = createServerFn({ method: "GET" }).handler(async () => {
  const mod = await import("some-package-with-a-denied-import-in-its-dependency-chain");
  return { result: mod.compute() };
});

export const Route = createFileRoute("/")({
  loader: () => getData(),
  component: () => null,
});

npm run build (vite build) fails with the ENOENT error above whenever the imported dependency's chain contains something import-protection would deny in the client environment (e.g. a Node builtin import several levels deep). We reproduced this on both the first version to ship import-protection (@tanstack/react-start@1.165.0 / @tanstack/start-plugin-core@1.165.0) and today's latest (@tanstack/react-start@1.168.27 / @tanstack/start-plugin-core@1.171.19) — it reproduces across the entire version range that has import-protection.

Workaround

We patched resolveId to skip cleanId specifically for import-protection's own virtual ids:

 resolveId: async (source, importer) => {
   const r = await this.resolve(source, importer)
   if (r) {
-    if (!r.external) return cleanId(r.id)
+    if (!r.external) return r.id.startsWith("\0tanstack-start-import-protection:") ? r.id : cleanId(r.id)
   }
   return null
 }

This fixes the build without disabling import-protection — the mock module resolves and loads correctly, exactly as it's designed to.

Your Example Website or App

N/A — minimal repro described above, happy to provide a full reproduction repo if useful.

Steps to Reproduce the Bug or Issue

  1. Scaffold a TanStack Start app using native-Vite mode (not vinxi) with @tanstack/react-start >= 1.165.0.
  2. Add a createServerFn().handler() whose handler references (even transitively) something import-protection would mark as a denied import in the client environment.
  3. Run vite build.
  4. Observe the Could not load tanstack-start-import-protection:mock-edge:...: ENOENT crash.

Expected behavior

The build should succeed, with the denied import correctly replaced by import-protection's generated mock module (as it does for the analogous cases that don't happen to route through the server-fn compiler's internal resolveId/loadModule static-analysis path).

Screenshots or Videos

No response

Platform

  • OS: Linux (NixOS 25.11)
  • Node: v22 / v26
  • @tanstack/react-start: 1.165.0 through 1.168.27 (reproduces across this whole range)
  • vite: 7.3.6
  • Build mode: native Vite (not vinxi)

Additional context

Related-but-distinct: the older pre-import-protection bug class (#4022, #3990, #2783 — "Readable is not exported by __vite-browser-external") is still present in versions before import-protection shipped (confirmed at 1.144.0/1.145.4 and 1.160.0). So currently there's no published version of @tanstack/react-start that builds a createServerFn-using native-Vite app cleanly without either this bug or that one, unless the fix above (or something equivalent) is applied.

Validations

  • Read the Contributing Guidelines.
  • Read the docs.
  • Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions