diff --git a/.changeset/tidy-pandas-load.md b/.changeset/tidy-pandas-load.md
new file mode 100644
index 0000000000..d1332d5c42
--- /dev/null
+++ b/.changeset/tidy-pandas-load.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/start-plugin-core': patch
+---
+
+Preserve exact Vite-resolved module IDs, including virtual prefixes and query variants, when the Start compiler recursively loads imports. Clean IDs only for file-oriented diagnostics and invalidation, and preserve existing query strings when adding compiler-owned lookup flags, so virtual modules such as import-protection mocks reach their plugin load hooks unchanged.
diff --git a/e2e/react-start/import-protection/src/routes/issue-7725.tsx b/e2e/react-start/import-protection/src/routes/issue-7725.tsx
new file mode 100644
index 0000000000..ef0152dd13
--- /dev/null
+++ b/e2e/react-start/import-protection/src/routes/issue-7725.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { issue7725ServerFnFactory } from '../violations/issue-7725-server-fn-factory'
+
+const getIssue7725Data = issue7725ServerFnFactory.handler(async () => {
+ return 'issue 7725 server function'
+})
+
+export const Route = createFileRoute('/issue-7725')({
+ loader: () => getIssue7725Data(),
+ component: Issue7725,
+})
+
+function Issue7725() {
+ return
Issue 7725
+}
diff --git a/e2e/react-start/import-protection/src/violations/issue-7725-server-fn-factory.ts b/e2e/react-start/import-protection/src/violations/issue-7725-server-fn-factory.ts
new file mode 100644
index 0000000000..45be765b1b
--- /dev/null
+++ b/e2e/react-start/import-protection/src/violations/issue-7725-server-fn-factory.ts
@@ -0,0 +1,3 @@
+import { createServerFn } from '@tanstack/react-start'
+
+export const issue7725ServerFnFactory = createServerFn()
diff --git a/e2e/react-start/import-protection/vite.config.ts b/e2e/react-start/import-protection/vite.config.ts
index 3163ded433..6a17f3a7cd 100644
--- a/e2e/react-start/import-protection/vite.config.ts
+++ b/e2e/react-start/import-protection/vite.config.ts
@@ -8,20 +8,43 @@ const outDir = process.env.E2E_DIST_DIR ?? 'dist'
const startModeConfig = getStartModeConfig()
const bundledDev = process.env.E2E_VITE_BUNDLED_DEV === 'true'
-export default defineConfig({
- resolve: { tsconfigPaths: true },
- experimental: bundledDev ? { bundledDev: true } : undefined,
- build: {
- outDir,
- },
- server: {
- port: 3000,
- },
- plugins: [tanstackStart(startModeConfig), viteReact()],
- // react-tweet's package.json exports resolve to `index.client.js` which
- // matches the default **/*.client.* deny pattern. Bundling it via
- // noExternal must NOT trigger a false-positive import-protection violation.
- ssr: {
- noExternal: ['react-tweet'],
- },
+const ISSUE_7725_DENIED_SPECIFIER = '../violations/issue-7725-server-fn-factory'
+
+export default defineConfig(({ command }) => {
+ const importProtection =
+ command === 'build'
+ ? {
+ ...startModeConfig.importProtection,
+ client: {
+ // Reproduce build-only issue #7725: resolving this imported
+ // server-fn builder must load import protection's mock virtual
+ // module, including its leading null-byte ID.
+ specifiers: [ISSUE_7725_DENIED_SPECIFIER],
+ },
+ }
+ : startModeConfig.importProtection
+
+ return {
+ resolve: { tsconfigPaths: true },
+ experimental: bundledDev ? { bundledDev: true } : undefined,
+ build: {
+ outDir,
+ },
+ server: {
+ port: 3000,
+ },
+ plugins: [
+ tanstackStart({
+ ...startModeConfig,
+ importProtection,
+ }),
+ viteReact(),
+ ],
+ // react-tweet's package.json exports resolve to `index.client.js` which
+ // matches the default **/*.client.* deny pattern. Bundling it via
+ // noExternal must NOT trigger a false-positive import-protection violation.
+ ssr: {
+ noExternal: ['react-tweet'],
+ },
+ }
})
diff --git a/packages/start-plugin-core/src/import-protection/constants.ts b/packages/start-plugin-core/src/import-protection/constants.ts
index 54bb55fdab..4186f6af49 100644
--- a/packages/start-plugin-core/src/import-protection/constants.ts
+++ b/packages/start-plugin-core/src/import-protection/constants.ts
@@ -1,7 +1,3 @@
-import { SERVER_FN_LOOKUP } from '../constants'
-
-export const SERVER_FN_LOOKUP_QUERY = `?${SERVER_FN_LOOKUP}`
-
export const MOCK_MODULE_ID = 'tanstack-start-import-protection:mock'
export const MOCK_BUILD_PREFIX = 'tanstack-start-import-protection:mock:build:'
export const MOCK_EDGE_PREFIX = 'tanstack-start-import-protection:mock-edge:'
diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts
index d6b36fe6e8..90fd5f564c 100644
--- a/packages/start-plugin-core/src/start-compiler/compiler.ts
+++ b/packages/start-plugin-core/src/start-compiler/compiler.ts
@@ -1537,7 +1537,7 @@ export class StartCompiler {
// TODO improve cycle detection? should we throw here instead of returning 'None'?
// prevent cycles
- const vKey = `${cleanId(id)}:${ident}`
+ const vKey = `${id}:${ident}`
if (visited.has(vKey)) {
return 'None'
}
@@ -1634,7 +1634,7 @@ export class StartCompiler {
resolution: ExportResolution,
visited = new Set(),
): Promise {
- const key = `${cleanId(resolution.moduleInfo.id)}:${resolution.localName}`
+ const key = `${resolution.moduleInfo.id}:${resolution.localName}`
if (visited.has(key)) {
return undefined
}
@@ -1709,7 +1709,7 @@ export class StartCompiler {
// Match by resolved binding identity, not by export name alone.
if (
target &&
- cleanId(resolved.moduleInfo.id) === cleanId(target.moduleInfo.id) &&
+ resolved.moduleInfo.id === target.moduleInfo.id &&
resolved.localName === target.localName
) {
return kind
@@ -1764,7 +1764,7 @@ export class StartCompiler {
// Import aliases can form cycles, e.g. A re-exports from B while B
// re-exports from A. Track the exported binding before following it.
- const vKey = `${cleanId(found.moduleInfo.id)}:${found.localName}`
+ const vKey = `${found.moduleInfo.id}:${found.localName}`
if (visited.has(vKey)) {
return 'None'
}
diff --git a/packages/start-plugin-core/src/start-compiler/utils.ts b/packages/start-plugin-core/src/start-compiler/utils.ts
index 36900845fb..384a13113d 100644
--- a/packages/start-plugin-core/src/start-compiler/utils.ts
+++ b/packages/start-plugin-core/src/start-compiler/utils.ts
@@ -25,6 +25,13 @@ export function codeFrameError(
return new Error(frame)
}
+/**
+ * Converts a bundler module ID to its physical-file identity for diagnostics,
+ * filesystem matching, and file-based invalidation.
+ *
+ * Do not use this for IDs passed to resolve/load hooks or as module cache keys:
+ * virtual prefixes and queries can be part of the module's semantic identity.
+ */
export function cleanId(id: string): string {
// Remove null byte prefix used by Vite/Rollup for virtual modules
if (id.startsWith('\0')) {
diff --git a/packages/start-plugin-core/src/vite/import-protection-plugin/plugin.ts b/packages/start-plugin-core/src/vite/import-protection-plugin/plugin.ts
index 55b98bec73..e319874fc6 100644
--- a/packages/start-plugin-core/src/vite/import-protection-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/vite/import-protection-plugin/plugin.ts
@@ -3,7 +3,8 @@ import { normalizePath } from 'vite'
import { dirname, relative } from 'pathe'
import { escapeRegExp, resolveViteId } from '../../utils'
-import { VITE_ENVIRONMENT_NAMES } from '../../constants'
+import { SERVER_FN_LOOKUP, VITE_ENVIRONMENT_NAMES } from '../../constants'
+import { hasIdQueryFlag } from '../module-id'
import {
ImportGraph,
buildTrace,
@@ -46,7 +47,6 @@ import { rewriteDeniedImports } from '../../import-protection/rewrite'
import { ExtensionlessAbsoluteIdResolver } from '../../import-protection/extensionlessAbsoluteIdResolver'
import {
IMPORT_PROTECTION_DEBUG,
- SERVER_FN_LOOKUP_QUERY,
VITE_BROWSER_VIRTUAL_PREFIX,
} from '../../import-protection/constants'
import {
@@ -1013,7 +1013,7 @@ export function importProtectionPlugin(
if (keySet) {
for (const k of keySet) {
- if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue
+ if (hasIdQueryFlag(k, SERVER_FN_LOOKUP)) continue
const imports = env.postTransformImports.get(k)
if (imports) {
if (!merged) merged = new Set(imports)
@@ -1051,7 +1051,7 @@ export function importProtectionPlugin(
if (keySet) {
for (const k of keySet) {
- if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue
+ if (hasIdQueryFlag(k, SERVER_FN_LOOKUP)) continue
const imports = env.postTransformImports.get(k)
if (imports) {
anyVariantCached = true
@@ -1650,7 +1650,7 @@ export function importProtectionPlugin(
}
const normalizedImporter = normalizeFilePath(importer)
- const isDirectLookup = importer.includes(SERVER_FN_LOOKUP_QUERY)
+ const isDirectLookup = hasIdQueryFlag(importer, SERVER_FN_LOOKUP)
const isBundledClientDev =
config.command === 'serve' &&
config.bundledDev &&
@@ -2228,7 +2228,7 @@ export function importProtectionPlugin(
const cacheKey = normalizePath(id)
const envState = getEnv(envName)
- const isServerFnLookup = id.includes(SERVER_FN_LOOKUP_QUERY)
+ const isServerFnLookup = hasIdQueryFlag(id, SERVER_FN_LOOKUP)
// Propagate SERVER_FN_LOOKUP status before import-analysis
if (isServerFnLookup) {
diff --git a/packages/start-plugin-core/src/vite/module-id.ts b/packages/start-plugin-core/src/vite/module-id.ts
new file mode 100644
index 0000000000..ef2ad98f10
--- /dev/null
+++ b/packages/start-plugin-core/src/vite/module-id.ts
@@ -0,0 +1,31 @@
+/** Checks for a query parameter without rewriting the opaque module ID. */
+export function hasIdQueryFlag(id: string, flag: string): boolean {
+ const queryIndex = id.indexOf('?')
+ if (queryIndex === -1) {
+ return false
+ }
+
+ return new URLSearchParams(id.slice(queryIndex + 1)).has(flag)
+}
+
+/** Appends an owned query flag without normalizing the existing query. */
+export function appendIdQueryFlag(id: string, flag: string): string {
+ if (!id.includes('?')) {
+ return `${id}?${flag}`
+ }
+
+ const separator = id.endsWith('&') ? '' : '&'
+ return `${id}${separator}${flag}`
+}
+
+/** Removes the owned query flag appended by {@link appendIdQueryFlag}. */
+export function removeIdQueryFlag(id: string, flag: string): string {
+ for (const separator of ['?', '&']) {
+ const suffix = `${separator}${flag}`
+ if (id.endsWith(suffix)) {
+ return id.slice(0, -suffix.length)
+ }
+ }
+
+ return id
+}
diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts
index 6309b76eb2..1cc02f3b91 100644
--- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts
@@ -21,6 +21,7 @@ import {
createHydrateCompilerPlugin,
} from '../../hydrate-when-transform'
import { resolveViteId } from '../../utils'
+import { appendIdQueryFlag, removeIdQueryFlag } from '../module-id'
import {
createViteDevServerFnModuleSpecifierEncoder,
decodeViteDevServerModuleSpecifier,
@@ -351,7 +352,7 @@ export function startCompilerPlugin(
{
load: (options) => this.load(options),
error: (message) => this.error(message),
- devId: `${id}?${SERVER_FN_LOOKUP}`,
+ devId: appendIdQueryFlag(id, SERVER_FN_LOOKUP),
},
)
if (code !== undefined) {
@@ -364,7 +365,10 @@ export function startCompilerPlugin(
if (r) {
if (!r.external) {
- return cleanId(r.id)
+ // Keep the resolved ID intact because it is passed back to
+ // Vite's load hook. Virtual-module prefixes and queries are
+ // part of that load identity, not compiler-only metadata.
+ return r.id
}
}
@@ -496,7 +500,10 @@ export function startCompilerPlugin(
},
handler(code, id) {
const compiler = compilers.get(this.environment.name)
- compiler?.ingestModule({ code, id: cleanId(id) })
+ compiler?.ingestModule({
+ code,
+ id: removeIdQueryFlag(id, SERVER_FN_LOOKUP),
+ })
},
},
},
diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
index c4009657c4..230caf7173 100644
--- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
@@ -457,6 +457,64 @@ describe('createServerFn compiles correctly', async () => {
)
})
+ test('keeps query-bearing virtual module identities distinct', async () => {
+ const publicFactoryId = '\0virtual:server-fn-factory?variant=public'
+ const implementationFactoryId =
+ '\0virtual:server-fn-factory?variant=implementation'
+ const virtualModules: Record = {
+ [publicFactoryId]: `
+ import { createIssueServerFn } from 'virtual:server-fn-factory?variant=implementation'
+ export { createIssueServerFn }
+ `,
+ [implementationFactoryId]: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const createIssueServerFn = createServerFn
+ `,
+ }
+ const loadedIds: Array = []
+
+ const compiler = new StartCompiler({
+ env: 'client',
+ ...getDefaultTestOptions('client'),
+ mode: 'build',
+ loadModule: async (id) => {
+ loadedIds.push(id)
+ const code = virtualModules[id]
+ if (code) {
+ compiler.ingestModule({ code, id })
+ }
+ },
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ getKnownServerFns: () => ({}),
+ resolveId: async (source) => {
+ if (source.startsWith('virtual:server-fn-factory?')) {
+ return `\0${source}`
+ }
+
+ return null
+ },
+ })
+
+ const result = await compiler.compile({
+ id: '/test/src/test.ts',
+ code: `
+ import { createIssueServerFn } from 'virtual:server-fn-factory?variant=public'
+ const issueServerFn = createIssueServerFn().handler(async () => 'ok')
+ `,
+ })
+
+ expect(result).not.toBeNull()
+ expect(result!.code).toContain('createClientRpc')
+ expect(loadedIds).toEqual([publicFactoryId, implementationFactoryId])
+ })
+
test('should resolve local named re-exports of createServerFn', async () => {
const code = `
import { createFooServerFn } from './factory'
diff --git a/packages/start-plugin-core/tests/vite/module-id.test.ts b/packages/start-plugin-core/tests/vite/module-id.test.ts
new file mode 100644
index 0000000000..003926a606
--- /dev/null
+++ b/packages/start-plugin-core/tests/vite/module-id.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, test } from 'vitest'
+import {
+ appendIdQueryFlag,
+ hasIdQueryFlag,
+ removeIdQueryFlag,
+} from '../../src/vite/module-id'
+
+const flag = 'server-fn-module-lookup'
+
+describe('hasIdQueryFlag', () => {
+ test.each([
+ ['/src/route.ts', false],
+ [`/src/route.ts?${flag}`, true],
+ [`/src/route.ts?mode=dev&${flag}`, true],
+ [`/src/route.ts?${flag}=enabled`, true],
+ [`/src/route.ts?mode=${flag}`, false],
+ [`/src/route.ts?${flag}-suffix`, false],
+ ])('checks the query parameter name in %s', (id, expected) => {
+ expect(hasIdQueryFlag(id, flag)).toBe(expected)
+ })
+})
+
+describe('appendIdQueryFlag', () => {
+ test.each([
+ ['/src/route.ts', `/src/route.ts?${flag}`],
+ ['/src/route.ts?mode=dev', `/src/route.ts?mode=dev&${flag}`],
+ ['/src/route.ts?', `/src/route.ts?&${flag}`],
+ ['/src/route.ts?mode=dev&', `/src/route.ts?mode=dev&${flag}`],
+ ])('appends the flag to %s', (id, expected) => {
+ expect(appendIdQueryFlag(id, flag)).toBe(expected)
+ })
+})
+
+describe('removeIdQueryFlag', () => {
+ test.each([
+ [`/src/route.ts?${flag}`, '/src/route.ts'],
+ [`/src/route.ts?mode=dev&${flag}`, '/src/route.ts?mode=dev'],
+ [`/src/route.ts?${flag}&mode=dev`, `/src/route.ts?${flag}&mode=dev`],
+ ['/src/route.ts?mode=dev', '/src/route.ts?mode=dev'],
+ ])('removes only an appended flag from %s', (id, expected) => {
+ expect(removeIdQueryFlag(id, flag)).toBe(expected)
+ })
+})
+
+describe('module ID query flag round trip', () => {
+ test.each([
+ '/src/route.ts',
+ '/src/route.ts?',
+ '/src/route.ts?variant=client',
+ '\0virtual:factory?variant=client&encoded=a%2Fb',
+ `/src/route.ts?${flag}`,
+ ])('preserves the original ID %s', (id) => {
+ const withFlag = appendIdQueryFlag(id, flag)
+ expect(removeIdQueryFlag(withFlag, flag)).toBe(id)
+ })
+})