diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 19c499b986d..31543a1040d 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' import { Route as UsersRouteImport } from './routes/users' +import { Route as TypeOnlyReexportRouteImport } from './routes/type-only-reexport' import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' import { Route as PostsRouteImport } from './routes/posts' @@ -63,6 +64,11 @@ const UsersRoute = UsersRouteImport.update({ path: '/users', getParentRoute: () => rootRouteImport, } as any) +const TypeOnlyReexportRoute = TypeOnlyReexportRouteImport.update({ + id: '/type-only-reexport', + path: '/type-only-reexport', + getParentRoute: () => rootRouteImport, +} as any) const StreamRoute = StreamRouteImport.update({ id: '/stream', path: '/stream', @@ -281,6 +287,7 @@ export interface FileRoutesByFullPath { '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute + '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren @@ -320,6 +327,7 @@ export interface FileRoutesByTo { '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute + '/type-only-reexport': typeof TypeOnlyReexportRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -361,6 +369,7 @@ export interface FileRoutesById { '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute + '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren @@ -406,6 +415,7 @@ export interface FileRouteTypes { | '/posts' | '/scripts' | '/stream' + | '/type-only-reexport' | '/users' | '/대한민국' | '/api/users' @@ -445,6 +455,7 @@ export interface FileRouteTypes { | '/links' | '/scripts' | '/stream' + | '/type-only-reexport' | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' @@ -485,6 +496,7 @@ export interface FileRouteTypes { | '/posts' | '/scripts' | '/stream' + | '/type-only-reexport' | '/users' | '/대한민국' | '/_layout/_layout-2' @@ -530,6 +542,7 @@ export interface RootRouteChildren { PostsRoute: typeof PostsRouteWithChildren ScriptsRoute: typeof ScriptsRoute StreamRoute: typeof StreamRoute + TypeOnlyReexportRoute: typeof TypeOnlyReexportRoute UsersRoute: typeof UsersRouteWithChildren Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route ApiUsersRoute: typeof ApiUsersRouteWithChildren @@ -557,6 +570,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UsersRouteImport parentRoute: typeof rootRouteImport } + '/type-only-reexport': { + id: '/type-only-reexport' + path: '/type-only-reexport' + fullPath: '/type-only-reexport' + preLoaderRoute: typeof TypeOnlyReexportRouteImport + parentRoute: typeof rootRouteImport + } '/stream': { id: '/stream' path: '/stream' @@ -982,6 +1002,7 @@ const rootRouteChildren: RootRouteChildren = { PostsRoute: PostsRouteWithChildren, ScriptsRoute: ScriptsRoute, StreamRoute: StreamRoute, + TypeOnlyReexportRoute: TypeOnlyReexportRoute, UsersRoute: UsersRouteWithChildren, Char45824Char54620Char48124Char44397Route: Char45824Char54620Char48124Char44397Route, @@ -996,12 +1017,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/e2e/react-start/basic/src/routes/type-only-reexport.tsx b/e2e/react-start/basic/src/routes/type-only-reexport.tsx new file mode 100644 index 00000000000..7d5eecdd1fe --- /dev/null +++ b/e2e/react-start/basic/src/routes/type-only-reexport.tsx @@ -0,0 +1,42 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { loggingMiddleware } from '~/shared-lib' + +/** + * This route tests that the compiler can handle re-exports through type-only modules. + * + * The loggingMiddleware is imported from ~/shared-lib, which re-exports from: + * - ./middleware (has runtime code) + * - ./types (has ONLY type exports - compiles to empty JS) + * + * If the compiler doesn't handle empty modules correctly, the build will fail with: + * "could not load module .../types/actions.ts" + */ + +const getMessage = createServerFn() + .middleware([loggingMiddleware]) + .handler(async () => { + return 'Hello from server with type-only module re-exports!' + }) + +export const Route = createFileRoute('/type-only-reexport')({ + component: TypeOnlyReexportPage, + loader: async () => { + const message = await getMessage() + return { message } + }, +}) + +function TypeOnlyReexportPage() { + const { message } = Route.useLoaderData() + return ( +
+

Type-Only Re-export Test

+

{message}

+

+ This page tests that the compiler can handle barrel files that re-export + from type-only modules (which compile to empty JavaScript). +

+
+ ) +} diff --git a/e2e/react-start/basic/src/shared-lib/index.ts b/e2e/react-start/basic/src/shared-lib/index.ts new file mode 100644 index 00000000000..8b306430395 --- /dev/null +++ b/e2e/react-start/basic/src/shared-lib/index.ts @@ -0,0 +1,12 @@ +/** + * Library index - re-exports from middleware and types. + * + * This barrel file re-exports from both: + * 1. ./middleware - has runtime code (createMiddleware) + * 2. ./types - has ONLY type exports (compiles to empty JS) + * + * The compiler must handle the type-only module gracefully when + * tracing exports through this barrel file. + */ +export * from './middleware' +export * from './typedefs' diff --git a/e2e/react-start/basic/src/shared-lib/middleware.ts b/e2e/react-start/basic/src/shared-lib/middleware.ts new file mode 100644 index 00000000000..a6e8f6bbbc4 --- /dev/null +++ b/e2e/react-start/basic/src/shared-lib/middleware.ts @@ -0,0 +1,10 @@ +/** + * Middleware that logs server function calls. + * This is exported through a barrel file that also re-exports type-only modules. + */ +import { createMiddleware } from '@tanstack/react-start' + +export const loggingMiddleware = createMiddleware().server(({ next }) => { + console.log('[logging] Server function called') + return next() +}) diff --git a/e2e/react-start/basic/src/shared-lib/typedefs/actions.ts b/e2e/react-start/basic/src/shared-lib/typedefs/actions.ts new file mode 100644 index 00000000000..f6a20138142 --- /dev/null +++ b/e2e/react-start/basic/src/shared-lib/typedefs/actions.ts @@ -0,0 +1,11 @@ +/** + * This file contains ONLY type exports. + * After TypeScript compilation, this becomes an empty JavaScript module. + * This tests that the compiler can handle re-exports through type-only modules. + * + * See: https://github.com/TanStack/router/issues/6198 + */ + +// biome-ignore lint/suspicious/noExplicitAny: Generic server function type +export type Action = (...deps: any[]) => any +export type ActionParams = Parameters[0] diff --git a/e2e/react-start/basic/src/shared-lib/typedefs/index.ts b/e2e/react-start/basic/src/shared-lib/typedefs/index.ts new file mode 100644 index 00000000000..3af5a3dd6de --- /dev/null +++ b/e2e/react-start/basic/src/shared-lib/typedefs/index.ts @@ -0,0 +1,4 @@ +/** + * Types index - re-exports from type-only modules + */ +export * from './actions' diff --git a/e2e/react-start/basic/tests/type-only-reexport.spec.ts b/e2e/react-start/basic/tests/type-only-reexport.spec.ts new file mode 100644 index 00000000000..507d48852dd --- /dev/null +++ b/e2e/react-start/basic/tests/type-only-reexport.spec.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +/** + * These tests verify the compiler can handle type-only module re-exports. + * + * The scenario: + * 1. ~/shared-lib/index.ts re-exports from ./middleware and ./types + * 2. ~/shared-lib/types/index.ts re-exports from ./actions.ts + * 3. ~/shared-lib/types/actions.ts contains ONLY type exports (no runtime code) + * + * After TypeScript compilation, actions.ts becomes an empty JavaScript module. + * The compiler must handle this gracefully when tracing exports through + * barrel files. Without the fix, the build would fail with: + * "could not load module .../types/actions.ts" + * + * If these tests pass, it proves the compiler correctly handles empty modules + * when following re-export chains. + */ + +test('page using middleware from barrel with type-only re-exports builds and renders', async ({ + page, +}) => { + // Navigate to the route that uses middleware from ~/shared-lib + // If the compiler fix isn't working, this page wouldn't exist because + // the build would have failed + await page.goto('/type-only-reexport') + await page.waitForURL('/type-only-reexport') + + // The heading should be visible + await expect(page.getByTestId('type-only-heading')).toContainText( + 'Type-Only Re-export Test', + ) + + // The server function should have executed and returned data + await expect(page.getByTestId('message')).toContainText( + 'Hello from server with type-only module re-exports!', + ) +}) diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts index ca8afc1eb46..ee35e68849f 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts @@ -141,10 +141,11 @@ export function createServerFnPlugin(opts: { loadModule: async (id: string) => { if (this.environment.mode === 'build') { const loaded = await this.load({ id }) - if (!loaded.code) { - throw new Error(`could not load module ${id}`) - } - compiler!.ingestModule({ code: loaded.code, id }) + // Handle modules with no runtime code (e.g., type-only exports). + // After TypeScript compilation, these become empty modules. + // Create an empty module info instead of throwing. + const code = loaded.code ?? '' + compiler!.ingestModule({ code, id }) } else if (this.environment.mode === 'dev') { /** * in dev, vite does not return code from `ctx.load()` diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts index 14aac107532..2ca0bf0d606 100644 --- a/packages/start-plugin-core/tests/compiler.test.ts +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -391,3 +391,24 @@ describe('edge cases for detectedKinds', () => { expect(result!.code).toContain('server-impl') }) }) + +test('ingestModule handles empty code gracefully', () => { + const compiler = new ServerFnCompiler({ + env: 'client', + directive: 'use server', + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [], + loadModule: async () => {}, + resolveId: async (id) => id, + }) + + // Should not throw when ingesting empty module + expect(() => { + compiler.ingestModule({ code: '', id: 'empty-types.ts' }) + }).not.toThrow() + + // Should also handle whitespace-only modules + expect(() => { + compiler.ingestModule({ code: ' \n\t ', id: 'whitespace.ts' }) + }).not.toThrow() +})