From 1b8c9947e788940ad0a5a368996d2df0f6fcaef0 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:22:41 +0200 Subject: [PATCH] fix(start): preserve resolved virtual module ids fixes #7725 --- .changeset/tidy-pandas-load.md | 5 ++ .../src/routes/issue-7725.tsx | 15 +++++ .../issue-7725-server-fn-factory.ts | 3 + .../import-protection/vite.config.ts | 55 +++++++++++++----- .../src/import-protection/constants.ts | 4 -- .../src/start-compiler/compiler.ts | 8 +-- .../src/start-compiler/utils.ts | 7 +++ .../vite/import-protection-plugin/plugin.ts | 12 ++-- .../start-plugin-core/src/vite/module-id.ts | 31 ++++++++++ .../src/vite/start-compiler-plugin/plugin.ts | 13 ++++- .../createServerFn/createServerFn.test.ts | 58 +++++++++++++++++++ .../tests/vite/module-id.test.ts | 56 ++++++++++++++++++ 12 files changed, 234 insertions(+), 33 deletions(-) create mode 100644 .changeset/tidy-pandas-load.md create mode 100644 e2e/react-start/import-protection/src/routes/issue-7725.tsx create mode 100644 e2e/react-start/import-protection/src/violations/issue-7725-server-fn-factory.ts create mode 100644 packages/start-plugin-core/src/vite/module-id.ts create mode 100644 packages/start-plugin-core/tests/vite/module-id.test.ts 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) + }) +})