Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-pandas-load.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions e2e/react-start/import-protection/src/routes/issue-7725.tsx
Original file line number Diff line number Diff line change
@@ -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 <h1>Issue 7725</h1>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createServerFn } from '@tanstack/react-start'

export const issue7725ServerFnFactory = createServerFn()
55 changes: 39 additions & 16 deletions e2e/react-start/import-protection/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
}
})
4 changes: 0 additions & 4 deletions packages/start-plugin-core/src/import-protection/constants.ts
Original file line number Diff line number Diff line change
@@ -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:'
Expand Down
8 changes: 4 additions & 4 deletions packages/start-plugin-core/src/start-compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down Expand Up @@ -1634,7 +1634,7 @@ export class StartCompiler {
resolution: ExportResolution,
visited = new Set<string>(),
): Promise<ExportResolution | undefined> {
const key = `${cleanId(resolution.moduleInfo.id)}:${resolution.localName}`
const key = `${resolution.moduleInfo.id}:${resolution.localName}`
if (visited.has(key)) {
return undefined
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
Expand Down
7 changes: 7 additions & 0 deletions packages/start-plugin-core/src/start-compiler/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions packages/start-plugin-core/src/vite/module-id.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
createHydrateCompilerPlugin,
} from '../../hydrate-when-transform'
import { resolveViteId } from '../../utils'
import { appendIdQueryFlag, removeIdQueryFlag } from '../module-id'
import {
createViteDevServerFnModuleSpecifierEncoder,
decodeViteDevServerModuleSpecifier,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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),
})
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
[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<string> = []

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'
Expand Down
56 changes: 56 additions & 0 deletions packages/start-plugin-core/tests/vite/module-id.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading