From d161bcfdbea27a16b156e31725437f52eef21d93 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 25 Dec 2025 18:39:16 +0100 Subject: [PATCH 1/3] fix: createIsomorphicFn compilation fixes #6217 fixes #6206 --- .../start-fn-stubs/src/createIsomorphicFn.ts | 7 ++- .../tests/createIsomorphicFn.test-d.ts | 3 - .../src/start-compiler-plugin/compiler.ts | 63 ++++++++++++++----- .../handleCreateIsomorphicFn.ts | 16 ----- .../createIsomorphicFn.test.ts | 34 +--------- .../snapshots/client/call-at-module-level.ts | 5 ++ .../client/createIsomorphicFnDestructured.tsx | 3 +- .../createIsomorphicFnDestructuredRename.tsx | 3 +- .../client/createIsomorphicFnFactory.tsx | 4 +- .../client/createIsomorphicFnStarImport.tsx | 3 +- .../snapshots/server/call-at-module-level.ts | 5 ++ .../server/createIsomorphicFnDestructured.tsx | 3 +- .../createIsomorphicFnDestructuredRename.tsx | 3 +- .../server/createIsomorphicFnFactory.tsx | 4 +- .../server/createIsomorphicFnStarImport.tsx | 3 +- .../test-files/call-at-module-level.ts | 13 ++++ 16 files changed, 93 insertions(+), 79 deletions(-) create mode 100644 packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts create mode 100644 packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts create mode 100644 packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts diff --git a/packages/start-fn-stubs/src/createIsomorphicFn.ts b/packages/start-fn-stubs/src/createIsomorphicFn.ts index 09a1be9ec13..df6590c6e0d 100644 --- a/packages/start-fn-stubs/src/createIsomorphicFn.ts +++ b/packages/start-fn-stubs/src/createIsomorphicFn.ts @@ -21,7 +21,7 @@ export interface ClientOnlyFn, TClient> ) => IsomorphicFn } -export interface IsomorphicFnBase extends IsomorphicFn { +export interface IsomorphicFnBase { server: , TServer>( serverImpl: (...args: TArgs) => TServer, ) => ServerOnlyFn @@ -34,8 +34,9 @@ export interface IsomorphicFnBase extends IsomorphicFn { // if we use `createIsomorphicFn` in this library itself, vite tries to execute it before the transformer runs // therefore we must return a dummy function that allows calling `server` and `client` method chains. export function createIsomorphicFn(): IsomorphicFnBase { - return { + const fn = () => undefined + return Object.assign(fn, { server: () => ({ client: () => () => {} }), client: () => ({ server: () => () => {} }), - } as any + }) as any } diff --git a/packages/start-fn-stubs/tests/createIsomorphicFn.test-d.ts b/packages/start-fn-stubs/tests/createIsomorphicFn.test-d.ts index 7c6c3b040a0..bb75a6311b8 100644 --- a/packages/start-fn-stubs/tests/createIsomorphicFn.test-d.ts +++ b/packages/start-fn-stubs/tests/createIsomorphicFn.test-d.ts @@ -4,9 +4,6 @@ import { createIsomorphicFn } from '../src/createIsomorphicFn' test('createIsomorphicFn with no implementations', () => { const fn = createIsomorphicFn() - expectTypeOf(fn).toBeCallableWith() - expectTypeOf(fn).returns.toBeUndefined() - expectTypeOf(fn).toHaveProperty('server') expectTypeOf(fn).toHaveProperty('client') }) diff --git a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts index 70ae1be911b..65d10f83399 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts @@ -52,10 +52,6 @@ export type LookupKind = type MethodChainSetup = { type: 'methodChain' candidateCallIdentifier: Set - // If true, a call to the root function (e.g., createIsomorphicFn()) is also a candidate - // even without chained method calls. This is used for IsomorphicFn which can be - // called without .client() or .server() (resulting in a no-op function). - allowRootAsCandidate?: boolean } type DirectCallSetup = { type: 'directCall' } type JSXSetup = { type: 'jsx'; componentName: string } @@ -75,7 +71,6 @@ const LookupSetup: Record< IsomorphicFn: { type: 'methodChain', candidateCallIdentifier: new Set(['server', 'client']), - allowRootAsCandidate: true, // createIsomorphicFn() alone is valid (returns no-op) }, ServerOnlyFn: { type: 'directCall' }, ClientOnlyFn: { type: 'directCall' }, @@ -179,14 +174,13 @@ for (const [kind, setup] of Object.entries(LookupSetup) as Array< } } -// Known factory function names for direct call and root-as-candidate patterns +// Known factory function names for direct call patterns // These are the names that, when called directly, create a new function. // Used to filter nested candidates - we only want to include actual factory calls, // not invocations of already-created functions (e.g., `myServerFn()` should NOT be a candidate) const DirectCallFactoryNames = new Set([ 'createServerOnlyFn', 'createClientOnlyFn', - 'createIsomorphicFn', ]) export type LookupConfig = { @@ -205,16 +199,12 @@ interface ModuleInfo { /** * Computes whether any file kinds need direct-call candidate detection. - * This includes both directCall types (ServerOnlyFn, ClientOnlyFn) and - * allowRootAsCandidate types (IsomorphicFn). + * This applies to directCall types (ServerOnlyFn, ClientOnlyFn). */ function needsDirectCallDetection(kinds: Set): boolean { for (const kind of kinds) { const setup = LookupSetup[kind] - if ( - setup.type === 'directCall' || - (setup.type === 'methodChain' && setup.allowRootAsCandidate) - ) { + if (setup.type === 'directCall') { return true } } @@ -1165,6 +1155,28 @@ export class StartCompiler { return resolvedKind } + /** + * Checks if an identifier is a direct import from a known factory library. + * Returns true for imports like `import { createServerOnlyFn } from '@tanstack/react-start'` + * or renamed imports like `import { createServerOnlyFn as myFn } from '...'`. + * Returns false for local variables that hold the result of calling a factory. + */ + private async isKnownFactoryImport( + identName: string, + fileId: string, + ): Promise { + const info = await this.getModuleInfo(fileId) + const binding = info.bindings.get(identName) + + if (!binding || binding.type !== 'import') { + return false + } + + // Check if it's imported from a known library + const knownExports = this.knownRootImports.get(binding.source) + return knownExports !== undefined && knownExports.has(binding.importedName) + } + private async resolveExprKind( expr: t.Expression | null, fileId: string, @@ -1197,9 +1209,28 @@ export class StartCompiler { if (calleeKind === 'Root' || calleeKind === 'Builder') { return 'Builder' } - // Use direct Set.has() instead of iterating - if (this.validLookupKinds.has(calleeKind as LookupKind)) { - return calleeKind + // For method chain patterns (callee is MemberExpression like .server() or .client()), + // return the resolved kind if valid + if (t.isMemberExpression(expr.callee)) { + if (this.validLookupKinds.has(calleeKind as LookupKind)) { + return calleeKind + } + } + // For direct calls (callee is Identifier), only return the kind if the + // callee is a direct import from a known library (e.g., createServerOnlyFn). + // Calling a local variable that holds an already-built function (e.g., myServerOnlyFn()) + // should NOT be treated as a transformation candidate. + if (t.isIdentifier(expr.callee)) { + const isFactoryImport = await this.isKnownFactoryImport( + expr.callee.name, + fileId, + ) + if ( + isFactoryImport && + this.validLookupKinds.has(calleeKind as LookupKind) + ) { + return calleeKind + } } } else if (t.isMemberExpression(expr) && t.isIdentifier(expr.property)) { result = await this.resolveCalleeKind(expr.object, fileId, visited) diff --git a/packages/start-plugin-core/src/start-compiler-plugin/handleCreateIsomorphicFn.ts b/packages/start-plugin-core/src/start-compiler-plugin/handleCreateIsomorphicFn.ts index 83b7a69dde6..8331a008491 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/handleCreateIsomorphicFn.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/handleCreateIsomorphicFn.ts @@ -18,22 +18,6 @@ export function handleCreateIsomorphicFn( const envCallInfo = context.env === 'client' ? methodChain.client : methodChain.server - // Check if we have any implementation at all - if (!methodChain.client && !methodChain.server) { - // No implementations provided - warn and replace with no-op - const variableId = path.parentPath.isVariableDeclarator() - ? path.parentPath.node.id - : null - console.warn( - 'createIsomorphicFn called without a client or server implementation!', - 'This will result in a no-op function.', - 'Variable name:', - t.isIdentifier(variableId) ? variableId.name : 'unknown', - ) - path.replaceWith(t.arrowFunctionExpression([], t.blockStatement([]))) - continue - } - if (!envCallInfo) { // No implementation for this environment - replace with no-op path.replaceWith(t.arrowFunctionExpression([], t.blockStatement([]))) diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts b/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts index c161b805860..ca292071452 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts +++ b/packages/start-plugin-core/tests/createIsomorphicFn/createIsomorphicFn.test.ts @@ -1,6 +1,6 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' -import { afterAll, describe, expect, test, vi } from 'vitest' +import { describe, expect, test } from 'vitest' import { StartCompiler } from '../../src/start-compiler-plugin/compiler' @@ -50,22 +50,6 @@ async function getFilenames() { } describe('createIsomorphicFn compiles correctly', async () => { - const noImplWarning = - 'createIsomorphicFn called without a client or server implementation!' - - const originalConsoleWarn = console.warn - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation((...args) => { - // we want to avoid sending this warning to the console, we know about it - if (args[0] === noImplWarning) { - return - } - originalConsoleWarn(...args) - }) - - afterAll(() => { - consoleSpy.mockRestore() - }) - const filenames = await getFilenames() describe.each(filenames)('should handle "%s"', async (filename) => { @@ -111,20 +95,4 @@ describe('createIsomorphicFn compiles correctly', async () => { }), ).rejects.toThrowError() }) - - test('should warn to console if no implementations provided', async () => { - await compile({ - env: 'client', - code: ` - import { createIsomorphicFn } from '@tanstack/react-start' - const noImpl = createIsomorphicFn()`, - id: 'no-fn.ts', - }) - expect(consoleSpy).toHaveBeenCalledWith( - noImplWarning, - 'This will result in a no-op function.', - 'Variable name:', - 'noImpl', - ) - }) }) diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts new file mode 100644 index 00000000000..5591995c329 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts @@ -0,0 +1,5 @@ +const getEnvironment = () => { + console.log("[CLIENT] getEnvironment called"); + return "client"; +}; +const moduleLevel = getEnvironment(); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx index 87eb2bba0ea..956fe05f98f 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx @@ -1,4 +1,5 @@ -const noImpl = () => {}; +import { createIsomorphicFn } from '@tanstack/react-start'; +const noImpl = createIsomorphicFn(); const serverOnlyFn = () => {}; const clientOnlyFn = () => 'client'; const serverThenClientFn = () => 'client'; diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx index 87eb2bba0ea..5e167daeb68 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx @@ -1,4 +1,5 @@ -const noImpl = () => {}; +import { createIsomorphicFn as isomorphicFn } from '@tanstack/react-start'; +const noImpl = isomorphicFn(); const serverOnlyFn = () => {}; const clientOnlyFn = () => 'client'; const serverThenClientFn = () => 'client'; diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx index 803bb6d8011..b54ad664b13 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx @@ -1,3 +1,5 @@ +import { createIsomorphicFn } from '@tanstack/react-start'; + // Isomorphic function factory - returns a createIsomorphicFn with .client() and .server() calls export function createPlatformFn(platform: string) { return () => `client-${platform}`; @@ -18,7 +20,7 @@ export const createClientImplFn = (name: string) => { // Factory returning no-implementation isomorphic fn export function createNoImplFn() { - return () => {}; + return createIsomorphicFn(); } // Top-level isomorphic fn for comparison diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx index 87eb2bba0ea..070f59d901e 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx @@ -1,4 +1,5 @@ -const noImpl = () => {}; +import * as TanStackStart from '@tanstack/react-start'; +const noImpl = TanStackStart.createIsomorphicFn(); const serverOnlyFn = () => {}; const clientOnlyFn = () => 'client'; const serverThenClientFn = () => 'client'; diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts new file mode 100644 index 00000000000..9747f2db029 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts @@ -0,0 +1,5 @@ +const getEnvironment = () => { + console.log("[SERVER] getEnvironment called"); + return "server"; +}; +const moduleLevel = getEnvironment(); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx index f0b7d12af45..195cee12a6a 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx @@ -1,4 +1,5 @@ -const noImpl = () => {}; +import { createIsomorphicFn } from '@tanstack/react-start'; +const noImpl = createIsomorphicFn(); const serverOnlyFn = () => 'server'; const clientOnlyFn = () => {}; const serverThenClientFn = () => 'server'; diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx index f0b7d12af45..c50c274dd60 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx @@ -1,4 +1,5 @@ -const noImpl = () => {}; +import { createIsomorphicFn as isomorphicFn } from '@tanstack/react-start'; +const noImpl = isomorphicFn(); const serverOnlyFn = () => 'server'; const clientOnlyFn = () => {}; const serverThenClientFn = () => 'server'; diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx index ee949e0c382..cce206bc8b6 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx @@ -1,3 +1,5 @@ +import { createIsomorphicFn } from '@tanstack/react-start'; + // Isomorphic function factory - returns a createIsomorphicFn with .client() and .server() calls export function createPlatformFn(platform: string) { return () => `server-${platform}`; @@ -18,7 +20,7 @@ export const createClientImplFn = (name: string) => { // Factory returning no-implementation isomorphic fn export function createNoImplFn() { - return () => {}; + return createIsomorphicFn(); } // Top-level isomorphic fn for comparison diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx index f0b7d12af45..65e4fc18b16 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx @@ -1,4 +1,5 @@ -const noImpl = () => {}; +import * as TanStackStart from '@tanstack/react-start'; +const noImpl = TanStackStart.createIsomorphicFn(); const serverOnlyFn = () => 'server'; const clientOnlyFn = () => {}; const serverThenClientFn = () => 'server'; diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts new file mode 100644 index 00000000000..dc0835eff84 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts @@ -0,0 +1,13 @@ +import { createIsomorphicFn } from '@tanstack/react-start' + +const getEnvironment = createIsomorphicFn() + .server(() => { + console.log("[SERVER] getEnvironment called"); + return "server"; + }) + .client(() => { + console.log("[CLIENT] getEnvironment called"); + return "client"; + }); + +const moduleLevel = getEnvironment(); \ No newline at end of file From d405d7c012fbd41ffcb195312e8bdbea73e358e5 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 25 Dec 2025 18:58:57 +0100 Subject: [PATCH 2/3] simplify compiler --- .../src/start-compiler-plugin/compiler.ts | 175 +++++++----------- .../server/clientOnlyNotFromTanstack.tsx | 10 +- 2 files changed, 73 insertions(+), 112 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts index 65d10f83399..f51bff2e8c3 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts @@ -33,11 +33,6 @@ type Binding = resolvedKind?: Kind } -type ExportEntry = - | { tag: 'Normal'; name: string } - | { tag: 'Default'; name: string } - | { tag: 'Namespace'; name: string; targetId: string } // for `export * as ns from './x'` - type Kind = 'None' | `Root` | `Builder` | LookupKind export type LookupKind = @@ -53,7 +48,11 @@ type MethodChainSetup = { type: 'methodChain' candidateCallIdentifier: Set } -type DirectCallSetup = { type: 'directCall' } +type DirectCallSetup = { + type: 'directCall' + // The factory function name used to create this kind (e.g., 'createServerOnlyFn') + factoryName: string +} type JSXSetup = { type: 'jsx'; componentName: string } const LookupSetup: Record< @@ -72,8 +71,8 @@ const LookupSetup: Record< type: 'methodChain', candidateCallIdentifier: new Set(['server', 'client']), }, - ServerOnlyFn: { type: 'directCall' }, - ClientOnlyFn: { type: 'directCall' }, + ServerOnlyFn: { type: 'directCall', factoryName: 'createServerOnlyFn' }, + ClientOnlyFn: { type: 'directCall', factoryName: 'createClientOnlyFn' }, ClientOnlyJSX: { type: 'jsx', componentName: 'ClientOnly' }, } @@ -134,6 +133,9 @@ const KindHandlers: Record< // ClientOnlyJSX is handled separately via JSX traversal, not here } +// All lookup kinds as an array for iteration with proper typing +const AllLookupKinds = Object.keys(LookupSetup) as Array + /** * Detects which LookupKinds are present in the code using string matching. * This is a fast pre-scan before AST parsing to limit the work done during compilation. @@ -145,10 +147,8 @@ export function detectKindsInCode( const detected = new Set() const validForEnv = LookupKindsPerEnv[env] - for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array< - [LookupKind, RegExp] - >) { - if (validForEnv.has(kind) && pattern.test(code)) { + for (const kind of AllLookupKinds) { + if (validForEnv.has(kind) && KindDetectionPatterns[kind].test(code)) { detected.add(kind) } } @@ -159,9 +159,8 @@ export function detectKindsInCode( // Pre-computed map: identifier name -> Set for fast candidate detection (method chain only) // Multiple kinds can share the same identifier (e.g., 'server' and 'client' are used by both Middleware and IsomorphicFn) const IdentifierToKinds = new Map>() -for (const [kind, setup] of Object.entries(LookupSetup) as Array< - [LookupKind, MethodChainSetup | DirectCallSetup] ->) { +for (const kind of AllLookupKinds) { + const setup = LookupSetup[kind] if (setup.type === 'methodChain') { for (const id of setup.candidateCallIdentifier) { let kinds = IdentifierToKinds.get(id) @@ -174,14 +173,16 @@ for (const [kind, setup] of Object.entries(LookupSetup) as Array< } } -// Known factory function names for direct call patterns -// These are the names that, when called directly, create a new function. +// Factory function names for direct call patterns. // Used to filter nested candidates - we only want to include actual factory calls, // not invocations of already-created functions (e.g., `myServerFn()` should NOT be a candidate) -const DirectCallFactoryNames = new Set([ - 'createServerOnlyFn', - 'createClientOnlyFn', -]) +const DirectCallFactoryNames = new Set() +for (const kind of AllLookupKinds) { + const setup = LookupSetup[kind] + if (setup.type === 'directCall') { + DirectCallFactoryNames.add(setup.factoryName) + } +} export type LookupConfig = { libName: string @@ -192,7 +193,8 @@ export type LookupConfig = { interface ModuleInfo { id: string bindings: Map - exports: Map + // Maps exported name → local binding name + exports: Map // Track `export * from './module'` declarations for re-export resolution reExportAllSources: Array } @@ -203,8 +205,7 @@ interface ModuleInfo { */ function needsDirectCallDetection(kinds: Set): boolean { for (const kind of kinds) { - const setup = LookupSetup[kind] - if (setup.type === 'directCall') { + if (LookupSetup[kind].type === 'directCall') { return true } } @@ -226,28 +227,13 @@ function areAllKindsTopLevelOnly(kinds: Set): boolean { */ function needsJSXDetection(kinds: Set): boolean { for (const kind of kinds) { - const setup = LookupSetup[kind] - if (setup.type === 'jsx') { + if (LookupSetup[kind].type === 'jsx') { return true } } return false } -/** - * Gets the set of JSX component names to detect. - */ -function getJSXComponentNames(kinds: Set): Set { - const names = new Set() - for (const kind of kinds) { - const setup = LookupSetup[kind] - if (setup.type === 'jsx') { - names.add(setup.componentName) - } - } - return names -} - /** * Checks if a CallExpression is a direct-call candidate for NESTED detection. * Returns true if the callee is a known factory function name. @@ -271,7 +257,7 @@ function isNestedDirectCallCandidate(node: t.CallExpression): boolean { * Checks if a CallExpression path is a top-level direct-call candidate. * Top-level means the call is the init of a VariableDeclarator at program level. * We accept any simple identifier call or namespace call at top level - * (e.g., `isomorphicFn()`, `TanStackStart.createServerOnlyFn()`) and let + * (e.g., `createServerOnlyFn()`, `TanStackStart.createServerOnlyFn()`) and let * resolution verify it. This handles renamed imports. */ function isTopLevelDirectCallCandidate( @@ -320,6 +306,9 @@ export class StartCompiler { private entryIdToFunctionId = new Map() private functionIds = new Set() + // Cached root path with trailing slash for dev mode function ID generation + private _rootWithTrailingSlash: string | undefined + constructor( private options: { env: 'client' | 'server' @@ -376,12 +365,9 @@ export class StartCompiler { }): string { if (this.mode === 'dev') { // In dev, encode the file path and export name for direct lookup - const rootWithTrailingSlash = this.options.root.endsWith('/') - ? this.options.root - : `${this.options.root}/` let file = opts.extractedFilename - if (opts.extractedFilename.startsWith(rootWithTrailingSlash)) { - file = opts.extractedFilename.slice(rootWithTrailingSlash.length) + if (opts.extractedFilename.startsWith(this.rootWithTrailingSlash)) { + file = opts.extractedFilename.slice(this.rootWithTrailingSlash.length) } file = `/@id/${file}` @@ -424,6 +410,15 @@ export class StartCompiler { return this.options.mode ?? 'dev' } + private get rootWithTrailingSlash(): string { + if (this._rootWithTrailingSlash === undefined) { + this._rootWithTrailingSlash = this.options.root.endsWith('/') + ? this.options.root + : `${this.options.root}/` + } + return this._rootWithTrailingSlash + } + private async resolveIdCached(id: string, importer?: string) { if (this.mode === 'dev') { return this.options.resolveId(id, importer) @@ -498,15 +493,8 @@ export class StartCompiler { this.moduleCache.set(libId, rootModule) } - rootModule.exports.set(config.rootExport, { - tag: 'Normal', - name: config.rootExport, - }) - rootModule.exports.set('*', { - tag: 'Namespace', - name: config.rootExport, - targetId: libId, - }) + rootModule.exports.set(config.rootExport, config.rootExport) + rootModule.exports.set('*', config.rootExport) rootModule.bindings.set(config.rootExport, { type: 'var', init: null, // Not needed since resolvedKind is set @@ -528,7 +516,7 @@ export class StartCompiler { id: string, ): ModuleInfo { const bindings = new Map() - const exports = new Map() + const exports = new Map() const reExportAllSources: Array = [] // we are only interested in top-level bindings, hence we don't traverse the AST @@ -571,7 +559,7 @@ export class StartCompiler { if (t.isVariableDeclaration(node.declaration)) { for (const d of node.declaration.declarations) { if (t.isIdentifier(d.id)) { - exports.set(d.id.name, { tag: 'Normal', name: d.id.name }) + exports.set(d.id.name, d.id.name) bindings.set(d.id.name, { type: 'var', init: d.init ?? null }) } } @@ -579,11 +567,7 @@ export class StartCompiler { } for (const sp of node.specifiers) { if (t.isExportNamespaceSpecifier(sp)) { - exports.set(sp.exported.name, { - tag: 'Namespace', - name: sp.exported.name, - targetId: node.source?.value || '', - }) + exports.set(sp.exported.name, sp.exported.name) } // export { local as exported } else if (t.isExportSpecifier(sp)) { @@ -591,7 +575,7 @@ export class StartCompiler { const exported = t.isIdentifier(sp.exported) ? sp.exported.name : sp.exported.value - exports.set(exported, { tag: 'Normal', name: local }) + exports.set(exported, local) // When re-exporting from another module (export { foo } from './module'), // create an import binding so the server function can be resolved @@ -607,11 +591,11 @@ export class StartCompiler { } else if (t.isExportDefaultDeclaration(node)) { const d = node.declaration if (t.isIdentifier(d)) { - exports.set('default', { tag: 'Default', name: d.name }) + exports.set('default', d.name) } else { const synth = '__default_export__' bindings.set(synth, { type: 'var', init: d as t.Expression }) - exports.set('default', { tag: 'Default', name: synth }) + exports.set('default', synth) } } else if (t.isExportAllDeclaration(node)) { // Handle `export * from './module'` syntax @@ -691,10 +675,6 @@ export class StartCompiler { // JSX candidates (e.g., ) const jsxCandidatePaths: Array> = [] const checkJSX = needsJSXDetection(fileKinds) - // Get target component names from JSX setup (e.g., 'ClientOnly') - const jsxTargetComponentNames = checkJSX - ? getJSXComponentNames(fileKinds) - : null // Get module info that was just cached by ingestModule const moduleInfo = this.moduleCache.get(id)! @@ -801,10 +781,10 @@ export class StartCompiler { } }, // Pattern 3: JSX element pattern (e.g., ) - // Collect JSX elements where the component name matches a known import - // that resolves to a target component (e.g., ClientOnly from @tanstack/react-router) + // Collect JSX elements where the component is imported from a known package + // and resolves to a JSX kind (e.g., ClientOnly from @tanstack/react-router) JSXElement: (path) => { - if (!checkJSX || !jsxTargetComponentNames) return + if (!checkJSX) return const openingElement = path.node.openingElement const nameNode = openingElement.name @@ -815,13 +795,18 @@ export class StartCompiler { const componentName = nameNode.name const binding = moduleInfo.bindings.get(componentName) - // Must be an import binding + // Must be an import binding from a known package if (!binding || binding.type !== 'import') return - // Check if the original import name matches a target component - if (jsxTargetComponentNames.has(binding.importedName)) { - jsxCandidatePaths.push(path) - } + // Verify the import source is a known TanStack router package + const knownExports = this.knownRootImports.get(binding.source) + if (!knownExports) return + + // Verify the imported name resolves to a JSX kind (e.g., ClientOnlyJSX) + const kind = knownExports.get(binding.importedName) + if (kind !== 'ClientOnlyJSX') return + + jsxCandidatePaths.push(path) }, }) } @@ -951,25 +936,8 @@ export class StartCompiler { } // Handle JSX candidates (e.g., ) - // Note: We only reach here on the server (ClientOnlyJSX is only in LookupKindsPerEnv.server) - // Verify import source using knownRootImports (same as function call resolution) + // Validation was already done during traversal - just call the handler for (const jsxPath of jsxCandidatePaths) { - const openingElement = jsxPath.node.openingElement - const nameNode = openingElement.name - if (!t.isJSXIdentifier(nameNode)) continue - - const componentName = nameNode.name - const binding = moduleInfo.bindings.get(componentName) - if (!binding || binding.type !== 'import') continue - - // Verify the import source is a known TanStack router package - const knownExports = this.knownRootImports.get(binding.source) - if (!knownExports) continue - - // Verify the imported name resolves to ClientOnlyJSX kind - const kind = knownExports.get(binding.importedName) - if (kind !== 'ClientOnlyJSX') continue - handleClientOnlyJSX(jsxPath, { env: 'server' }) } @@ -1039,9 +1007,9 @@ export class StartCompiler { visitedModules.add(moduleInfo.id) // First check direct exports - const directExport = moduleInfo.exports.get(exportName) - if (directExport) { - const binding = moduleInfo.bindings.get(directExport.name) + const localBindingName = moduleInfo.exports.get(exportName) + if (localBindingName) { + const binding = moduleInfo.bindings.get(localBindingName) if (binding) { const result = { moduleInfo, binding } // Cache the result (build mode only) @@ -1305,11 +1273,12 @@ export class StartCompiler { ) if (targetModuleId) { const targetModule = await this.getModuleInfo(targetModuleId) - const exportEntry = targetModule.exports.get(callee.property.name) - if (exportEntry) { - const exportedBinding = targetModule.bindings.get( - exportEntry.name, - ) + const localBindingName = targetModule.exports.get( + callee.property.name, + ) + if (localBindingName) { + const exportedBinding = + targetModule.bindings.get(localBindingName) if (exportedBinding) { return await this.resolveBindingKind( exportedBinding, diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx index f91fe2854ca..b35c36ab1b3 100644 --- a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx @@ -1,9 +1 @@ -// This should NOT be transformed because ClientOnly is not from @tanstack/*-router -import { ClientOnly } from 'some-other-package'; -export function MyComponent() { - return
- Loading...
}> -
This should remain as-is
-
- ; -} \ No newline at end of file +no-transform \ No newline at end of file From c7a8a75698c3ecfa5d1a0db5b4ebd1dee066ab41 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 25 Dec 2025 19:05:05 +0100 Subject: [PATCH 3/3] formatting --- .../snapshots/client/call-at-module-level.ts | 4 ++-- .../snapshots/server/call-at-module-level.ts | 4 ++-- .../test-files/call-at-module-level.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts index 5591995c329..efc23cf31bf 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/call-at-module-level.ts @@ -1,5 +1,5 @@ const getEnvironment = () => { - console.log("[CLIENT] getEnvironment called"); - return "client"; + console.log('[CLIENT] getEnvironment called'); + return 'client'; }; const moduleLevel = getEnvironment(); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts index 9747f2db029..2fa99b56866 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/call-at-module-level.ts @@ -1,5 +1,5 @@ const getEnvironment = () => { - console.log("[SERVER] getEnvironment called"); - return "server"; + console.log('[SERVER] getEnvironment called'); + return 'server'; }; const moduleLevel = getEnvironment(); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts index dc0835eff84..ee017d7f593 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts +++ b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/call-at-module-level.ts @@ -2,12 +2,12 @@ import { createIsomorphicFn } from '@tanstack/react-start' const getEnvironment = createIsomorphicFn() .server(() => { - console.log("[SERVER] getEnvironment called"); - return "server"; + console.log('[SERVER] getEnvironment called') + return 'server' }) .client(() => { - console.log("[CLIENT] getEnvironment called"); - return "client"; - }); + console.log('[CLIENT] getEnvironment called') + return 'client' + }) -const moduleLevel = getEnvironment(); \ No newline at end of file +const moduleLevel = getEnvironment()