From 0348283630e38f9ae9d0d3e2c799c17fa552f167 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 20 Dec 2025 23:49:02 +0100 Subject: [PATCH] fix: properly track re-exports of server function factories fixes #6029 --- .../routes/factory/-functions/functions.ts | 24 +++++++ .../factory/-functions/reexportIndex.ts | 2 + .../factory/-functions/reexportWrapper.ts | 12 ++++ .../factory/-functions/starReexportIndex.ts | 2 + .../factory/-functions/starReexportWrapper.ts | 14 +++++ .../src/routes/factory/index.tsx | 22 +++++++ .../tests/server-functions.spec.ts | 48 ++++++++++++++ .../src/create-server-fn-plugin/compiler.ts | 63 ++++++++++++++++++- 8 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 e2e/react-start/server-functions/src/routes/factory/-functions/reexportIndex.ts create mode 100644 e2e/react-start/server-functions/src/routes/factory/-functions/reexportWrapper.ts create mode 100644 e2e/react-start/server-functions/src/routes/factory/-functions/starReexportIndex.ts create mode 100644 e2e/react-start/server-functions/src/routes/factory/-functions/starReexportWrapper.ts diff --git a/e2e/react-start/server-functions/src/routes/factory/-functions/functions.ts b/e2e/react-start/server-functions/src/routes/factory/-functions/functions.ts index 35be5f91f7b..05dd8f46bd9 100644 --- a/e2e/react-start/server-functions/src/routes/factory/-functions/functions.ts +++ b/e2e/react-start/server-functions/src/routes/factory/-functions/functions.ts @@ -2,6 +2,10 @@ import { createMiddleware, createServerFn } from '@tanstack/react-start' import { createBarServerFn } from './createBarServerFn' import { createFooServerFn } from './createFooServerFn' import { createFakeFn } from './createFakeFn' +// Test re-export syntax: `export { foo } from './module'` +import { reexportFactory } from './reexportIndex' +// Test star re-export syntax: `export * from './module'` +import { starReexportFactory } from './starReexportIndex' export const fooFn = createFooServerFn().handler(({ context }) => { return { @@ -91,3 +95,23 @@ export const composedFn = composeFactory() context, } }) + +// Test that re-exported factories (using `export { foo } from './module'`) work correctly +// The middleware from reexportFactory should execute and add { reexport: 'reexport-middleware-executed' } to context +export const reexportedFactoryFn = reexportFactory().handler(({ context }) => { + return { + name: 'reexportedFactoryFn', + context, + } +}) + +// Test that star re-exported factories (using `export * from './module'`) work correctly +// The middleware from starReexportFactory should execute and add { starReexport: 'star-reexport-middleware-executed' } to context +export const starReexportedFactoryFn = starReexportFactory().handler( + ({ context }) => { + return { + name: 'starReexportedFactoryFn', + context, + } + }, +) diff --git a/e2e/react-start/server-functions/src/routes/factory/-functions/reexportIndex.ts b/e2e/react-start/server-functions/src/routes/factory/-functions/reexportIndex.ts new file mode 100644 index 00000000000..5dd2ab6cbbe --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/factory/-functions/reexportIndex.ts @@ -0,0 +1,2 @@ +// This file tests re-exporting a factory function from another module +export { reexportFactory } from './reexportWrapper' diff --git a/e2e/react-start/server-functions/src/routes/factory/-functions/reexportWrapper.ts b/e2e/react-start/server-functions/src/routes/factory/-functions/reexportWrapper.ts new file mode 100644 index 00000000000..ebc3ef14d05 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/factory/-functions/reexportWrapper.ts @@ -0,0 +1,12 @@ +import { createMiddleware, createServerFn } from '@tanstack/react-start' + +const reexportMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + console.log('reexport middleware triggered') + return next({ + context: { reexport: 'reexport-middleware-executed' } as const, + }) + }, +) + +export const reexportFactory = createServerFn().middleware([reexportMiddleware]) diff --git a/e2e/react-start/server-functions/src/routes/factory/-functions/starReexportIndex.ts b/e2e/react-start/server-functions/src/routes/factory/-functions/starReexportIndex.ts new file mode 100644 index 00000000000..7b49fb14bb7 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/factory/-functions/starReexportIndex.ts @@ -0,0 +1,2 @@ +// This file tests re-exporting a factory function from another module using the star syntax +export * from './starReexportWrapper' diff --git a/e2e/react-start/server-functions/src/routes/factory/-functions/starReexportWrapper.ts b/e2e/react-start/server-functions/src/routes/factory/-functions/starReexportWrapper.ts new file mode 100644 index 00000000000..f9e90681f1d --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/factory/-functions/starReexportWrapper.ts @@ -0,0 +1,14 @@ +import { createMiddleware, createServerFn } from '@tanstack/react-start' + +const starReexportMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + console.log('star reexport middleware triggered') + return next({ + context: { starReexport: 'star-reexport-middleware-executed' } as const, + }) + }, +) + +export const starReexportFactory = createServerFn().middleware([ + starReexportMiddleware, +]) diff --git a/e2e/react-start/server-functions/src/routes/factory/index.tsx b/e2e/react-start/server-functions/src/routes/factory/index.tsx index 2186aae9ff2..8be82b597fb 100644 --- a/e2e/react-start/server-functions/src/routes/factory/index.tsx +++ b/e2e/react-start/server-functions/src/routes/factory/index.tsx @@ -12,6 +12,8 @@ import { fooFnPOST, localFn, localFnPOST, + reexportedFactoryFn, + starReexportedFactoryFn, } from './-functions/functions' export const Route = createFileRoute('/factory/')({ @@ -130,6 +132,26 @@ const functions = { window, }, }, + // Test that re-exported factories (using `export { foo } from './module'`) work correctly + // The middleware from reexportFactory should execute and add { reexport: 'reexport-middleware-executed' } to context + reexportedFactoryFn: { + fn: reexportedFactoryFn, + type: 'serverFn', + expected: { + name: 'reexportedFactoryFn', + context: { reexport: 'reexport-middleware-executed' }, + }, + }, + // Test that star re-exported factories (using `export * from './module'`) work correctly + // The middleware from starReexportFactory should execute and add { starReexport: 'star-reexport-middleware-executed' } to context + starReexportedFactoryFn: { + fn: starReexportedFactoryFn, + type: 'serverFn', + expected: { + name: 'starReexportedFactoryFn', + context: { starReexport: 'star-reexport-middleware-executed' }, + }, + }, } satisfies Record interface TestCase { diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 208ef2519bc..0b56c3dbc8d 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -543,3 +543,51 @@ test('redirect in server function called in query during SSR', async ({ await expect(page.getByTestId('redirect-target-ssr')).toBeVisible() expect(page.url()).toContain('/redirect-test-ssr/target') }) + +test('re-exported server function factory middleware executes correctly', async ({ + page, +}) => { + // This test specifically verifies that when a server function factory is re-exported + // using `export { foo } from './module'` syntax, the middleware still executes. + // Previously, this syntax caused middleware to be silently skipped. + await page.goto('/factory') + + await expect(page.getByTestId('factory-route-component')).toBeInViewport() + + // Click the button for the re-exported factory function + await page.getByTestId('btn-fn-reexportedFactoryFn').click() + + // Wait for the result + await expect(page.getByTestId('fn-result-reexportedFactoryFn')).toContainText( + 'reexport-middleware-executed', + ) + + // Verify the full context was returned (middleware executed) + await expect( + page.getByTestId('fn-comparison-reexportedFactoryFn'), + ).toContainText('equal') +}) + +test('star re-exported server function factory middleware executes correctly', async ({ + page, +}) => { + // This test specifically verifies that when a server function factory is re-exported + // using `export * from './module'` syntax, the middleware still executes. + // Previously, this syntax caused middleware to be silently skipped. + await page.goto('/factory') + + await expect(page.getByTestId('factory-route-component')).toBeInViewport() + + // Click the button for the star re-exported factory function + await page.getByTestId('btn-fn-starReexportedFactoryFn').click() + + // Wait for the result + await expect( + page.getByTestId('fn-result-starReexportedFactoryFn'), + ).toContainText('star-reexport-middleware-executed') + + // Verify the full context was returned (middleware executed) + await expect( + page.getByTestId('fn-comparison-starReexportedFactoryFn'), + ).toContainText('equal') +}) diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts index 12496bbdc89..84212206eaf 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts @@ -51,6 +51,8 @@ interface ModuleInfo { ast: ReturnType bindings: Map exports: Map + // Track `export * from './module'` declarations for re-export resolution + reExportAllSources: Array } export class ServerFnCompiler { @@ -86,6 +88,7 @@ export class ServerFnCompiler { exports: new Map(), code: '', id: libId, + reExportAllSources: [], } this.moduleCache.set(libId, rootModule) } @@ -116,6 +119,7 @@ export class ServerFnCompiler { const bindings = new Map() const exports = new Map() + const reExportAllSources: Array = [] // we are only interested in top-level bindings, hence we don't traverse the AST // instead we only iterate over the program body @@ -178,6 +182,16 @@ export class ServerFnCompiler { ? sp.exported.name : sp.exported.value exports.set(exported, { tag: 'Normal', name: local }) + + // When re-exporting from another module (export { foo } from './module'), + // create an import binding so the server function can be resolved + if (node.source) { + bindings.set(local, { + type: 'import', + source: node.source.value, + importedName: local, + }) + } } } } else if (t.isExportDefaultDeclaration(node)) { @@ -189,10 +203,21 @@ export class ServerFnCompiler { bindings.set(synth, { type: 'var', init: d as t.Expression }) exports.set('default', { tag: 'Default', name: synth }) } + } else if (t.isExportAllDeclaration(node)) { + // Handle `export * from './module'` syntax + // Track the source so we can look up exports from it when needed + reExportAllSources.push(node.source.value) } } - const info: ModuleInfo = { code, id, ast, bindings, exports } + const info: ModuleInfo = { + code, + id, + ast, + bindings, + exports, + reExportAllSources, + } this.moduleCache.set(id, info) return info } @@ -343,7 +368,43 @@ export class ServerFnCompiler { const importedModule = await this.getModuleInfo(target) + // Try to find the export in the module's direct exports const moduleExport = importedModule.exports.get(binding.importedName) + + // If not found directly, check re-export-all sources (`export * from './module'`) + if (!moduleExport && importedModule.reExportAllSources.length > 0) { + for (const reExportSource of importedModule.reExportAllSources) { + const reExportTarget = await this.options.resolveId( + reExportSource, + importedModule.id, + ) + if (reExportTarget) { + const reExportModule = await this.getModuleInfo(reExportTarget) + const reExportEntry = reExportModule.exports.get( + binding.importedName, + ) + if (reExportEntry) { + // Found the export in a re-exported module, resolve from there + const reExportBinding = reExportModule.bindings.get( + reExportEntry.name, + ) + if (reExportBinding) { + if (reExportBinding.resolvedKind) { + return reExportBinding.resolvedKind + } + const resolvedKind = await this.resolveBindingKind( + reExportBinding, + reExportModule.id, + visited, + ) + reExportBinding.resolvedKind = resolvedKind + return resolvedKind + } + } + } + } + } + if (!moduleExport) { return 'None' }