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..ef71c4f6c29 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 @@ -76,7 +76,7 @@ export const localFnPOST = localFnFactory({ method: 'POST' }) export const fakeFn = createFakeFn().handler(async () => { return { name: 'fakeFn', - window, + window: typeof window !== 'undefined' ? window : 'no window object', } }) 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..582e2ab4b82 100644 --- a/e2e/react-start/server-functions/src/routes/factory/index.tsx +++ b/e2e/react-start/server-functions/src/routes/factory/index.tsx @@ -127,7 +127,7 @@ const functions = { type: 'localFn', expected: { name: 'fakeFn', - window, + window: typeof window !== 'undefined' ? window : 'no window object', }, }, } satisfies Record diff --git a/e2e/react-start/split-exports/.gitignore b/e2e/react-start/split-exports/.gitignore new file mode 100644 index 00000000000..1cd91e546be --- /dev/null +++ b/e2e/react-start/split-exports/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.output +.vite +*.local diff --git a/e2e/react-start/split-exports/.prettierignore b/e2e/react-start/split-exports/.prettierignore new file mode 100644 index 00000000000..378756846d3 --- /dev/null +++ b/e2e/react-start/split-exports/.prettierignore @@ -0,0 +1,2 @@ +pnpm-lock.yaml +routeTree.gen.ts diff --git a/e2e/react-start/split-exports/package.json b/e2e/react-start/split-exports/package.json new file mode 100644 index 00000000000..3f36351a4e0 --- /dev/null +++ b/e2e/react-start/split-exports/package.json @@ -0,0 +1,38 @@ +{ + "name": "tanstack-react-start-e2e-split-exports", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port ${PORT:-3000}", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:hmr": "rm -rf port*.txt; playwright test --config=playwright-hmr.config.ts" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "postcss": "^8.5.1", + "srvx": "^0.8.6", + "tailwindcss": "^4.1.17", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/split-exports/playwright-hmr.config.ts b/e2e/react-start/split-exports/playwright-hmr.config.ts new file mode 100644 index 00000000000..5c8df2a0f15 --- /dev/null +++ b/e2e/react-start/split-exports/playwright-hmr.config.ts @@ -0,0 +1,59 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +/** + * Playwright configuration for HMR tests. + * + * This config runs the dev server instead of a production build, + * allowing tests to verify Hot Module Replacement works correctly + * with the split-exports plugin. + * + * Run with: npx playwright test --config=playwright-hmr.config.ts + */ + +// Use a different port for HMR tests to avoid conflicts +const PORT = (await getTestServerPort(packageJson.name)) + 1 +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + // Only run HMR test files + testMatch: '*-hmr.spec.ts', + // HMR tests can be slower due to file system operations + timeout: 60000, + // Run serially to avoid file system conflicts + workers: 1, + reporter: [['line']], + + use: { + baseURL, + // Longer timeouts for HMR operations + actionTimeout: 10000, + navigationTimeout: 30000, + }, + + webServer: { + // Use dev server instead of build + command: `pnpm dev`, + url: baseURL, + // Always start fresh for HMR tests + reuseExistingServer: false, + stdout: 'pipe', + stderr: 'pipe', + // Dev server may take longer to start + timeout: 60000, + env: { + PORT: String(PORT), + }, + }, + + projects: [ + { + name: 'chromium-hmr', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}) diff --git a/e2e/react-start/split-exports/playwright.config.ts b/e2e/react-start/split-exports/playwright.config.ts new file mode 100644 index 00000000000..aa75d2d3500 --- /dev/null +++ b/e2e/react-start/split-exports/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + // Exclude HMR tests - they require dev server (use playwright-hmr.config.ts) + testIgnore: '*-hmr.spec.ts', + workers: 1, + reporter: [['line']], + + use: { + baseURL, + }, + + webServer: { + command: `pnpm build && pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + PORT: String(PORT), + }, + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}) diff --git a/e2e/react-start/split-exports/postcss.config.mjs b/e2e/react-start/split-exports/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/react-start/split-exports/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/react-start/split-exports/src/routeTree.gen.ts b/e2e/react-start/split-exports/src/routeTree.gen.ts new file mode 100644 index 00000000000..45c0a7f3e47 --- /dev/null +++ b/e2e/react-start/split-exports/src/routeTree.gen.ts @@ -0,0 +1,156 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ServerRequestImportRouteImport } from './routes/server-request-import' +import { Route as ReexportImportRouteImport } from './routes/reexport-import' +import { Route as DirectImportRouteImport } from './routes/direct-import' +import { Route as AliasImportRouteImport } from './routes/alias-import' +import { Route as IndexRouteImport } from './routes/index' + +const ServerRequestImportRoute = ServerRequestImportRouteImport.update({ + id: '/server-request-import', + path: '/server-request-import', + getParentRoute: () => rootRouteImport, +} as any) +const ReexportImportRoute = ReexportImportRouteImport.update({ + id: '/reexport-import', + path: '/reexport-import', + getParentRoute: () => rootRouteImport, +} as any) +const DirectImportRoute = DirectImportRouteImport.update({ + id: '/direct-import', + path: '/direct-import', + getParentRoute: () => rootRouteImport, +} as any) +const AliasImportRoute = AliasImportRouteImport.update({ + id: '/alias-import', + path: '/alias-import', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/alias-import': typeof AliasImportRoute + '/direct-import': typeof DirectImportRoute + '/reexport-import': typeof ReexportImportRoute + '/server-request-import': typeof ServerRequestImportRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/alias-import': typeof AliasImportRoute + '/direct-import': typeof DirectImportRoute + '/reexport-import': typeof ReexportImportRoute + '/server-request-import': typeof ServerRequestImportRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/alias-import': typeof AliasImportRoute + '/direct-import': typeof DirectImportRoute + '/reexport-import': typeof ReexportImportRoute + '/server-request-import': typeof ServerRequestImportRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/alias-import' + | '/direct-import' + | '/reexport-import' + | '/server-request-import' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/alias-import' + | '/direct-import' + | '/reexport-import' + | '/server-request-import' + id: + | '__root__' + | '/' + | '/alias-import' + | '/direct-import' + | '/reexport-import' + | '/server-request-import' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AliasImportRoute: typeof AliasImportRoute + DirectImportRoute: typeof DirectImportRoute + ReexportImportRoute: typeof ReexportImportRoute + ServerRequestImportRoute: typeof ServerRequestImportRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/server-request-import': { + id: '/server-request-import' + path: '/server-request-import' + fullPath: '/server-request-import' + preLoaderRoute: typeof ServerRequestImportRouteImport + parentRoute: typeof rootRouteImport + } + '/reexport-import': { + id: '/reexport-import' + path: '/reexport-import' + fullPath: '/reexport-import' + preLoaderRoute: typeof ReexportImportRouteImport + parentRoute: typeof rootRouteImport + } + '/direct-import': { + id: '/direct-import' + path: '/direct-import' + fullPath: '/direct-import' + preLoaderRoute: typeof DirectImportRouteImport + parentRoute: typeof rootRouteImport + } + '/alias-import': { + id: '/alias-import' + path: '/alias-import' + fullPath: '/alias-import' + preLoaderRoute: typeof AliasImportRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AliasImportRoute: AliasImportRoute, + DirectImportRoute: DirectImportRoute, + ReexportImportRoute: ReexportImportRoute, + ServerRequestImportRoute: ServerRequestImportRoute, +} +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/split-exports/src/router.tsx b/e2e/react-start/split-exports/src/router.tsx new file mode 100644 index 00000000000..16fd65460dd --- /dev/null +++ b/e2e/react-start/split-exports/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + }) + + return router +} diff --git a/e2e/react-start/split-exports/src/routes/__root.tsx b/e2e/react-start/split-exports/src/routes/__root.tsx new file mode 100644 index 00000000000..81aff73ef02 --- /dev/null +++ b/e2e/react-start/split-exports/src/routes/__root.tsx @@ -0,0 +1,62 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'Split Exports E2E Test', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
+ + Home + + + Direct Import + + + Re-export Import + + + Alias Import + +
+
+ + + + + ) +} diff --git a/e2e/react-start/split-exports/src/routes/alias-import.tsx b/e2e/react-start/split-exports/src/routes/alias-import.tsx new file mode 100644 index 00000000000..12bfbc69520 --- /dev/null +++ b/e2e/react-start/split-exports/src/routes/alias-import.tsx @@ -0,0 +1,171 @@ +/** + * Test: Import using TypeScript path aliases. + * + * This route uses the ~/ alias to import modules. + * Tests that the split-exports plugin correctly handles aliased imports. + * + * This test also covers the nested import scenario - importing from nested.ts + * which internally uses server-only code but only exposes isomorphic functions. + */ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' + +// Import using path alias from shared.ts +import { + getEnvironment, + getServerEnvironment, + getUserById, + formatUserName, + APP_NAME, +} from '~/utils/shared' + +// Import from nested module using path alias +// This tests the nested import scenario where nested.ts internally uses +// server-only code (getServerOnlyUserData) but only exposes isomorphic functions +import { + fetchUserProfile, + computeGreeting, + getServerGreeting, + GREETING_PREFIX, +} from '~/utils/nested' + +export const Route = createFileRoute('/alias-import')({ + component: AliasImportTest, + loader: async () => { + const envOnLoad = getEnvironment() + const greetingOnLoad = computeGreeting('SSR via Alias') + const user = await getUserById({ data: '100' }) + // Test fetchUserProfile from nested.ts (uses server-only code internally) + const profile = await fetchUserProfile({ data: '42' }) + return { envOnLoad, greetingOnLoad, user, profile } + }, +}) + +function AliasImportTest() { + const { envOnLoad, greetingOnLoad, user, profile } = Route.useLoaderData() + const [results, setResults] = useState<{ + envOnClick?: string + greetingOnClick?: string + serverEnv?: string + serverGreeting?: string + serverProfile?: { + id: string + displayName: string + contact: string + appName: string + } + } | null>(null) + + async function handleClick() { + const envOnClick = getEnvironment() + const greetingOnClick = computeGreeting('Client via Alias') + const [serverEnv, serverGreeting, serverProfile] = await Promise.all([ + getServerEnvironment(), + getServerGreeting({ data: 'Server via Alias' }), + fetchUserProfile({ data: '99' }), + ]) + setResults({ + envOnClick, + greetingOnClick, + serverEnv, + serverGreeting, + serverProfile, + }) + } + + return ( +
+

Alias Import Test

+

+ Testing imports using TypeScript path aliases (~/utils/...). Also tests + nested imports where the module internally uses server-only code. +

+ +
+

+ Constants (from aliased imports): +

+
+ App Name:
{APP_NAME}
+
+
+ Greeting Prefix:{' '} +
{GREETING_PREFIX}
+
+
+ +
+

+ Format User Name (pure function): +

+
+          {formatUserName('Jane', 'Smith')}
+        
+
+ +
+

SSR Results:

+
+ Environment on load: +
{JSON.stringify(envOnLoad)}
+
+
+ Greeting on load: +
{JSON.stringify(greetingOnLoad)}
+
+
+ User from server: +
{JSON.stringify(user)}
+
+
+ Profile from nested module (uses server-only code internally): +
{JSON.stringify(profile)}
+
+
+ + + + {results && ( +
+

Client Results:

+
+ Environment on click: +
+              {JSON.stringify(results.envOnClick)}
+            
+
+
+ Greeting on click: +
+              {JSON.stringify(results.greetingOnClick)}
+            
+
+
+ Server environment: +
+              {JSON.stringify(results.serverEnv)}
+            
+
+
+ Server greeting: +
+              {JSON.stringify(results.serverGreeting)}
+            
+
+
+ Server profile (from nested module): +
+              {JSON.stringify(results.serverProfile)}
+            
+
+
+ )} +
+ ) +} diff --git a/e2e/react-start/split-exports/src/routes/direct-import.tsx b/e2e/react-start/split-exports/src/routes/direct-import.tsx new file mode 100644 index 00000000000..30407e732de --- /dev/null +++ b/e2e/react-start/split-exports/src/routes/direct-import.tsx @@ -0,0 +1,126 @@ +/** + * Test: Direct import from a module with mixed exports. + * + * This route imports isomorphic functions directly from shared.ts, + * which also exports server-only code that should NOT be bundled. + */ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' + +// Direct import - only importing the isomorphic exports +// Server-only exports (getServerOnlyDatabase, getServerOnlyUserData, serverOnlyConfig) +// should be eliminated from the client bundle by the split-exports plugin +import { + getEnvironment, + getServerEnvironment, + formatMessage, + getUserById, + formatUserName, + APP_NAME, +} from '../utils/shared' + +export const Route = createFileRoute('/direct-import')({ + component: DirectImportTest, + loader: async () => { + // Call isomorphic function during SSR - should return 'server' + const envOnLoad = getEnvironment() + // Call server function during SSR + const user = await getUserById({ data: '1' }) + return { envOnLoad, user } + }, +}) + +function DirectImportTest() { + const { envOnLoad, user } = Route.useLoaderData() + const [results, setResults] = useState<{ + envOnClick?: string + messageOnClick?: string + serverEnv?: string + serverUser?: { id: string; name: string; email: string } + } | null>(null) + + async function handleClick() { + // Call isomorphic function on client - should return 'client' + const envOnClick = getEnvironment() + // Call isomorphic function on client + const messageOnClick = formatMessage('Hello World') + // Call server functions from client + const [serverEnv, serverUser] = await Promise.all([ + getServerEnvironment(), + getUserById({ data: '2' }), + ]) + setResults({ envOnClick, messageOnClick, serverEnv, serverUser }) + } + + return ( +
+

Direct Import Test

+

+ Testing direct imports from a module with mixed server-only and + isomorphic exports. +

+ +
+

App Name (constant):

+
{APP_NAME}
+
+ +
+

+ Format User Name (pure function): +

+
{formatUserName('John', 'Doe')}
+
+ +
+

SSR Results:

+
+ Environment on load (should be "server"): +
{JSON.stringify(envOnLoad)}
+
+
+ User loaded during SSR: +
{JSON.stringify(user)}
+
+
+ + + + {results && ( +
+

Client Results:

+
+ Environment on click (should be "client"): +
+              {JSON.stringify(results.envOnClick)}
+            
+
+
+ Message formatted on client: +
+              {JSON.stringify(results.messageOnClick)}
+            
+
+
+ Server environment (via server function): +
+              {JSON.stringify(results.serverEnv)}
+            
+
+
+ User from server function: +
+              {JSON.stringify(results.serverUser)}
+            
+
+
+ )} +
+ ) +} diff --git a/e2e/react-start/split-exports/src/routes/index.tsx b/e2e/react-start/split-exports/src/routes/index.tsx new file mode 100644 index 00000000000..30550630345 --- /dev/null +++ b/e2e/react-start/split-exports/src/routes/index.tsx @@ -0,0 +1,38 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

+ Split Exports Plugin E2E Tests +

+

+ This e2e test verifies that the split-exports plugin correctly handles + modules that export both server-only and isomorphic code. +

+

Test Scenarios:

+
    +
  • + Direct Import: Import isomorphic functions directly + from a module that also exports server-only code +
  • +
  • + Re-export Import: Import from a module that + re-exports isomorphic functions from another module +
  • +
  • + Nested Import: Import from a module that internally + uses server-only code but only exposes isomorphic functions +
  • +
  • + Alias Import: Import using TypeScript path aliases + (~/utils/...) +
  • +
+
+ ) +} diff --git a/e2e/react-start/split-exports/src/routes/reexport-import.tsx b/e2e/react-start/split-exports/src/routes/reexport-import.tsx new file mode 100644 index 00000000000..4b2a7702434 --- /dev/null +++ b/e2e/react-start/split-exports/src/routes/reexport-import.tsx @@ -0,0 +1,100 @@ +/** + * Test: Import from a module that re-exports. + * + * This route imports from public-api.ts, which re-exports + * isomorphic functions from shared.ts. + */ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' + +// Import from the re-export module +import { + getEnv, // renamed re-export + getServerEnvironment, + formatMessage, + getAllUsers, + APP_NAME, +} from '../utils/public-api' + +export const Route = createFileRoute('/reexport-import')({ + component: ReexportImportTest, + loader: async () => { + const envOnLoad = getEnv() + const users = await getAllUsers() + return { envOnLoad, users } + }, +}) + +function ReexportImportTest() { + const { envOnLoad, users } = Route.useLoaderData() + const [results, setResults] = useState<{ + envOnClick?: string + messageOnClick?: string + serverEnv?: string + } | null>(null) + + async function handleClick() { + const envOnClick = getEnv() + const messageOnClick = formatMessage('Re-export test') + const serverEnv = await getServerEnvironment() + setResults({ envOnClick, messageOnClick, serverEnv }) + } + + return ( +
+

Re-export Import Test

+

+ Testing imports from a module that re-exports isomorphic functions. +

+ +
+

App Name:

+
{APP_NAME}
+
+ +
+

SSR Results:

+
+ Environment on load: +
{JSON.stringify(envOnLoad)}
+
+
+ All users from server: +
{JSON.stringify(users)}
+
+
+ + + + {results && ( +
+

Client Results:

+
+ Environment on click: +
+              {JSON.stringify(results.envOnClick)}
+            
+
+
+ Message formatted: +
+              {JSON.stringify(results.messageOnClick)}
+            
+
+
+ Server environment: +
+              {JSON.stringify(results.serverEnv)}
+            
+
+
+ )} +
+ ) +} diff --git a/e2e/react-start/split-exports/src/routes/server-request-import.tsx b/e2e/react-start/split-exports/src/routes/server-request-import.tsx new file mode 100644 index 00000000000..fe5feeaecd7 --- /dev/null +++ b/e2e/react-start/split-exports/src/routes/server-request-import.tsx @@ -0,0 +1,146 @@ +/** + * Test: Nested server-only imports through createServerFn + * + * This route tests the following chain: + * + * 1. This route imports `getRequestInfoServerFn` (a createServerFn) from + * '../utils/server-request' + * + * 2. Inside server-request.ts, the createServerFn handler calls an internal + * function `getRequestInfo()` which uses: + * `import { getRequest } from '@tanstack/react-start/server'` + * + * 3. `getRequest` is a server-only API that cannot run on the client + * + * The split-exports plugin must ensure that when this route only imports + * the isomorphic `getRequestInfoServerFn`, the server-only `getRequest` + * import (and any other server-only exports) are eliminated from the + * client bundle through dead code elimination. + * + * This validates that import rewriting works correctly through multiple + * levels of the module graph. + */ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' + +// Import only the isomorphic server function - the server-only exports +// (getServerOnlyRequestHeaders, SERVER_REQUEST_MARKER) should be eliminated +import { + getRequestInfoServerFn, + echoWithRequestInfo, +} from '../utils/server-request' + +export const Route = createFileRoute('/server-request-import')({ + component: ServerRequestImportTest, + loader: async () => { + // Call the server function as a loader + // This should return method: "GET" because loaders use GET requests + const requestInfo = await getRequestInfoServerFn() + return { requestInfo } + }, +}) + +function ServerRequestImportTest() { + const { requestInfo } = Route.useLoaderData() + const [clientResults, setClientResults] = useState<{ + requestInfo?: { method: string; pathname: string; executedOn: string } + echoResult?: { echo: string; method: string; executedOn: string } + } | null>(null) + + async function handleClick() { + // Call server functions from client + // These will be POST requests since they're called from client-side code + const [requestInfo, echoResult] = await Promise.all([ + getRequestInfoServerFn(), + echoWithRequestInfo({ data: { message: 'Hello from client!' } }), + ]) + setClientResults({ requestInfo, echoResult }) + } + + return ( +
+

Server Request Import Test

+

+ Testing that imports inside files with ?tss-split-exports query are + properly rewritten. This verifies that server-only imports (like + getRequest from @tanstack/react-start/server) are eliminated from client + bundles. +

+ +
+

SSR/Loader Results:

+
+
+ Request Method (should be GET): +
+              {requestInfo.method}
+            
+
+
+ Executed On: +
+              {requestInfo.executedOn}
+            
+
+
+ Full Result: +
+              {JSON.stringify(requestInfo, null, 2)}
+            
+
+
+
+ + + + {clientResults && ( +
+

Client Results:

+
+
+

getRequestInfoServerFn result:

+
+
+ Method (should be GET): +
+                    {clientResults.requestInfo?.method}
+                  
+
+
+ Executed On: +
+                    {clientResults.requestInfo?.executedOn}
+                  
+
+
+
+ +
+

echoWithRequestInfo result:

+
+
+ Echo: +
+                    {clientResults.echoResult?.echo}
+                  
+
+
+ Method: +
+                    {clientResults.echoResult?.method}
+                  
+
+
+
+
+
+ )} +
+ ) +} diff --git a/e2e/react-start/split-exports/src/styles/app.css b/e2e/react-start/split-exports/src/styles/app.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-start/split-exports/src/styles/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-start/split-exports/src/utils/nested.ts b/e2e/react-start/split-exports/src/utils/nested.ts new file mode 100644 index 00000000000..cd1b233b2a4 --- /dev/null +++ b/e2e/react-start/split-exports/src/utils/nested.ts @@ -0,0 +1,62 @@ +/** + * This module tests nested imports scenario. + * It imports from shared.ts and uses both isomorphic and server-only code internally, + * but only exposes isomorphic functions and server functions defined in THIS module. + */ + +import { createServerFn, createIsomorphicFn } from '@tanstack/react-start' +import { getServerOnlyUserData, formatUserName, APP_NAME } from './shared' + +// ============================================================ +// INTERNAL SERVER-ONLY USAGE +// ============================================================ + +/** + * This server function uses the server-only getServerOnlyUserData internally. + * The client can call this server function, but the server-only code + * only runs on the server. + */ +export const fetchUserProfile = createServerFn() + .inputValidator((userId: string) => userId) + .handler(async ({ data: userId }) => { + // This uses the server-only function internally + const userData = getServerOnlyUserData(userId) + + // Transform and return safe data + return { + id: userData.id, + displayName: userData.name, + contact: userData.email, + appName: APP_NAME, + } + }) + +// ============================================================ +// ISOMORPHIC EXPORTS +// ============================================================ + +/** + * Isomorphic function that computes on both sides. + */ +export const computeGreeting = createIsomorphicFn() + .server((name: string) => `Hello from the server, ${name}!`) + .client((name: string) => `Hello from the client, ${name}!`) + +/** + * Server function to get greeting from server. + */ +export const getServerGreeting = createServerFn() + .inputValidator((name: string) => name) + .handler(async ({ data: name }) => { + return computeGreeting(name) + }) + +/** + * Re-export the pure function for use in components. + */ +export { formatUserName } + +/** + * A computed constant. + */ +export const GREETING_PREFIX = 'Welcome to ' + APP_NAME diff --git a/e2e/react-start/split-exports/src/utils/public-api.ts b/e2e/react-start/split-exports/src/utils/public-api.ts new file mode 100644 index 00000000000..1424cec508d --- /dev/null +++ b/e2e/react-start/split-exports/src/utils/public-api.ts @@ -0,0 +1,24 @@ +/** + * This module re-exports from shared.ts + * Tests that the split-exports plugin handles re-exports correctly. + */ + +// Re-export isomorphic functions (createIsomorphicFn based) +export { + getEnvironment, + formatMessage, + formatUserName, + APP_NAME, +} from './shared' + +// Re-export server functions (createServerFn based - also isomorphic) +export { getServerEnvironment, getUserById, getAllUsers } from './shared' + +// Also re-export with rename +export { getEnvironment as getEnv } from './shared' + +// Note: We intentionally do NOT re-export the server-only exports: +// - getServerOnlyUserData +// - getServerOnlyDatabase +// - serverOnlyConfig +// This simulates a "public API" module that only exposes safe exports. diff --git a/e2e/react-start/split-exports/src/utils/server-request.ts b/e2e/react-start/split-exports/src/utils/server-request.ts new file mode 100644 index 00000000000..ee1dbb980fc --- /dev/null +++ b/e2e/react-start/split-exports/src/utils/server-request.ts @@ -0,0 +1,98 @@ +/** + * Test: Module with createServerFn that internally uses server-only imports + * + * This module demonstrates the following pattern: + * + * 1. Exports isomorphic server functions (createServerFn) that are safe to + * import on the client - the compiler transforms them to fetch calls + * + * 2. Internally uses `import { getRequest } from '@tanstack/react-start/server'` + * which is a server-only API + * + * 3. The server-only import is used inside the createServerFn handler, which + * only runs on the server + * + * When a route imports only the isomorphic exports from this module, the + * split-exports plugin should rewrite the import to eliminate the server-only + * `getRequest` import from the client bundle through dead code elimination. + */ + +import { createServerFn } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' + +// ============================================================ +// SERVER-ONLY CODE - Uses getRequest which is server-only +// This should NOT be bundled into the client +// ============================================================ + +/** + * Internal server-only function that extracts info from the current request. + * This uses `getRequest()` from @tanstack/react-start/server which is + * server-only and would throw if called on the client. + */ +function getRequestInfo() { + const request = getRequest() + return { + method: request.method, + url: request.url, + // Extract just the pathname for easier testing + pathname: new URL(request.url).pathname, + } +} + +/** + * Another server-only function that would break if bundled to client. + * This is exported to verify it gets eliminated from client bundles. + */ +export function getServerOnlyRequestHeaders() { + const request = getRequest() + return Object.fromEntries(request.headers.entries()) +} + +/** + * Server-only constant that should not leak to client. + */ +export const SERVER_REQUEST_MARKER = + 'This string should not appear in client bundles' + +// ============================================================ +// ISOMORPHIC CODE - Server Functions (createServerFn) +// These are safe to import on client - compiler transforms them +// ============================================================ + +/** + * Server function that returns information about the current request. + * This internally calls getRequestInfo() which uses the server-only + * getRequest() API. The handler runs on the server, so this is safe. + * + * When called as a loader: + * - method will be "GET" + * - url will contain the server function endpoint + * + * When called as a mutation/action: + * - method will be "POST" + */ +export const getRequestInfoServerFn = createServerFn().handler(() => { + const info = getRequestInfo() + return { + method: info.method, + pathname: info.pathname, + // Include a marker to verify this ran on the server + executedOn: 'server', + } +}) + +/** + * Server function that echoes back data along with request info. + * Tests that server functions with input validators work correctly. + */ +export const echoWithRequestInfo = createServerFn() + .inputValidator((data: { message: string }) => data) + .handler(({ data }) => { + const info = getRequestInfo() + return { + echo: data.message, + method: info.method, + executedOn: 'server', + } + }) diff --git a/e2e/react-start/split-exports/src/utils/shared.ts b/e2e/react-start/split-exports/src/utils/shared.ts new file mode 100644 index 00000000000..b1900f1f207 --- /dev/null +++ b/e2e/react-start/split-exports/src/utils/shared.ts @@ -0,0 +1,138 @@ +/** + * This module exports BOTH server-only code AND isomorphic code. + * The split-exports plugin should allow client code to import only + * the isomorphic exports without bundling the server-only code. + * + * Key test scenario: + * - createServerFn exports are ISOMORPHIC (compiler transforms to fetch on client) + * - createIsomorphicFn exports are ISOMORPHIC (explicit client/server implementations) + * - Plain functions using server-only APIs are SERVER-ONLY (should be eliminated) + */ + +import { createServerFn, createIsomorphicFn } from '@tanstack/react-start' + +// ============================================================ +// SERVER-ONLY CODE - This should NOT be bundled into the client +// ============================================================ + +/** + * Simulates a database connection that only exists on the server. + * If this code runs on the client, it would throw an error. + */ +export function getServerOnlyDatabase() { + // This would typically be something like: + // import { db } from './db' // which uses node:fs or similar + if (typeof window !== 'undefined') { + throw new Error('Database code should not run on the client!') + } + return { + users: { + findById: (id: string) => ({ + id, + name: `User ${id}`, + email: `user${id}@example.com`, + // Secret field that should never be on the client + passwordHash: 'secret-hash-should-not-leak', + }), + findAll: () => [ + { id: '1', name: 'Alice', email: 'alice@example.com' }, + { id: '2', name: 'Bob', email: 'bob@example.com' }, + ], + }, + } +} + +/** + * Server-only function that directly uses the database. + * This export should be eliminated from client bundles when not imported. + */ +export function getServerOnlyUserData(userId: string) { + const db = getServerOnlyDatabase() + return db.users.findById(userId) +} + +/** + * Another server-only export that would break if bundled to client. + */ +export const serverOnlyConfig = { + databaseUrl: 'postgresql://localhost:5432/mydb', + secretKey: 'super-secret-key-should-not-leak', + getDatabase: getServerOnlyDatabase, +} + +// ============================================================ +// ISOMORPHIC CODE - Server Functions (createServerFn) +// These are safe to import on client - compiler transforms them +// ============================================================ + +/** + * Server function to get the current environment. + * Always returns 'server' because the handler runs on the server. + * The client gets a fetch call instead. + */ +export const getServerEnvironment = createServerFn().handler(() => { + return 'server' +}) + +/** + * Server function to get a user by ID. + * Uses the server-only database internally - but that's fine because + * the handler only runs on the server. + */ +export const getUserById = createServerFn() + .inputValidator((userId: string) => userId) + .handler(async ({ data: userId }) => { + const userData = getServerOnlyUserData(userId) + // Return safe data (no password hash) + return { + id: userData.id, + name: userData.name, + email: userData.email, + } + }) + +/** + * Server function to get all users. + */ +export const getAllUsers = createServerFn().handler(async () => { + const db = getServerOnlyDatabase() + return db.users.findAll() +}) + +// ============================================================ +// ISOMORPHIC CODE - createIsomorphicFn +// These have explicit client/server implementations +// ============================================================ + +/** + * Isomorphic function that has different implementations on client/server. + * This is safe to import on the client. + */ +export const getEnvironment = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +/** + * Isomorphic function with parameters. + */ +export const formatMessage = createIsomorphicFn() + .server((message: string) => `[SERVER] ${message}`) + .client((message: string) => `[CLIENT] ${message}`) + +// ============================================================ +// ISOMORPHIC CODE - Pure utilities +// These work everywhere without transformation +// ============================================================ + +/** + * Pure isomorphic utility that doesn't need server/client split. + * Just a helper function that works everywhere. + */ +export function formatUserName(firstName: string, lastName: string): string { + return `${firstName} ${lastName}`.trim() +} + +/** + * A constant that's safe on both sides. + */ +export const APP_NAME = 'Split Exports Test App' diff --git a/e2e/react-start/split-exports/tests/split-exports-hmr.spec.ts b/e2e/react-start/split-exports/tests/split-exports-hmr.spec.ts new file mode 100644 index 00000000000..f0d0584a18e --- /dev/null +++ b/e2e/react-start/split-exports/tests/split-exports-hmr.spec.ts @@ -0,0 +1,281 @@ +import { expect, type Page } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Split Exports Plugin HMR Tests + * + * These tests verify that Hot Module Replacement works correctly with the + * split-exports plugin. When a source file changes, the correct modules + * should be invalidated and the page should update. + * + * NOTE: These tests run against the dev server, not a production build. + * See playwright-hmr.config.ts for the configuration. + * + * Test scenarios: + * 1. Modify shared utility file - all importers should update + * 2. Modify isomorphic function - client should see change + * 3. Modify server-only code - client should NOT be affected (key feature!) + * 4. Add/remove exports - affected importers should update + */ + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const UTILS_DIR = path.resolve(__dirname, '../src/utils') +const SHARED_FILE = path.join(UTILS_DIR, 'shared.ts') +const NESTED_FILE = path.join(UTILS_DIR, 'nested.ts') + +// Store original file contents for restoration +let originalSharedContent: string +let originalNestedContent: string + +test.beforeAll(async () => { + // Save original file contents + originalSharedContent = await fs.promises.readFile(SHARED_FILE, 'utf-8') + originalNestedContent = await fs.promises.readFile(NESTED_FILE, 'utf-8') +}) + +test.afterAll(async () => { + // Restore original file contents + await fs.promises.writeFile(SHARED_FILE, originalSharedContent) + await fs.promises.writeFile(NESTED_FILE, originalNestedContent) +}) + +test.afterEach(async () => { + // Restore files after each test to ensure clean state + await fs.promises.writeFile(SHARED_FILE, originalSharedContent) + await fs.promises.writeFile(NESTED_FILE, originalNestedContent) + // Small delay to ensure file system syncs + await new Promise((r) => setTimeout(r, 100)) +}) + +/** + * Helper to wait for HMR update to complete. + * Looks for the HMR status indicator or waits for network idle. + */ +async function waitForHmr(page: Page, timeout = 5000): Promise { + // Wait for any pending HMR connections + await page.waitForLoadState('networkidle', { timeout }) + // Additional small delay for HMR processing + await page.waitForTimeout(500) +} + +/** + * Helper to modify a file and wait for HMR. + */ +async function modifyFileAndWaitForHmr( + page: Page, + filePath: string, + transform: (content: string) => string, +): Promise { + const content = await fs.promises.readFile(filePath, 'utf-8') + const modified = transform(content) + await fs.promises.writeFile(filePath, modified) + await waitForHmr(page) +} + +test.describe('HMR: Isomorphic function changes', () => { + test('updating APP_NAME constant reflects in the UI', async ({ page }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // Verify initial value + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + + // Modify the APP_NAME constant + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace( + "export const APP_NAME = 'Split Exports Test App'", + "export const APP_NAME = 'HMR Updated App Name'", + ), + ) + + // Verify the change is reflected + await expect(page.getByTestId('app-name')).toContainText( + 'HMR Updated App Name', + ) + }) + + test('updating formatUserName function reflects in the UI', async ({ + page, + }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // Verify initial value + await expect(page.getByTestId('formatted-name')).toContainText('John Doe') + + // Modify the formatUserName function to add a prefix + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace( + 'return `${firstName} ${lastName}`.trim()', + 'return `[HMR] ${firstName} ${lastName}`.trim()', + ), + ) + + // Verify the change is reflected + await expect(page.getByTestId('formatted-name')).toContainText('[HMR] John') + }) + + test('updating getEnvironment client implementation reflects after button click', async ({ + page, + }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // First click to get initial client result + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('client-env')).toContainText('client') + + // Modify the client implementation + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace(".client(() => 'client')", ".client(() => 'hmr-client')"), + ) + + // Click again and verify the updated behavior + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('client-env')).toContainText('hmr-client') + }) +}) + +test.describe('HMR: Server-only code changes (should NOT affect client)', () => { + test('modifying server-only function does not break client', async ({ + page, + }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // Verify initial state works + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + + // Modify server-only code - this should NOT cause client errors + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace( + "passwordHash: 'secret-hash-should-not-leak'", + "passwordHash: 'modified-secret-hash'", + ), + ) + + // Client should still work - no errors, still shows content + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + + // Client tests should still work + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('client-env')).toContainText('client') + }) + + test('modifying server-only config does not affect client imports', async ({ + page, + }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // Initial state + await expect(page.getByTestId('formatted-name')).toContainText('John Doe') + + // Modify server-only config + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace( + "databaseUrl: 'postgresql://localhost:5432/mydb'", + "databaseUrl: 'postgresql://localhost:5432/modified'", + ), + ) + + // Client functionality should still work perfectly + await expect(page.getByTestId('formatted-name')).toContainText('John Doe') + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('client-message')).toContainText('[CLIENT]') + }) +}) + +test.describe('HMR: Multiple importers', () => { + test('change in shared.ts updates importing pages', async ({ page }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace( + "export const APP_NAME = 'Split Exports Test App'", + "export const APP_NAME = 'Multi-Import Test'", + ), + ) + + await expect(page.getByTestId('app-name')).toContainText( + 'Multi-Import Test', + ) + }) +}) + +test.describe('HMR: Nested dependency changes', () => { + test('change in nested.ts GREETING_PREFIX propagates to importers', async ({ + page, + }) => { + await page.goto('/alias-import') + await page.waitForLoadState('networkidle') + + // Check initial greeting prefix (computed from APP_NAME) + await expect(page.getByTestId('greeting-prefix')).toContainText( + 'Welcome to Split Exports Test App', + ) + + // Modify GREETING_PREFIX in nested.ts + // Note: This is a computed value, so we need to modify how it's computed + await modifyFileAndWaitForHmr(page, NESTED_FILE, (content) => + content.replace( + "export const GREETING_PREFIX = 'Welcome to ' + APP_NAME", + "export const GREETING_PREFIX = 'HMR Updated: ' + APP_NAME", + ), + ) + + await expect(page.getByTestId('greeting-prefix')).toContainText( + 'HMR Updated: Split Exports Test App', + ) + }) + + test('change in shared.ts APP_NAME propagates through nested.ts to importers', async ({ + page, + }) => { + await page.goto('/alias-import') + await page.waitForLoadState('networkidle') + + // Initial state uses APP_NAME from shared.ts + await expect(page.getByTestId('greeting-prefix')).toContainText( + 'Welcome to Split Exports Test App', + ) + + // Modify APP_NAME in shared.ts - should propagate through nested.ts + await modifyFileAndWaitForHmr(page, SHARED_FILE, (content) => + content.replace( + "export const APP_NAME = 'Split Exports Test App'", + "export const APP_NAME = 'Cascading HMR Test'", + ), + ) + + // The GREETING_PREFIX in nested.ts depends on APP_NAME + await expect(page.getByTestId('greeting-prefix')).toContainText( + 'Welcome to Cascading HMR Test', + ) + }) +}) + +// Note: Error recovery tests are skipped because the test fixture +// automatically fails on any console errors, and we can't easily bypass +// that for intentional error scenarios. The main HMR functionality +// is covered by the other tests. diff --git a/e2e/react-start/split-exports/tests/split-exports.spec.ts b/e2e/react-start/split-exports/tests/split-exports.spec.ts new file mode 100644 index 00000000000..8beaab00b14 --- /dev/null +++ b/e2e/react-start/split-exports/tests/split-exports.spec.ts @@ -0,0 +1,222 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +/** + * Split Exports Plugin E2E Tests + * + * These tests verify that the split-exports plugin correctly: + * 1. Allows importing isomorphic functions from modules with server-only code + * 2. Eliminates server-only code from client bundles + * 3. Server functions and isomorphic functions work correctly + */ + +test.describe('Direct Import', () => { + test('isomorphic functions work correctly on SSR and client', async ({ + page, + }) => { + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // Check constants work + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + await expect(page.getByTestId('formatted-name')).toContainText('John Doe') + + // Check SSR results - isomorphic function returned 'server' during SSR + await expect(page.getByTestId('ssr-env')).toContainText('server') + // Check that user was loaded from server function + await expect(page.getByTestId('ssr-user')).toContainText('User 1') + + // Run client tests + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + + // Check client results - isomorphic function returns 'client' on client + await expect(page.getByTestId('client-env')).toContainText('client') + // Check message was formatted with client prefix + await expect(page.getByTestId('client-message')).toContainText('[CLIENT]') + // Check server function returns 'server' when called from client + await expect(page.getByTestId('server-env')).toContainText('server') + // Check server function returned user data + await expect(page.getByTestId('server-user')).toContainText('User 2') + }) +}) + +test.describe('Re-export Import', () => { + test('re-exported isomorphic functions work correctly', async ({ page }) => { + await page.goto('/reexport-import') + await page.waitForLoadState('networkidle') + + // Check constants work from re-export + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + + // Check SSR results + await expect(page.getByTestId('ssr-env')).toContainText('server') + // Check users were loaded + await expect(page.getByTestId('ssr-users')).toContainText('Alice') + await expect(page.getByTestId('ssr-users')).toContainText('Bob') + + // Run client tests + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + + // Check client results + await expect(page.getByTestId('client-env')).toContainText('client') + await expect(page.getByTestId('client-message')).toContainText('[CLIENT]') + await expect(page.getByTestId('server-env')).toContainText('server') + }) +}) + +test.describe('Alias Import', () => { + test('path alias imports work correctly with split-exports', async ({ + page, + }) => { + await page.goto('/alias-import') + await page.waitForLoadState('networkidle') + + // Check constants from aliased imports + await expect(page.getByTestId('app-name')).toContainText( + 'Split Exports Test App', + ) + await expect(page.getByTestId('greeting-prefix')).toContainText( + 'Welcome to Split Exports Test App', + ) + // Check formatUserName (pure function re-exported from shared.ts) + await expect(page.getByTestId('formatted-name')).toContainText('Jane Smith') + + // Check SSR results + await expect(page.getByTestId('ssr-env')).toContainText('server') + await expect(page.getByTestId('ssr-greeting')).toContainText( + 'Hello from the server', + ) + await expect(page.getByTestId('ssr-user')).toContainText('User 100') + // Check profile from nested module (tests internal server-only usage) + await expect(page.getByTestId('ssr-profile')).toContainText('User 42') + + // Run client tests + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + + // Check client results + await expect(page.getByTestId('client-env')).toContainText('client') + await expect(page.getByTestId('client-greeting')).toContainText( + 'Hello from the client', + ) + await expect(page.getByTestId('server-env')).toContainText('server') + await expect(page.getByTestId('server-greeting')).toContainText( + 'Hello from the server', + ) + // Check server profile from nested module + await expect(page.getByTestId('server-profile')).toContainText('User 99') + }) +}) + +test.describe('Server Request Import', () => { + /** + * This test verifies a critical bug fix in the split-exports plugin: + * + * When a route file is processed with `?tss-split-exports=Route`, imports + * inside that file should ALSO be rewritten with the split-exports query. + * Previously, the plugin would skip import rewriting for files that already + * had the query, causing server-only imports to leak into the client bundle. + * + * The test scenario: + * 1. server-request.ts exports createServerFn (isomorphic) and uses + * `import { getRequest } from '@tanstack/react-start/server'` internally + * 2. getRequest is server-only and should NOT be in client bundle + * 3. server-request-import.tsx imports only the createServerFn + * 4. The import should be rewritten to only include the server function, + * eliminating the server-only getRequest import from the client + */ + test('server function using internal getRequest works correctly', async ({ + page, + }) => { + await page.goto('/server-request-import') + await page.waitForLoadState('networkidle') + + // Check SSR/loader results + // Loaders use GET requests + await expect(page.getByTestId('loader-method')).toContainText('GET') + await expect(page.getByTestId('loader-executed-on')).toContainText('server') + + // Run client tests + await page.getByTestId('run-client-tests-btn').click() + await page.waitForLoadState('networkidle') + + // Check client results + // Client-side calls also use GET requests (with payload in query params) + await expect(page.getByTestId('client-method')).toContainText('GET') + await expect(page.getByTestId('client-executed-on')).toContainText('server') + await expect(page.getByTestId('echo-result')).toContainText( + 'Hello from client!', + ) + await expect(page.getByTestId('echo-method')).toContainText('GET') + }) +}) + +test.describe('No server-only code leaks to client', () => { + test('client bundle does not contain server-only code markers', async ({ + page, + }) => { + // Navigate to a page to load client bundles + await page.goto('/direct-import') + await page.waitForLoadState('networkidle') + + // Get all script sources + const scripts = await page.evaluate(() => { + const scriptElements = Array.from( + document.querySelectorAll('script[src]'), + ) + return scriptElements.map((s) => s.getAttribute('src')).filter(Boolean) + }) + + // For each script, fetch and check it doesn't contain server-only markers + for (const scriptSrc of scripts) { + if (!scriptSrc || scriptSrc.includes('node_modules')) continue + + const response = await page.request.get(scriptSrc) + const content = await response.text() + + // These strings should NOT appear in client bundles + expect(content).not.toContain('passwordHash') + expect(content).not.toContain('super-secret-key-should-not-leak') + expect(content).not.toContain('postgresql://localhost:5432') + expect(content).not.toContain( + 'Database code should not run on the client', + ) + } + }) + + test('server-request-import page does not leak getRequest import to client', async ({ + page, + }) => { + // This test verifies the bug fix for imports inside files with ?tss-split-exports + // Navigate to the server-request-import page to load its client bundles + await page.goto('/server-request-import') + await page.waitForLoadState('networkidle') + + // Get all script sources + const scripts = await page.evaluate(() => { + const scriptElements = Array.from( + document.querySelectorAll('script[src]'), + ) + return scriptElements.map((s) => s.getAttribute('src')).filter(Boolean) + }) + + // For each script, fetch and check it doesn't contain server-only markers + for (const scriptSrc of scripts) { + if (!scriptSrc || scriptSrc.includes('node_modules')) continue + + const response = await page.request.get(scriptSrc) + const content = await response.text() + + // These strings from server-request.ts should NOT appear in client bundles + expect(content).not.toContain( + 'This string should not appear in client bundles', + ) + } + }) +}) diff --git a/e2e/react-start/split-exports/tsconfig.json b/e2e/react-start/split-exports/tsconfig.json new file mode 100644 index 00000000000..3a9fb7cd716 --- /dev/null +++ b/e2e/react-start/split-exports/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/split-exports/vite.config.ts b/e2e/react-start/split-exports/vite.config.ts new file mode 100644 index 00000000000..ae06492a8ef --- /dev/null +++ b/e2e/react-start/split-exports/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: Number(process.env.PORT) || 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/e2e/solid-start/server-functions/src/routes/factory/-functions/functions.ts b/e2e/solid-start/server-functions/src/routes/factory/-functions/functions.ts index 2af5736775f..c4d3967f09a 100644 --- a/e2e/solid-start/server-functions/src/routes/factory/-functions/functions.ts +++ b/e2e/solid-start/server-functions/src/routes/factory/-functions/functions.ts @@ -76,7 +76,7 @@ export const localFnPOST = localFnFactory({ method: 'POST' }) export const fakeFn = createFakeFn().handler(async () => { return { name: 'fakeFn', - window, + window: typeof window !== 'undefined' ? window : 'no window object', } }) diff --git a/e2e/solid-start/server-functions/src/routes/factory/index.tsx b/e2e/solid-start/server-functions/src/routes/factory/index.tsx index edbf65eb412..e60f9bca34b 100644 --- a/e2e/solid-start/server-functions/src/routes/factory/index.tsx +++ b/e2e/solid-start/server-functions/src/routes/factory/index.tsx @@ -127,7 +127,7 @@ const functions = { type: 'localFn', expected: { name: 'fakeFn', - window, + window: typeof window !== 'undefined' ? window : 'no window object', }, }, } satisfies Record 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 1fb95ee0782..07b4015b202 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 @@ -57,6 +57,7 @@ export class ServerFnCompiler { private moduleCache = new Map() private initialized = false private validLookupKinds: Set + constructor( private options: { env: 'client' | 'server' @@ -64,7 +65,10 @@ export class ServerFnCompiler { lookupConfigurations: Array lookupKinds: Set loadModule: (id: string) => Promise - resolveId: (id: string, importer?: string) => Promise + resolveId: ( + id: string, + importer: string | undefined, + ) => Promise }, ) { this.validLookupKinds = options.lookupKinds @@ -219,7 +223,7 @@ export class ServerFnCompiler { kind: LookupKind }> = [] for (const handler of candidates) { - const kind = await this.resolveExprKind(handler, id) + const kind = await this.resolveExprKind(handler, id, new Set()) if (this.validLookupKinds.has(kind as LookupKind)) { toRewrite.push({ callExpression: handler, kind: kind as LookupKind }) } @@ -293,7 +297,7 @@ export class ServerFnCompiler { private async resolveIdentifierKind( ident: string, id: string, - visited = new Set(), + visited: Set, ): Promise { const info = await this.getModuleInfo(id) @@ -321,7 +325,7 @@ export class ServerFnCompiler { private async resolveBindingKind( binding: Binding, fileId: string, - visited = new Set(), + visited: Set, ): Promise { if (binding.resolvedKind) { return binding.resolvedKind @@ -367,7 +371,7 @@ export class ServerFnCompiler { private async resolveExprKind( expr: t.Expression | null, fileId: string, - visited = new Set(), + visited: Set, ): Promise { if (!expr) { return 'None' @@ -418,7 +422,7 @@ export class ServerFnCompiler { private async resolveCalleeKind( callee: t.Expression, fileId: string, - visited = new Set(), + visited: Set, ): Promise { if (t.isIdentifier(callee)) { return this.resolveIdentifierKind(callee.name, fileId, visited) 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 7afd4f9432f..bbc305a0951 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 @@ -1,13 +1,12 @@ +import type { Environment } from 'vite' import { TRANSFORM_ID_REGEX } from '../constants' +import type { ModuleLoaderApi } from '../module-loader-plugin/plugin' +import { findModuleLoaderApi, stripQueryString } from '../plugin-utils' import { ServerFnCompiler } from './compiler' import type { LookupConfig, LookupKind } from './compiler' import type { CompileStartFrameworkOptions } from '../start-compiler-plugin/compilers' import type { PluginOption } from 'vite' -function cleanId(id: string): string { - return id.split('?')[0]! -} - const LookupKindsPerEnv: Record<'client' | 'server', Set> = { client: new Set(['Middleware', 'ServerFn'] as const), server: new Set(['ServerFn'] as const), @@ -38,13 +37,14 @@ const getLookupConfigurationsForEnv = ( return [createServerFnConfig] } } -const SERVER_FN_LOOKUP = 'server-fn-module-lookup' + export function createServerFnPlugin(opts: { framework: CompileStartFrameworkOptions directive: string environments: Array<{ name: string; type: 'client' | 'server' }> }): PluginOption { - const compilers: Record = {} + // Store compilers per environment name to handle concurrent transforms correctly + const compilers: Record = {} function perEnvServerFnPlugin(environment: { name: string @@ -56,6 +56,18 @@ export function createServerFnPlugin(opts: { ? [/\.\s*handler\(/, /\.\s*createMiddleware\(\)/] : [/\.\s*handler\(/] + let loaderApi: ModuleLoaderApi | undefined + let viteEnv: Environment | undefined + let ctxLoad: + | ((options: { id: string }) => Promise<{ code: string | null }>) + | undefined + let ctxResolve: + | (( + source: string, + importer?: string, + ) => Promise<{ id: string; external?: boolean | 'absolute' } | null>) + | undefined + return { name: `tanstack-start-core::server-fn:${environment.name}`, enforce: 'pre', @@ -65,7 +77,6 @@ export function createServerFnPlugin(opts: { transform: { filter: { id: { - exclude: new RegExp(`${SERVER_FN_LOOKUP}$`), include: TRANSFORM_ID_REGEX, }, code: { @@ -73,8 +84,16 @@ export function createServerFnPlugin(opts: { }, }, async handler(code, id) { - let compiler = compilers[this.environment.name] + const env = this.environment + const envName = env.name + + let compiler = compilers[envName] if (!compiler) { + loaderApi = findModuleLoaderApi(env.plugins) + viteEnv = env + ctxLoad = this.load.bind(this) + ctxResolve = this.resolve.bind(this) + compiler = new ServerFnCompiler({ env: environment.type, directive: opts.directive, @@ -83,44 +102,31 @@ export function createServerFnPlugin(opts: { environment.type, opts.framework, ), - 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 }) - } else if (this.environment.mode === 'dev') { - /** - * in dev, vite does not return code from `ctx.load()` - * so instead, we need to take a different approach - * we must force vite to load the module and run it through the vite plugin pipeline - * we can do this by using the `fetchModule` method - * the `captureServerFnModuleLookupPlugin` captures the module code via its transform hook and invokes analyzeModuleAST - */ - await this.environment.fetchModule( - id + '?' + SERVER_FN_LOOKUP, - ) - } else { - throw new Error( - `could not load module ${id}: unknown environment mode ${this.environment.mode}`, - ) - } + loadModule: async (moduleId: string) => { + const ctxLoadForEnv = + viteEnv!.mode === 'build' ? ctxLoad : undefined + const moduleCode = await loaderApi!.loadModuleCode( + viteEnv!, + moduleId, + ctxLoadForEnv, + ) + compiler!.ingestModule({ code: moduleCode, id: moduleId }) }, - resolveId: async (source: string, importer?: string) => { - const r = await this.resolve(source, importer) - if (r) { - if (!r.external) { - return cleanId(r.id) - } + resolveId: async ( + source: string, + importer: string | undefined, + ) => { + const r = await ctxResolve!(source, importer) + if (r && !r.external) { + return stripQueryString(r.id) } return null }, }) - compilers[this.environment.name] = compiler + compilers[envName] = compiler } - id = cleanId(id) + id = stripQueryString(id) const result = await compiler.compile({ id, code }) return result }, @@ -145,24 +151,5 @@ export function createServerFnPlugin(opts: { } } - return [ - ...opts.environments.map(perEnvServerFnPlugin), - { - name: 'tanstack-start-core:capture-server-fn-module-lookup', - // we only need this plugin in dev mode - apply: 'serve', - applyToEnvironment(env) { - return !!opts.environments.find((e) => e.name === env.name) - }, - transform: { - filter: { - id: new RegExp(`${SERVER_FN_LOOKUP}$`), - }, - handler(code, id) { - const compiler = compilers[this.environment.name] - compiler?.ingestModule({ code, id: cleanId(id) }) - }, - }, - }, - ] + return opts.environments.map(perEnvServerFnPlugin) } diff --git a/packages/start-plugin-core/src/module-loader-plugin/index.ts b/packages/start-plugin-core/src/module-loader-plugin/index.ts new file mode 100644 index 00000000000..f9a71b9f71a --- /dev/null +++ b/packages/start-plugin-core/src/module-loader-plugin/index.ts @@ -0,0 +1,2 @@ +export { moduleLoaderPlugin, MODULE_LOADER_QUERY_KEY } from './plugin' +export type { ModuleLoaderApi } from './plugin' diff --git a/packages/start-plugin-core/src/module-loader-plugin/plugin.ts b/packages/start-plugin-core/src/module-loader-plugin/plugin.ts new file mode 100644 index 00000000000..1a005073d2c --- /dev/null +++ b/packages/start-plugin-core/src/module-loader-plugin/plugin.ts @@ -0,0 +1,189 @@ +import type { DevEnvironment, Environment, Plugin, PluginOption } from 'vite' +import { isDevEnvironment } from '../plugin-utils' + +export const MODULE_LOADER_QUERY_KEY = 'tss-module-load' + +/** + * Interface for the module loader API exposed to other plugins. + */ +export interface ModuleLoaderApi { + /** + * Load a module's transformed code by its resolved ID. + * Works in both dev and build modes. + * + * @param env - The environment to load the module in + * @param id - The resolved module ID to load + * @param ctxLoad - In build mode, pass ctx.load bound to ctx + * @returns The transformed module code + */ + loadModuleCode( + env: Environment, + id: string, + ctxLoad?: (options: { id: string }) => Promise<{ code: string | null }>, + ): Promise +} + +/** + * Plugin that provides module loading capabilities to other plugins. + * This plugin must be added before plugins that need to load module code. + * + * In build mode, uses Vite's ctx.load() which returns code directly. + * In dev mode, uses fetchModule with a capture plugin since ctx.load() + * doesn't return code in dev mode. + * + * @example + * ```typescript + * // In another plugin: + * const loaderPlugin = this.environment.plugins.find( + * p => p.name === 'tanstack-module-loader' + * ) + * const code = await loaderPlugin.api.loadModuleCode(this, resolvedId) + * ``` + */ +export function moduleLoaderPlugin(): PluginOption { + // Cache of loaded module code per environment + // Map: envName -> (id -> code) + const moduleCache = new Map>() + + // Pending load requests for dev mode async loading + // Map: envName -> (id -> { resolve, reject }) + const pendingLoads = new Map< + string, + Map< + string, + { + resolve: (code: string) => void + reject: (err: Error) => void + } + > + >() + + function getEnvCache(envName: string): Map { + let cache = moduleCache.get(envName) + if (!cache) { + cache = new Map() + moduleCache.set(envName, cache) + } + return cache + } + + function getEnvPendingLoads( + envName: string, + ): Map< + string, + { resolve: (code: string) => void; reject: (err: Error) => void } + > { + let pending = pendingLoads.get(envName) + if (!pending) { + pending = new Map() + pendingLoads.set(envName, pending) + } + return pending + } + + const mainPlugin: Plugin = { + name: 'tanstack-module-loader', + + api: { + async loadModuleCode( + env: Environment, + id: string, + ctxLoad?: (options: { id: string }) => Promise<{ code: string | null }>, + ): Promise { + const envName = env.name + + // Check cache first + const envCache = getEnvCache(envName) + const cached = envCache.get(id) + if (cached !== undefined) { + return cached + } + + if (env.mode === 'build') { + // In build mode, use the provided ctxLoad + if (!ctxLoad) { + throw new Error( + `loadModuleCode requires ctxLoad in build mode for module: ${id}`, + ) + } + const loaded = await ctxLoad({ id }) + if (!loaded.code) { + throw new Error(`Could not load module: ${id}`) + } + // Cache it + envCache.set(id, loaded.code) + return loaded.code + } else if (isDevEnvironment(env)) { + // In dev mode, use fetchModule with capture plugin + // The fetchModule triggers Vite to load and transform the module, + // and our capture plugin intercepts it to get the code + const devEnv: DevEnvironment = env + return new Promise((resolve, reject) => { + // Register pending load + const envPending = getEnvPendingLoads(envName) + envPending.set(id, { resolve, reject }) + + // Trigger fetch - the capture plugin will intercept and resolve + devEnv + .fetchModule(id + '?' + MODULE_LOADER_QUERY_KEY) + .catch((err: Error) => { + // Clean up pending on error + envPending.delete(id) + reject(err) + }) + }) + } else { + throw new Error( + `Could not load module ${id}: unsupported environment mode`, + ) + } + }, + } satisfies ModuleLoaderApi, + + // Invalidate cache on HMR + hotUpdate(ctx) { + const envCache = moduleCache.get(this.environment.name) + if (envCache) { + for (const mod of ctx.modules) { + if (mod.id) { + envCache.delete(mod.id) + } + } + } + }, + } + + // Capture plugin for dev mode - intercepts the fetchModule request + const capturePlugin: Plugin = { + name: 'tanstack-module-loader:capture', + apply: 'serve', + + transform: { + filter: { + id: new RegExp(`\\?${MODULE_LOADER_QUERY_KEY}$`), + }, + handler(code, id) { + // Remove query parameter to get clean ID + const cleanId = id.replace('?' + MODULE_LOADER_QUERY_KEY, '') + const envName = this.environment.name + + // Cache the code + const envCache = getEnvCache(envName) + envCache.set(cleanId, code) + + // Resolve pending load if any + const envPending = pendingLoads.get(envName) + const pending = envPending?.get(cleanId) + if (pending) { + pending.resolve(code) + envPending!.delete(cleanId) + } + + // Return null to not affect the module transform + return null + }, + }, + } + + return [mainPlugin, capturePlugin] +} diff --git a/packages/start-plugin-core/src/plugin-utils.ts b/packages/start-plugin-core/src/plugin-utils.ts new file mode 100644 index 00000000000..a377ef808eb --- /dev/null +++ b/packages/start-plugin-core/src/plugin-utils.ts @@ -0,0 +1,50 @@ +import type { DevEnvironment, Environment, Plugin, Rollup } from 'vite' +import type { ModuleLoaderApi } from './module-loader-plugin' + +// ============ Types ============ + +/** + * Plugin context type that works across different TypeScript/Vite versions. + * Extends Rollup's TransformPluginContext with Vite's environment additions. + * + * Uses Vite's Environment type which is a union of DevEnvironment, BuildEnvironment, + * and UnknownEnvironment. + */ +export type PluginContext = Rollup.TransformPluginContext & { + environment: Environment +} + +/** + * Type guard to check if an environment is a DevEnvironment (has fetchModule). + */ +export function isDevEnvironment(env: Environment): env is DevEnvironment { + return env.mode === 'dev' +} + +// ============ Utilities ============ + +/** + * Strip query string from a module ID to get the clean file path. + */ +export function stripQueryString(id: string): string { + return id.split('?')[0]! +} + +/** + * Find the module loader API from environment plugins. + * Throws if the module loader plugin is not found. + */ +export function findModuleLoaderApi( + envPlugins: ReadonlyArray, +): ModuleLoaderApi { + const loaderPlugin = envPlugins.find( + (p): p is Plugin & { api: ModuleLoaderApi } => + p.name === 'tanstack-module-loader' && !!p.api, + ) + if (!loaderPlugin) { + throw new Error( + 'Module loader plugin not found. Ensure moduleLoaderPlugin() is added before other TanStack Start plugins.', + ) + } + return loaderPlugin.api +} diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index 310d7309cf4..e84b8957f89 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -20,6 +20,8 @@ import { } from './output-directory' import { postServerBuild } from './post-server-build' import { createServerFnPlugin } from './create-server-fn-plugin/plugin' +import { moduleLoaderPlugin } from './module-loader-plugin/plugin' +import { splitExportsPlugin } from './split-exports-plugin/plugin' import type { ViteEnvironmentNames } from './constants' import type { TanStackStartInputConfig, @@ -360,6 +362,9 @@ export function TanStackStartVitePluginCore( }, }, tanStackStartRouter(startPluginOpts, getConfig, corePluginOpts), + // Module loader plugin provides shared module loading capabilities + // Must be before plugins that need to load module code (split-exports, create-server-fn) + moduleLoaderPlugin(), // N.B. TanStackStartCompilerPlugin must be before the TanStackServerFnPlugin startCompilerPlugin({ framework: corePluginOpts.framework, environments }), createServerFnPlugin({ @@ -367,6 +372,13 @@ export function TanStackStartVitePluginCore( directive, environments, }), + // Split exports plugin rewrites imports to enable better dead code elimination + // Must run AFTER server function plugins so that .handler() calls are already transformed + // before split-exports creates module variants with query strings + splitExportsPlugin({ + enabled: startPluginOpts?.importOptimization?.enabled ?? true, + getConfig, + }), TanStackServerFnPlugin({ // This is the ID that will be available to look up and import diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index 7dec32effda..f723978c58e 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -192,6 +192,12 @@ const tanstackStartOptionsSchema = z .and(pagePrerenderOptionsSchema.optional()) .optional(), spa: spaSchema.optional(), + importOptimization: z + .object({ + enabled: z.boolean().optional().default(true), + }) + .optional() + .default({}), vite: z .object({ installDevServerMiddleware: z.boolean().optional() }) .optional(), diff --git a/packages/start-plugin-core/src/split-exports-plugin/README.md b/packages/start-plugin-core/src/split-exports-plugin/README.md new file mode 100644 index 00000000000..b2e1a1a9ab2 --- /dev/null +++ b/packages/start-plugin-core/src/split-exports-plugin/README.md @@ -0,0 +1,124 @@ +# Split-Exports Plugin + +A Vite plugin that automatically optimizes imports by rewriting them with query strings to enable dead code elimination. This prevents server-only code from leaking into client bundles. + +## How It Works + +The plugin operates in two phases: + +### Phase 1: Import Rewriting + +When a file imports from another module, the plugin rewrites the import to include a query parameter listing which exports are actually used. + +**Before:** + +```ts +// routes/users.tsx +import { formatUser, getUser } from '../utils/user' +``` + +**After:** + +```ts +// routes/users.tsx +import { + formatUser, + getUser, +} from '../utils/user?tss-split-exports=formatUser,getUser' +``` + +### Phase 2: Export Transformation + +When a module is loaded with the `?tss-split-exports` query, the plugin transforms it to only export the requested symbols. Unrequested exports are converted to local declarations, and dead code elimination removes any code that becomes unreferenced. + +## Example: Separating Server and Client Code + +Consider a utility file that exports both isomorphic and server-only code: + +```ts +// utils/user.ts +import { db } from './db' + +// Isomorphic - safe for client +export const formatUser = (user: { name: string }) => { + return user.name.toUpperCase() +} + +// Server-only - uses database +export const getUser = async (id: string) => { + return db.users.findOne({ id }) +} + +// Server-only - uses database +export const deleteUser = async (id: string) => { + return db.users.delete({ id }) +} +``` + +And a route that only uses `formatUser`: + +```ts +// routes/profile.tsx +import { formatUser } from '../utils/user' + +export const Route = createFileRoute('/profile')({ + component: () =>
{formatUser({ name: 'john' })}
, +}) +``` + +**What happens:** + +1. The import is rewritten to: + + ```ts + import { formatUser } from '../utils/user?tss-split-exports=formatUser' + ``` + +2. When `utils/user.ts?tss-split-exports=formatUser` is loaded, it's transformed to: + + ```ts + // Isomorphic - safe for client + export const formatUser = (user: { name: string }) => { + return user.name.toUpperCase() + } + ``` + +3. The `db` import, `getUser`, and `deleteUser` are all eliminated because they're no longer referenced. + +**Result:** The client bundle only contains `formatUser`. The database code never reaches the browser. + +## What Gets Skipped + +The plugin intentionally skips certain import/export patterns: + +| Pattern | Example | Reason | +| ------------------------ | ------------------------------------- | ----------------------------------------------- | +| npm packages | `import { useState } from 'react'` | External packages handle their own tree-shaking | +| Type-only imports | `import type { User } from './types'` | Types are erased at compile time | +| Namespace imports | `import * as utils from './utils'` | Can't determine which exports are used | +| Side-effect-only imports | `import './polyfill'` | No specifiers to track | +| Wildcard re-exports | `export * from './other'` | Can't enumerate exports statically | + +## Configuration + +```ts +splitExportsPlugin({ + // Enable/disable the plugin (default: true) + enabled: true, + + // Paths to exclude from transformation + exclude: ['generated', /\.test\./], + + // Function to get resolved Start config (for srcDirectory filtering) + getConfig: () => ({ resolvedStartConfig }), +}) +``` + +Debug logging can be enabled by setting the environment variable `TSR_VITE_DEBUG=split-exports` or `TSR_VITE_DEBUG=true`. + +## File Structure + +- `plugin.ts` - Main Vite plugin with four sub-plugins (import rewriter, resolver, export transformer, HMR) +- `compiler.ts` - AST transformation logic using Babel +- `query-utils.ts` - Query string parsing and manipulation for module IDs +- `plugin-utils.ts` - Utility functions for path handling and filtering diff --git a/packages/start-plugin-core/src/split-exports-plugin/compiler.ts b/packages/start-plugin-core/src/split-exports-plugin/compiler.ts new file mode 100644 index 00000000000..6a1491a2eaf --- /dev/null +++ b/packages/start-plugin-core/src/split-exports-plugin/compiler.ts @@ -0,0 +1,594 @@ +import * as t from '@babel/types' +import babel from '@babel/core' +import { + deadCodeElimination, + findReferencedIdentifiers, +} from 'babel-dead-code-elimination' +import { generateFromAst, parseAst } from '@tanstack/router-utils' +import { appendSplitExportsQuery, hasSplitExportsQuery } from './query-utils' +import type { GeneratorResult, ParseAstResult } from '@tanstack/router-utils' + +/** + * Result of extracting imports from a module. + * Includes the parsed AST for reuse in subsequent transformations. + */ +export interface ExtractImportsResult { + /** Map of source module -> set of imported names */ + imports: Map> + /** The parsed AST, can be reused by transformImports to avoid double parsing */ + ast: ParseAstResult +} + +/** + * Check if an import source looks like a bare module specifier (npm package). + * Examples: 'lodash', '@tanstack/react-router', 'react-dom/client' + */ +export function isBareModuleSpecifier(source: string): boolean { + // Relative or absolute imports + if (source.startsWith('.') || source.startsWith('/')) { + return false + } + + // Common alias prefixes + if (source.startsWith('~') || source.startsWith('#')) { + return false + } + + // Scoped packages (@scope/package) vs aliases (@/path) + if (source.startsWith('@')) { + const match = source.match(/^@([^/]+)\//) + if (!match) return false // Just @ with no slash - probably an alias + + const scope = match[1] + // Valid npm scope: non-empty, lowercase letters, numbers, hyphens + return !!scope && /^[a-z0-9-]+$/.test(scope) + } + + // Everything else is likely a bare module (lodash, react, etc.) + return true +} + +/** + * Check if a module exports any classes. + * This is used to skip the split-exports optimization for modules with class exports, + * as class identity would be lost when the module is loaded with different query strings. + * + * Detects: + * - export class Foo {} + * - export default class Foo {} + * - export default class {} + * - class Foo {}; export { Foo } + * - class Foo {}; export { Foo as Bar } + * - export const Foo = class {} + * - export const Bar = class BarClass {} + * - const Foo = class {}; export { Foo } + */ +export function hasClassExports(code: string): boolean { + // Quick string check for early bailout + if (!code.includes('class ')) { + return false + } + + const ast = parseAst({ code }) + + // Collect names of top-level class declarations and class expression variables + const localClassNames = new Set() + + for (const node of ast.program.body) { + // Track local class declarations: class Foo {} + if (t.isClassDeclaration(node) && node.id) { + localClassNames.add(node.id.name) + } + + // Track local class expressions: const Foo = class {} + if (t.isVariableDeclaration(node)) { + for (const declarator of node.declarations) { + if ( + t.isClassExpression(declarator.init) && + t.isIdentifier(declarator.id) + ) { + localClassNames.add(declarator.id.name) + } + } + } + + // 1. export class Foo {} + if (t.isExportNamedDeclaration(node)) { + if (t.isClassDeclaration(node.declaration)) { + return true + } + + // 2. export const Foo = class {} + if (t.isVariableDeclaration(node.declaration)) { + for (const declarator of node.declaration.declarations) { + if (t.isClassExpression(declarator.init)) { + return true + } + } + } + } + + // 3. export default class {} or export default class Foo {} + if (t.isExportDefaultDeclaration(node)) { + if ( + t.isClassDeclaration(node.declaration) || + t.isClassExpression(node.declaration) + ) { + return true + } + } + } + + // 4. Check for export { Foo } where Foo is a local class + for (const node of ast.program.body) { + if (t.isExportNamedDeclaration(node) && !node.source) { + // Only check local exports, not re-exports like export { Foo } from './other' + for (const specifier of node.specifiers) { + if (t.isExportSpecifier(specifier) && t.isIdentifier(specifier.local)) { + if (localClassNames.has(specifier.local.name)) { + return true + } + } + } + } + } + + return false +} + +/** + * Parse a module and extract all import specifiers from it. + * Returns a map of source module -> set of imported names, plus the parsed AST. + * Type-only imports and namespace imports are ignored. + * Bare module specifiers (npm packages) are skipped. + * + * The returned AST can be passed to `transformImports` to avoid double parsing. + */ +export function extractImportsFromModule(code: string): ExtractImportsResult { + const ast = parseAst({ code }) + const imports = new Map>() + + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) { + continue + } + + // Skip type-only imports entirely (import type { ... }) + if (node.importKind === 'type') { + continue + } + + const source = node.source.value + + // Skip bare module specifiers (npm packages) + if (isBareModuleSpecifier(source)) { + continue + } + + // Skip imports that already have our query parameter + if (hasSplitExportsQuery(source)) { + continue + } + + // Check for namespace imports first - if present, skip this source entirely + const hasNamespaceImport = node.specifiers.some((spec) => + t.isImportNamespaceSpecifier(spec), + ) + if (hasNamespaceImport) { + continue + } + + // Skip side-effect only imports (import './foo') + if (node.specifiers.length === 0) { + continue + } + + let importedNames = imports.get(source) + if (!importedNames) { + importedNames = new Set() + imports.set(source, importedNames) + } + + for (const specifier of node.specifiers) { + // Skip type-only specifiers + if (t.isImportSpecifier(specifier) && specifier.importKind === 'type') { + continue + } + + if (t.isImportSpecifier(specifier)) { + const importedName = t.isIdentifier(specifier.imported) + ? specifier.imported.name + : specifier.imported.value + importedNames.add(importedName) + } else if (t.isImportDefaultSpecifier(specifier)) { + importedNames.add('default') + } + // Note: namespace imports are already filtered out above + } + } + + // Remove entries with no value imports (all type-only specifiers) + // We don't need to transform these - Vite will handle them + for (const [source, names] of imports) { + if (names.size === 0) { + imports.delete(source) + } + } + + return { imports, ast } +} + +/** + * Options for transforming imports. + */ +export interface TransformImportsOptions { + /** + * If provided, only transform imports from these sources. + * The keys should match the original import source strings. + * If not provided, transforms all imports returned by extractImportsFromModule. + */ + importsToTransform?: Map> + + /** + * If provided, reuse this AST instead of parsing the code again. + * This should be the AST returned from extractImportsFromModule. + */ + ast?: ParseAstResult + + /** + * Whether to generate source maps. + * @default true + */ + sourceMaps?: boolean +} + +/** + * Transform import declarations to add the split-exports query parameter. + * Returns the transformed code or null if no changes were made. + * + * @param code - The source code to transform + * @param filename - The filename for source maps + * @param options - Optional configuration for which imports to transform + */ +export function transformImports( + code: string, + filename: string, + options: TransformImportsOptions = {}, +): GeneratorResult | null { + // Use provided imports or extract them (for backwards compatibility) + let ast: ParseAstResult + let imports: Map> + + if (options.importsToTransform) { + imports = options.importsToTransform + // Use provided AST or parse the code + ast = options.ast ?? parseAst({ code }) + } else { + // Extract imports and get AST in one pass + const result = extractImportsFromModule(code) + imports = result.imports + ast = result.ast + } + + if (imports.size === 0) { + return null + } + + // Track if we actually modified anything + let hasChanges = false + + // Simple loop over top-level statements - imports are always at the top level + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) { + continue + } + + const source = node.source.value + + // Only transform if this source is in our imports map + // (extractImportsFromModule already filtered out type-only, namespace, etc.) + const importedNames = imports.get(source) + if (!importedNames || importedNames.size === 0) { + continue + } + + // Create new source with query string + node.source = t.stringLiteral( + appendSplitExportsQuery(source, importedNames), + ) + hasChanges = true + } + + if (!hasChanges) { + return null + } + + return generateFromAst(ast, { + sourceMaps: options.sourceMaps ?? true, + sourceFileName: filename, + filename, + }) +} + +/** + * Options for transforming exports. + */ +export interface TransformExportsOptions { + /** + * Whether to generate source maps. + * @default true + */ + sourceMaps?: boolean +} + +/** + * Result of extracting all export names from a module. + */ +export interface GetAllExportNamesResult { + /** Set of all export names in the module */ + exportNames: Set + /** The parsed AST, can be reused by transformExports */ + ast: ParseAstResult + /** True if the module has wildcard re-exports (export * from) */ + hasWildcardReExport: boolean +} + +/** + * Extract exported names from a declaration node. + */ +function getExportedNamesFromDeclaration( + declaration: t.Declaration, +): Array { + if (t.isVariableDeclaration(declaration)) { + return declaration.declarations + .filter((decl): decl is t.VariableDeclarator & { id: t.Identifier } => + t.isIdentifier(decl.id), + ) + .map((decl) => decl.id.name) + } + + if ( + (t.isFunctionDeclaration(declaration) || + t.isClassDeclaration(declaration)) && + declaration.id + ) { + return [declaration.id.name] + } + + return [] +} + +/** + * Get the exported name from an export specifier. + */ +function getExportedName(specifier: t.ExportSpecifier): string { + return t.isIdentifier(specifier.exported) + ? specifier.exported.name + : specifier.exported.value +} + +/** + * Add binding identifiers from a path to the refIdents set for dead code elimination. + * This ensures that local declarations created from removed exports can be eliminated. + */ +function addBindingIdentifiersToRefIdents( + path: babel.NodePath, + refIdents: Set>, +): void { + path.traverse({ + Identifier(identPath: babel.NodePath) { + // Add function/class name identifiers + if ( + (identPath.parentPath.isFunctionDeclaration() || + identPath.parentPath.isClassDeclaration()) && + identPath.key === 'id' + ) { + refIdents.add(identPath) + } + // Add variable declarator identifiers + if ( + identPath.parentPath.isVariableDeclarator() && + identPath.key === 'id' + ) { + refIdents.add(identPath) + } + }, + }) +} + +/** + * Quickly extract all export names from a module. + * This is used for early bailout optimization when all exports are requested. + * Returns the AST so it can be reused by transformExports if needed. + */ +export function getAllExportNames(code: string): GetAllExportNamesResult { + const ast = parseAst({ code }) + const exportNames = new Set() + let hasWildcardReExport = false + + for (const node of ast.program.body) { + if (t.isExportNamedDeclaration(node)) { + // Handle: export const foo = ..., export function bar() {}, etc. + if (node.declaration) { + for (const name of getExportedNamesFromDeclaration(node.declaration)) { + exportNames.add(name) + } + } + + // Handle: export { foo, bar as baz } and export { foo } from './other' + for (const specifier of node.specifiers) { + if (t.isExportSpecifier(specifier)) { + exportNames.add(getExportedName(specifier)) + } + } + } else if (t.isExportDefaultDeclaration(node)) { + exportNames.add('default') + } else if (t.isExportAllDeclaration(node)) { + // We can't enumerate exports from wildcard re-exports + hasWildcardReExport = true + } + } + + return { exportNames, ast, hasWildcardReExport } +} + +/** + * Transform a module to only export the specified identifiers. + * All other exports are converted to local declarations and dead code is eliminated. + * Returns null if no changes were made. + */ +export function transformExports( + code: string, + filename: string, + exportsToKeep: Set, + options: TransformExportsOptions = {}, +): GeneratorResult | null { + // Extract all export names and get the AST in one pass + // This allows early bailout if all exports are being kept + const { + exportNames: allExports, + ast, + hasWildcardReExport, + } = getAllExportNames(code) + + // Early bailout: if we can enumerate all exports and all are being kept, + // skip the expensive transformation (findReferencedIdentifiers, etc.) + if (!hasWildcardReExport) { + const allKept = [...allExports].every((name) => exportsToKeep.has(name)) + if (allKept) { + // All exports are being kept, no transformation needed + return null + } + } + + // Find all referenced identifiers before transformation + const refIdents = findReferencedIdentifiers(ast) + + // Track if we actually modified anything + let hasChanges = false + + babel.traverse(ast, { + ExportNamedDeclaration(path) { + const node = path.node + + if (node.declaration) { + // export const/let/var foo = ..., export function bar() {}, export class Baz {} + const names = getExportedNamesFromDeclaration(node.declaration) + const keptNames = names.filter((name) => exportsToKeep.has(name)) + + if (keptNames.length === names.length) { + // All kept, no changes needed + return + } + + hasChanges = true + + if (keptNames.length === 0) { + // None kept - convert entire declaration to local, let DCE handle unused parts + path.replaceWith(node.declaration) + // Add identifiers to refIdents so DCE can remove them if unused + addBindingIdentifiersToRefIdents(path, refIdents) + } else if (t.isVariableDeclaration(node.declaration)) { + // Some kept - need to split variable declarations + // e.g., `export const a = 1, b = 2, c = 3` with keep=[a,c] + // becomes `export const a = 1, c = 3` + `const b = 2` + const keptDeclarators: Array = [] + const removedDeclarators: Array = [] + + for (const decl of node.declaration.declarations) { + if (t.isIdentifier(decl.id) && exportsToKeep.has(decl.id.name)) { + keptDeclarators.push(decl) + } else { + removedDeclarators.push(decl) + } + } + + // Replace the export with just the kept declarators + node.declaration.declarations = keptDeclarators + + // If there are removed declarators, add them as a local declaration + if (removedDeclarators.length > 0) { + const localDecl = t.variableDeclaration( + node.declaration.kind, + removedDeclarators, + ) + const [insertedPath] = path.insertAfter(localDecl) + // Add identifiers to refIdents so DCE can remove them if unused + addBindingIdentifiersToRefIdents(insertedPath, refIdents) + } + } else { + // Unreachable for function/class: they export exactly one name, + // so keptNames is either length 0 (handled above) or 1 (all kept, returned early) + // Keep as defensive fallback + path.replaceWith(node.declaration) + // Add identifiers to refIdents so DCE can remove them if unused + addBindingIdentifiersToRefIdents(path, refIdents) + } + } else if (node.specifiers.length > 0) { + // export { foo, bar } or export { foo, bar } from './other' + const keptSpecifiers = node.specifiers.filter((spec) => { + if (t.isExportSpecifier(spec)) { + return exportsToKeep.has(getExportedName(spec)) + } + return false + }) + + if (keptSpecifiers.length === node.specifiers.length) { + // All kept, no changes needed + return + } + + hasChanges = true + + if (keptSpecifiers.length > 0) { + node.specifiers = keptSpecifiers + } else { + path.remove() + } + } + }, + + ExportDefaultDeclaration(path) { + if (exportsToKeep.has('default')) { + return + } + + hasChanges = true + + const decl = path.node.declaration + if ( + (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && + decl.id + ) { + // Named: `export default function foo() {}` -> `function foo() {}` + // Keep as local declaration so DCE can decide if it's referenced elsewhere + path.replaceWith(decl) + // Add identifiers to refIdents so DCE can remove them if unused + addBindingIdentifiersToRefIdents(path, refIdents) + } else { + // Anonymous function/class or expression - just remove + path.remove() + } + }, + + // Note: ExportAllDeclaration (export * from './foo') is intentionally not handled + // We leave these as-is and let the bundler tree-shake them + }) + + // hasChanges may remain false if: + // - Module only has wildcard re-exports (export * from) which we preserve + // - All named exports are in exportsToKeep (but we didn't bail out due to wildcard) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!hasChanges) { + return null + } + + // Run dead code elimination + deadCodeElimination(ast, refIdents) + + return generateFromAst(ast, { + sourceMaps: options.sourceMaps ?? true, + sourceFileName: filename, + filename, + }) +} diff --git a/packages/start-plugin-core/src/split-exports-plugin/plugin-utils.ts b/packages/start-plugin-core/src/split-exports-plugin/plugin-utils.ts new file mode 100644 index 00000000000..2e0d9ecf067 --- /dev/null +++ b/packages/start-plugin-core/src/split-exports-plugin/plugin-utils.ts @@ -0,0 +1,104 @@ +import { normalizePath } from 'vite' +import { stripQueryString } from '../plugin-utils' + +export const debug = + process.env.TSR_VITE_DEBUG && + ['true', 'split-exports'].includes(process.env.TSR_VITE_DEBUG) + +/** + * File extensions that can be parsed as JavaScript/TypeScript. + * These are the only files we should attempt to transform. + */ +const PARSEABLE_EXTENSIONS = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', +]) + +/** + * Query parameters that indicate the module is being processed by + * the directive functions plugin (server function extraction). + * We should NOT transform exports in these modules because they + * need to preserve the handler exports. + */ +const DIRECTIVE_QUERY_PATTERN = /[?&]tsr-directive-/ + +/** + * Check if a module ID has a directive query parameter. + * These modules are being processed for server function extraction + * and should not have their exports transformed. + */ +export function hasDirectiveQuery(id: string): boolean { + return DIRECTIVE_QUERY_PATTERN.test(id) +} + +/** + * Check if a file path has an extension that can be parsed as JS/TS. + * Returns false for CSS, images, fonts, and other asset files. + */ +export function isParseableFile(id: string): boolean { + // Strip query string first + const cleanId = stripQueryString(id) + + // Find the extension + const lastDot = cleanId.lastIndexOf('.') + if (lastDot === -1) { + // No extension - might be a virtual module, let it through + return true + } + + const ext = cleanId.slice(lastDot).toLowerCase() + return PARSEABLE_EXTENSIONS.has(ext) +} + +/** + * Check if an ID should be excluded from transformation. + * If srcDirectory is provided, only files inside it are processed. + */ +export function shouldExclude( + id: string, + srcDirectory: string | undefined, + exclude: Array | undefined, +): boolean { + // Normalize and strip query string for consistent path comparison + const cleanId = normalizePath(stripQueryString(id)) + + // Always exclude node_modules (safety net) + if (cleanId.includes('/node_modules/')) { + return true + } + + // If srcDirectory is provided, only include files inside it + if (srcDirectory) { + const normalizedSrcDir = normalizePath(srcDirectory) + if (!cleanId.startsWith(normalizedSrcDir)) { + if (debug) { + console.info( + '[split-exports] Skipping (outside srcDirectory):', + cleanId, + ) + } + return true + } + } + + // Check custom exclusion patterns + if (exclude && exclude.length > 0) { + for (const pattern of exclude) { + if (typeof pattern === 'string') { + if (cleanId.includes(pattern)) { + return true + } + } else if (pattern.test(cleanId)) { + return true + } + } + } + + return false +} diff --git a/packages/start-plugin-core/src/split-exports-plugin/plugin.ts b/packages/start-plugin-core/src/split-exports-plugin/plugin.ts new file mode 100644 index 00000000000..ef3f7722f25 --- /dev/null +++ b/packages/start-plugin-core/src/split-exports-plugin/plugin.ts @@ -0,0 +1,474 @@ +import { logDiff } from '@tanstack/router-utils' +import { normalizePath } from 'vite' +import { TRANSFORM_ID_REGEX } from '../constants' +import type { ModuleLoaderApi } from '../module-loader-plugin/plugin' +import { findModuleLoaderApi, stripQueryString } from '../plugin-utils' +import { + extractImportsFromModule, + hasClassExports, + transformExports, + transformImports, +} from './compiler' +import { + debug, + hasDirectiveQuery, + isParseableFile, + shouldExclude, +} from './plugin-utils' +import { + SPLIT_EXPORTS_QUERY_KEY, + extractSplitExportsQuery, + hasSplitExportsQuery, + parseSplitExportsQuery, + removeSplitExportsQuery, +} from './query-utils' +import type { GetConfigFn } from '../plugin' +import type { Plugin, PluginOption, ResolvedConfig } from 'vite' + +/** + * Determine if sourcemaps should be generated based on Vite config. + * Returns true for 'inline', 'hidden', true, or any truthy string value. + */ +function shouldGenerateSourceMaps(config: ResolvedConfig | undefined): boolean { + if (!config) return true // Default to generating sourcemaps if no config + const sourcemap = config.build.sourcemap + // sourcemap can be boolean, 'inline', 'hidden', or undefined + return !!sourcemap +} + +/** + * Options for the split-exports plugin. + */ +export interface SplitExportsPluginOptions { + /** + * Enable or disable the plugin. + * @default true + */ + enabled?: boolean + + /** + * Paths to exclude from transformation (glob patterns). + * node_modules is always excluded. + */ + exclude?: Array + + /** + * Function to get the resolved Start config. + * When provided, only files inside srcDirectory will be transformed. + */ + getConfig?: GetConfigFn +} + +/** + * Plugin 1: Rewrite imports to add split-exports query parameters. + * This runs early (enforce: 'pre') to add query strings before other plugins process the imports. + */ +function importRewriterPlugin(options: SplitExportsPluginOptions): Plugin { + let srcDirectory: string | undefined + let sourceMaps = true + let loaderApi: ModuleLoaderApi | undefined + + // Cache for class export detection per resolved path + // true = has class exports (skip), false = no class exports (can optimize) + const classExportCache = new Map() + + return { + name: 'tanstack-split-exports:rewrite-imports', + enforce: 'pre', + + configResolved(config) { + // Resolve srcDirectory once when config is ready + if (options.getConfig) { + const { resolvedStartConfig } = options.getConfig() + srcDirectory = resolvedStartConfig.srcDirectory + } + // Capture sourcemap setting from Vite config + sourceMaps = shouldGenerateSourceMaps(config) + }, + + transform: { + filter: { + id: TRANSFORM_ID_REGEX, + }, + async handler(code, id) { + // Skip excluded paths (including files outside srcDirectory) + // Note: We intentionally do NOT skip files with tss-split-exports query here. + // A file like `foo.ts?tss-split-exports=Route` may still have imports + // that need to be rewritten (e.g., `import { bar } from './bar'`). + // The extractImportsFromModule function already skips imports that have + // our query parameter, preventing infinite recursion. + if (shouldExclude(id, srcDirectory, options.exclude)) { + return null + } + + // Quick check: skip files that don't contain import statements + if (!code.includes('import')) { + return null + } + + // Extract all potential imports (including aliased ones like ~/ and @/) + // Also get the AST to reuse in transformImports (avoids double parsing) + const { imports: allImports, ast } = extractImportsFromModule(code) + + if (allImports.size === 0) { + return null + } + + // Get the module loader API (looked up once, then cached) + if (!loaderApi) { + loaderApi = findModuleLoaderApi(this.environment.plugins) + } + + // Resolve all imports in parallel for better performance + const importEntries = Array.from(allImports.entries()) + const resolutions = await Promise.all( + importEntries.map(async ([source]) => { + const resolved = await this.resolve(source, id, { skipSelf: true }) + return { source, resolved } + }), + ) + + // Build a map of source -> resolved path for filtering + const resolvedPaths = new Map() + for (const { source, resolved } of resolutions) { + if (resolved?.id) { + resolvedPaths.set( + source, + normalizePath(stripQueryString(resolved.id)), + ) + } + } + + // Filter imports based on resolved paths + const importsToTransform = new Map>() + const normalizedSrcDir = srcDirectory + ? normalizePath(srcDirectory) + : undefined + + for (const [source, names] of allImports) { + const resolvedPath = resolvedPaths.get(source) + if (!resolvedPath) { + continue + } + + // Skip non-parseable files (CSS, images, etc.) + if (!isParseableFile(resolvedPath)) { + if (debug) { + console.info( + '[split-exports] Skipping import (non-parseable file):', + source, + '->', + resolvedPath, + ) + } + continue + } + + // Skip node_modules + if (resolvedPath.includes('/node_modules/')) { + continue + } + + // If srcDirectory is set, only include files inside it + if (normalizedSrcDir && !resolvedPath.startsWith(normalizedSrcDir)) { + if (debug) { + console.info( + '[split-exports] Skipping import (outside srcDirectory):', + source, + '->', + resolvedPath, + ) + } + continue + } + + // Check if target module exports classes (skip if so to preserve class identity) + let hasClasses = classExportCache.get(resolvedPath) + if (hasClasses === undefined) { + try { + // In build mode, we need to pass ctx.load for loading modules + const ctxLoad = + this.environment.mode === 'build' + ? this.load.bind(this) + : undefined + const targetCode = await loaderApi.loadModuleCode( + this.environment, + resolvedPath, + ctxLoad, + ) + hasClasses = hasClassExports(targetCode) + classExportCache.set(resolvedPath, hasClasses) + } catch { + // If we can't load the module, assume no classes (safe fallback) + hasClasses = false + classExportCache.set(resolvedPath, hasClasses) + } + } + + if (hasClasses) { + if (debug) { + console.info( + '[split-exports] Skipping import (module exports class):', + source, + '->', + resolvedPath, + ) + } + continue + } + + importsToTransform.set(source, names) + if (debug) { + console.info( + '[split-exports] Including import:', + source, + '->', + resolvedPath, + ) + } + } + + if (importsToTransform.size === 0) { + return null + } + + const result = transformImports(code, id, { + importsToTransform, + ast, // Reuse the AST from extractImportsFromModule + sourceMaps, // Skip sourcemap generation if disabled in Vite config + }) + + if (result && debug) { + console.info( + `[split-exports env: ${this.environment.name}] Rewrote imports in:`, + id, + ) + logDiff(code, result.code) + console.log('Output:\n', result.code + '\n\n') + } + + return result + }, + }, + + // Invalidate class export cache on HMR + hotUpdate(ctx) { + for (const mod of ctx.modules) { + if (mod.id) { + classExportCache.delete(mod.id) + } + } + }, + } +} + +/** + * Plugin 2: Resolve split-exports query IDs. + * When we see an import like './foo?tss-split-exports=bar', we resolve the base path + * and re-attach our query parameter. + */ +function resolverPlugin(): Plugin { + return { + name: 'tanstack-split-exports:resolve', + + resolveId: { + filter: { + id: new RegExp(`[?&]${SPLIT_EXPORTS_QUERY_KEY}=`), + }, + async handler(id, importer, resolveOptions) { + // Extract our query and the clean path + const { cleanId, exportNames } = extractSplitExportsQuery(id) + + // Resolve the clean path using Vite's normal resolution + const resolved = await this.resolve(cleanId, importer, { + ...resolveOptions, + skipSelf: true, + }) + + if (!resolved) { + return null + } + + // Re-attach our query to the resolved path + // Check if the resolved ID already has query params + const hasQuery = resolved.id.includes('?') + const separator = hasQuery ? '&' : '?' + // URL-encode individual names to handle $ and unicode characters in identifiers + const sortedNames = Array.from(exportNames) + .sort() + .map(encodeURIComponent) + .join(',') + + return { + ...resolved, + id: `${resolved.id}${separator}${SPLIT_EXPORTS_QUERY_KEY}=${sortedNames}`, + } + }, + }, + } +} + +/** + * Plugin 3: Transform modules with split-exports query to only export requested symbols. + * This runs after other transforms (like TypeScript compilation) have processed the code. + */ +function exportTransformerPlugin(): Plugin { + let sourceMaps = true + + return { + name: 'tanstack-split-exports:transform-exports', + enforce: 'pre', + + configResolved(config) { + // Capture sourcemap setting from Vite config + sourceMaps = shouldGenerateSourceMaps(config) + }, + + transform: { + filter: { + id: new RegExp(`[?&]${SPLIT_EXPORTS_QUERY_KEY}=`), + }, + handler(code, id) { + // Safety check: skip non-parseable files (CSS, images, etc.) + // This shouldn't happen if importRewriterPlugin is working correctly, + // but we check here as a safety net + if (!isParseableFile(id)) { + return null + } + + // Skip modules that are being processed by the directive functions plugin + // (server function extraction). These modules need to preserve their + // handler exports which may not be in the split-exports list. + if (hasDirectiveQuery(id)) { + if (debug) { + console.info('[split-exports] Skipping (has directive query):', id) + } + return null + } + + const exportsToKeep = parseSplitExportsQuery(id) + if (!exportsToKeep || exportsToKeep.size === 0) { + return null + } + + const cleanId = removeSplitExportsQuery(id) + + if (debug) { + console.info( + `[split-exports env: ${this.environment.name}] Transforming exports in:`, + id, + 'keeping:', + Array.from(exportsToKeep), + ) + } + + const result = transformExports(code, cleanId, exportsToKeep, { + sourceMaps, // Skip sourcemap generation if disabled in Vite config + }) + + if (!result) { + return null + } + + if (debug) { + logDiff(code, result.code) + console.log('Output:\n', result.code + '\n\n') + } + + return result + }, + }, + } +} + +/** + * Plugin 4: Handle HMR invalidation for split-exports modules. + * When a source file changes, we need to invalidate all the split-exports variants of it. + */ +function hmrPlugin(): Plugin { + return { + name: 'tanstack-split-exports:hmr', + apply: 'serve', + + hotUpdate(ctx) { + const affectedModules: Set<(typeof ctx.modules)[number]> = new Set() + + for (const mod of ctx.modules) { + if (!mod.id) continue + + const modIdWithoutQuery = removeSplitExportsQuery(mod.id) + + // Find all modules in the graph that are split-exports variants of this file + for (const [id, module] of this.environment.moduleGraph.idToModuleMap) { + if (!hasSplitExportsQuery(id)) { + continue + } + + const cleanId = removeSplitExportsQuery(id) + + // Check if this split-exports module is derived from the changed file + if (cleanId === modIdWithoutQuery || cleanId === mod.id) { + // Invalidate this split-exports variant + affectedModules.add(module) + + // Also mark importers for update so they can re-evaluate + for (const importer of module.importers) { + affectedModules.add(importer) + } + } + } + } + + // Add affected modules to the update + if (affectedModules.size > 0) { + return [...ctx.modules, ...affectedModules] + } + + return undefined + }, + } +} + +/** + * Creates the split-exports Vite plugin. + * + * This plugin automatically optimizes imports by: + * 1. Analyzing which exports are actually imported from each module + * 2. Rewriting imports to include a query string specifying needed exports + * 3. Transforming the imported modules to only export what's needed + * 4. Running dead code elimination to remove unused code + * + * This prevents server-only code from leaking into client bundles when + * a module exports both isomorphic and server-only code. + * + * @example + * ```ts + * // vite.config.ts + * import { splitExportsPlugin } from '@tanstack/start-plugin-core' + * + * export default { + * plugins: [ + * splitExportsPlugin({ + * enabled: true, + * debug: false, + * }), + * ], + * } + * ``` + */ +export function splitExportsPlugin( + options: SplitExportsPluginOptions = {}, +): PluginOption { + const { enabled = true } = options + + if (!enabled) { + return [] + } + + return [ + importRewriterPlugin(options), + resolverPlugin(), + exportTransformerPlugin(), + hmrPlugin(), + ] +} diff --git a/packages/start-plugin-core/src/split-exports-plugin/query-utils.ts b/packages/start-plugin-core/src/split-exports-plugin/query-utils.ts new file mode 100644 index 00000000000..827019748a2 --- /dev/null +++ b/packages/start-plugin-core/src/split-exports-plugin/query-utils.ts @@ -0,0 +1,103 @@ +export const SPLIT_EXPORTS_QUERY_KEY = 'tss-split-exports' + +/** + * Split a module ID into path and query string parts. + * Uses a simple split since module IDs may not be valid URLs. + */ +function splitIdParts(id: string): { path: string; query: string } { + const queryIndex = id.indexOf('?') + if (queryIndex === -1) { + return { path: id, query: '' } + } + return { + path: id.slice(0, queryIndex), + query: id.slice(queryIndex + 1), + } +} + +/** + * Check if an ID has the split-exports query parameter. + * Uses simple string matching for performance. + */ +export function hasSplitExportsQuery(id: string): boolean { + return ( + id.includes(`?${SPLIT_EXPORTS_QUERY_KEY}=`) || + id.includes(`&${SPLIT_EXPORTS_QUERY_KEY}=`) + ) +} + +/** + * Parse the split-exports query parameter from a module ID. + * Returns the set of exported names that should be kept, or null if no query is present. + * Note: We manually extract the raw value to avoid URLSearchParams auto-decoding, + * which allows us to correctly split on comma before decoding individual names. + */ +export function parseSplitExportsQuery(id: string): Set | null { + const { query } = splitIdParts(id) + if (!query) return null + + // Manually find the parameter value to avoid URLSearchParams auto-decoding + const prefix = `${SPLIT_EXPORTS_QUERY_KEY}=` + const params = query.split('&') + const param = params.find((p) => p.startsWith(prefix)) + if (!param) return null + + const rawValue = param.slice(prefix.length) + if (!rawValue) return null + + // Split on comma first (our separator), then decode each name + return new Set(rawValue.split(',').filter(Boolean).map(decodeURIComponent)) +} + +/** + * Remove the split-exports query parameter from a module ID. + * Preserves other query parameters. + */ +export function removeSplitExportsQuery(id: string): string { + const { path, query } = splitIdParts(id) + if (!query) return id + + const params = new URLSearchParams(query) + if (!params.has(SPLIT_EXPORTS_QUERY_KEY)) return id + + params.delete(SPLIT_EXPORTS_QUERY_KEY) + const remainingQuery = params.toString() + return remainingQuery ? `${path}?${remainingQuery}` : path +} + +/** + * Append the split-exports query parameter to a source path. + * Handles existing query strings correctly. + * Export names are URL-encoded to handle $ and unicode characters in identifiers. + */ +export function appendSplitExportsQuery( + source: string, + exportNames: Set, +): string { + if (exportNames.size === 0) return source + + const { path, query } = splitIdParts(source) + + // Sort names for consistent, cache-friendly query strings + // URL-encode individual names to handle $ and unicode characters in identifiers + const sortedNames = Array.from(exportNames).sort().map(encodeURIComponent) + const newParam = `${SPLIT_EXPORTS_QUERY_KEY}=${sortedNames.join(',')}` + + if (!query) { + return `${path}?${newParam}` + } + + return `${path}?${query}&${newParam}` +} + +/** + * Extract the split-exports query from an ID and return both the clean ID and export names. + */ +export function extractSplitExportsQuery(id: string): { + cleanId: string + exportNames: Set +} { + const exportNames = parseSplitExportsQuery(id) || new Set() + const cleanId = removeSplitExportsQuery(id) + return { cleanId, exportNames } +} diff --git a/packages/start-plugin-core/tests/plugin-utils.test.ts b/packages/start-plugin-core/tests/plugin-utils.test.ts new file mode 100644 index 00000000000..21437f98583 --- /dev/null +++ b/packages/start-plugin-core/tests/plugin-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { stripQueryString } from '../src/plugin-utils' + +describe('stripQueryString', () => { + test('strips query string from path', () => { + expect(stripQueryString('/path/to/file.ts?v=123')).toBe('/path/to/file.ts') + }) + + test('returns path unchanged when no query string', () => { + expect(stripQueryString('/path/to/file.ts')).toBe('/path/to/file.ts') + }) + + test('handles multiple query parameters', () => { + expect(stripQueryString('/path/to/file.ts?v=123&other=abc')).toBe( + '/path/to/file.ts', + ) + }) +}) diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/defaultExport.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/defaultExport.ts new file mode 100644 index 00000000000..ea832b91092 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/defaultExport.ts @@ -0,0 +1,5 @@ +// Test file: Default export +const helper = () => 'helper'; +export default function main() { + return helper(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/functionExports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/functionExports.ts new file mode 100644 index 00000000000..5fde2afb6bb --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/functionExports.ts @@ -0,0 +1,4 @@ +// Test file: Multiple function exports +export function myFunction() { + return 'function'; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/multipleDeclarators.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/multipleDeclarators.ts new file mode 100644 index 00000000000..de781e6b09d --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/multipleDeclarators.ts @@ -0,0 +1,5 @@ +// Test file: Export with declaration containing multiple declarators +export const a = 1; +export function processA() { + return a * 2; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/namedExports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/namedExports.ts new file mode 100644 index 00000000000..b0160485dd2 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/namedExports.ts @@ -0,0 +1,3 @@ +// Test file: Multiple named exports - some should be removed +export const foo = () => 'foo'; +export const bar = () => 'bar'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/reExports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/reExports.ts new file mode 100644 index 00000000000..a51df7f7cce --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/reExports.ts @@ -0,0 +1,3 @@ +// Test file: Re-exports from other modules +export { foo } from './source'; +export { helper as renamedHelper } from './helpers'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/serverOnlyCode.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/serverOnlyCode.ts new file mode 100644 index 00000000000..4c046f08b45 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/serverOnlyCode.ts @@ -0,0 +1,13 @@ +// Test file: Complex module with server-only code (real-world scenario) +// server-only + +// This is isomorphic - used on both client and server +export const formatUser = (user: { + name: string; +}) => { + return user.name.toUpperCase(); +}; + +// This is server-only - should be eliminated when only formatUser is imported + +// This is also server-only \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/starReExport.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/starReExport.ts new file mode 100644 index 00000000000..b002be34798 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/exports/starReExport.ts @@ -0,0 +1,3 @@ +// Test file: Star re-exports should be left as-is +export * from './source'; +export { foo } from './other'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/defaultImport.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/defaultImport.ts new file mode 100644 index 00000000000..5db85f20bc1 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/defaultImport.ts @@ -0,0 +1,6 @@ +// Test file: Default import from relative module +import utils from "./utils?tss-split-exports=default"; +import config from "../config?tss-split-exports=default"; +export function main() { + return utils.process(config); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/existingQuery.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/existingQuery.ts new file mode 100644 index 00000000000..a32a8bdb99b --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/existingQuery.ts @@ -0,0 +1,6 @@ +// Test file: Imports with existing query string +import { foo } from "./utils?v=123&tss-split-exports=foo"; +import { bar } from "./helpers?tss-split-exports=bar"; +export function main() { + return foo() + bar(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/externalImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/externalImports.ts new file mode 100644 index 00000000000..16c36381634 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/externalImports.ts @@ -0,0 +1,7 @@ +// Test file: External (node_modules) imports should be skipped +import { useState } from 'react'; +import { foo } from "./utils?tss-split-exports=foo"; +export function Component() { + const [state] = useState(foo()); + return state; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/mixedImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/mixedImports.ts new file mode 100644 index 00000000000..35991579011 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/mixedImports.ts @@ -0,0 +1,5 @@ +// Test file: Mixed default and named imports +import utils, { foo, bar } from "./utils?tss-split-exports=bar,default,foo"; +export function main() { + return utils.process() + foo() + bar(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/mixedTypeValueImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/mixedTypeValueImports.ts new file mode 100644 index 00000000000..7007d8d2804 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/mixedTypeValueImports.ts @@ -0,0 +1,6 @@ +// Test file: Mixed type and value imports in same declaration +import { type UserType, type Config, processUser, formatData } from "./utils?tss-split-exports=formatData,processUser"; +import { type HelperType, helper } from "./helpers?tss-split-exports=helper"; +export function main(user: UserType, config: Config) { + return processUser(user) + formatData(config) + helper(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/namedImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/namedImports.ts new file mode 100644 index 00000000000..f000121742a --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/namedImports.ts @@ -0,0 +1,6 @@ +// Test file: Named imports from relative module +import { foo, bar } from "./utils?tss-split-exports=bar,foo"; +import { helper } from "../shared/helpers?tss-split-exports=helper"; +export function main() { + return foo() + bar() + helper(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/typeOnlyImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/typeOnlyImports.ts new file mode 100644 index 00000000000..116476436a9 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/snapshots/imports/typeOnlyImports.ts @@ -0,0 +1,6 @@ +// Test file: Type-only imports should be skipped +import type { UserType } from './types'; +import { processUser } from "./utils?tss-split-exports=processUser"; +export function main(user: UserType) { + return processUser(user); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/split-exports-plugin/split-exports.test.ts b/packages/start-plugin-core/tests/split-exports-plugin/split-exports.test.ts new file mode 100644 index 00000000000..5ed83d655b1 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/split-exports.test.ts @@ -0,0 +1,1168 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, test, vi } from 'vitest' + +import { + extractImportsFromModule, + getAllExportNames, + hasClassExports, + isBareModuleSpecifier, + transformExports, + transformImports, +} from '../../src/split-exports-plugin/compiler' + +import { + isParseableFile, + shouldExclude, +} from '../../src/split-exports-plugin/plugin-utils' +import { + appendSplitExportsQuery, + extractSplitExportsQuery, + hasSplitExportsQuery, + parseSplitExportsQuery, + removeSplitExportsQuery, + SPLIT_EXPORTS_QUERY_KEY, +} from '../../src/split-exports-plugin/query-utils' + +describe('split-exports-plugin compiler', () => { + describe('query string utilities', () => { + describe('hasSplitExportsQuery', () => { + test('returns true for query with ?', () => { + expect( + hasSplitExportsQuery(`./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar`), + ).toBe(true) + }) + + test('returns true for query with &', () => { + expect( + hasSplitExportsQuery(`./foo?v=1&${SPLIT_EXPORTS_QUERY_KEY}=bar`), + ).toBe(true) + }) + + test('returns false when no query', () => { + expect(hasSplitExportsQuery('./foo')).toBe(false) + }) + + test('returns false for different query', () => { + expect(hasSplitExportsQuery('./foo?other=bar')).toBe(false) + }) + }) + + describe('parseSplitExportsQuery', () => { + test('parses single export', () => { + const result = parseSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar`, + ) + expect(result).toEqual(new Set(['bar'])) + }) + + test('parses multiple exports', () => { + const result = parseSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar,baz,qux`, + ) + expect(result).toEqual(new Set(['bar', 'baz', 'qux'])) + }) + + test('parses with existing query params', () => { + const result = parseSplitExportsQuery( + `./foo?v=1&${SPLIT_EXPORTS_QUERY_KEY}=bar,baz`, + ) + expect(result).toEqual(new Set(['bar', 'baz'])) + }) + + test('returns null when no query', () => { + expect(parseSplitExportsQuery('./foo')).toBeNull() + }) + + test('handles default export', () => { + const result = parseSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=default,bar`, + ) + expect(result).toEqual(new Set(['default', 'bar'])) + }) + + test('decodes URL-encoded characters in export names', () => { + // Round-trip: encoded names should be decoded back to original + // $ and unicode characters are valid in JS identifiers but get URL-encoded + const result = parseSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=%24helper,normal,%CE%B1%CE%B2%CE%B3`, + ) + expect(result).toEqual(new Set(['normal', '$helper', 'αβγ'])) + }) + }) + + describe('removeSplitExportsQuery', () => { + test('removes query when it is the only param', () => { + const result = removeSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar`, + ) + expect(result).toBe('./foo') + }) + + test('removes query and preserves other params (our query first)', () => { + const result = removeSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar&v=1`, + ) + expect(result).toBe('./foo?v=1') + }) + + test('removes query and preserves other params (our query last)', () => { + const result = removeSplitExportsQuery( + `./foo?v=1&${SPLIT_EXPORTS_QUERY_KEY}=bar`, + ) + expect(result).toBe('./foo?v=1') + }) + + test('removes query and preserves other params (our query middle)', () => { + const result = removeSplitExportsQuery( + `./foo?v=1&${SPLIT_EXPORTS_QUERY_KEY}=bar&x=2`, + ) + expect(result).toBe('./foo?v=1&x=2') + }) + + test('returns unchanged when no query', () => { + expect(removeSplitExportsQuery('./foo')).toBe('./foo') + }) + }) + + describe('appendSplitExportsQuery', () => { + test('appends with ? when no existing query', () => { + const result = appendSplitExportsQuery('./foo', new Set(['bar', 'baz'])) + expect(result).toBe(`./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar,baz`) + }) + + test('appends with & when query exists', () => { + const result = appendSplitExportsQuery( + './foo?v=1', + new Set(['bar', 'baz']), + ) + expect(result).toBe(`./foo?v=1&${SPLIT_EXPORTS_QUERY_KEY}=bar,baz`) + }) + + test('sorts export names alphabetically', () => { + const result = appendSplitExportsQuery( + './foo', + new Set(['zeta', 'alpha', 'beta']), + ) + expect(result).toBe(`./foo?${SPLIT_EXPORTS_QUERY_KEY}=alpha,beta,zeta`) + }) + + test('URL-encodes special characters in export names', () => { + // $ and unicode characters are valid in JS identifiers but get URL-encoded + const result = appendSplitExportsQuery( + './foo', + new Set(['normal', '$helper', 'αβγ']), + ) + expect(result).toBe( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=%24helper,normal,%CE%B1%CE%B2%CE%B3`, + ) + }) + + test('returns unchanged when no exports', () => { + expect(appendSplitExportsQuery('./foo', new Set())).toBe('./foo') + }) + }) + + describe('extractSplitExportsQuery', () => { + test('extracts clean ID and export names', () => { + const result = extractSplitExportsQuery( + `./foo?${SPLIT_EXPORTS_QUERY_KEY}=bar,baz`, + ) + expect(result.cleanId).toBe('./foo') + expect(result.exportNames).toEqual(new Set(['bar', 'baz'])) + }) + + test('handles no query', () => { + const result = extractSplitExportsQuery('./foo') + expect(result.cleanId).toBe('./foo') + expect(result.exportNames).toEqual(new Set()) + }) + }) + }) + + describe('isBareModuleSpecifier', () => { + describe('returns true for bare modules (npm packages)', () => { + test('simple package name', () => { + expect(isBareModuleSpecifier('lodash')).toBe(true) + }) + + test('package with subpath', () => { + expect(isBareModuleSpecifier('lodash/get')).toBe(true) + }) + + test('scoped package', () => { + expect(isBareModuleSpecifier('@tanstack/react-router')).toBe(true) + }) + + test('scoped package with subpath', () => { + expect(isBareModuleSpecifier('@tanstack/react-router/client')).toBe( + true, + ) + }) + + test('scoped package with hyphen in scope', () => { + expect(isBareModuleSpecifier('@some-scope/package')).toBe(true) + }) + + test('scoped package with numbers in scope', () => { + expect(isBareModuleSpecifier('@scope123/package')).toBe(true) + }) + + test('react-dom/client', () => { + expect(isBareModuleSpecifier('react-dom/client')).toBe(true) + }) + }) + + describe('returns false for relative/absolute paths', () => { + test('relative current directory', () => { + expect(isBareModuleSpecifier('./foo')).toBe(false) + }) + + test('relative parent directory', () => { + expect(isBareModuleSpecifier('../bar')).toBe(false) + }) + + test('absolute path', () => { + expect(isBareModuleSpecifier('/absolute/path')).toBe(false) + }) + }) + + describe('returns false for path aliases', () => { + test('tilde alias', () => { + expect(isBareModuleSpecifier('~/utils')).toBe(false) + }) + + test('tilde alias with deep path', () => { + expect(isBareModuleSpecifier('~/utils/seo')).toBe(false) + }) + + test('@ alias (empty scope)', () => { + expect(isBareModuleSpecifier('@/components')).toBe(false) + }) + + test('@ alias with deep path', () => { + expect(isBareModuleSpecifier('@/components/Button')).toBe(false) + }) + + test('hash alias', () => { + expect(isBareModuleSpecifier('#/internal')).toBe(false) + }) + + test('hash alias with deep path', () => { + expect(isBareModuleSpecifier('#/internal/utils')).toBe(false) + }) + + test('PascalCase alias (not valid npm scope)', () => { + expect(isBareModuleSpecifier('@Components/Button')).toBe(false) + }) + + test('uppercase alias', () => { + expect(isBareModuleSpecifier('@UI/Modal')).toBe(false) + }) + + test('bare @ without slash', () => { + expect(isBareModuleSpecifier('@')).toBe(false) + }) + }) + }) + + describe('hasClassExports', () => { + describe('returns true for class exports', () => { + test('export class Foo {}', () => { + expect(hasClassExports('export class Foo {}')).toBe(true) + }) + + test('export default class Foo {}', () => { + expect(hasClassExports('export default class Foo {}')).toBe(true) + }) + + test('export default class {} (anonymous)', () => { + expect(hasClassExports('export default class {}')).toBe(true) + }) + + test('class Foo {}; export { Foo }', () => { + expect(hasClassExports('class Foo {}; export { Foo }')).toBe(true) + }) + + test('class Foo {}; export { Foo as Bar }', () => { + expect(hasClassExports('class Foo {}; export { Foo as Bar }')).toBe( + true, + ) + }) + + test('export const Foo = class {}', () => { + expect(hasClassExports('export const Foo = class {}')).toBe(true) + }) + + test('export const Bar = class BarClass {}', () => { + expect(hasClassExports('export const Bar = class BarClass {}')).toBe( + true, + ) + }) + + test('const Foo = class {}; export { Foo }', () => { + expect(hasClassExports('const Foo = class {}; export { Foo }')).toBe( + true, + ) + }) + + test('export const A = 1, B = class {}', () => { + expect(hasClassExports('export const A = 1, B = class {}')).toBe(true) + }) + + test('mixed exports with class', () => { + const code = ` + export function foo() {} + export const bar = 1 + export class MyClass {} + ` + expect(hasClassExports(code)).toBe(true) + }) + }) + + describe('returns false for non-class exports', () => { + test('export function foo() {}', () => { + expect(hasClassExports('export function foo() {}')).toBe(false) + }) + + test('export const foo = 1', () => { + expect(hasClassExports('export const foo = 1')).toBe(false) + }) + + test('export default function foo() {}', () => { + expect(hasClassExports('export default function foo() {}')).toBe(false) + }) + + test('export default 42', () => { + expect(hasClassExports('export default 42')).toBe(false) + }) + + test('class Foo {} (no export)', () => { + expect(hasClassExports('class Foo {}')).toBe(false) + }) + + test('const Foo = class {} (no export)', () => { + expect(hasClassExports('const Foo = class {}')).toBe(false) + }) + + test('export { Foo } from "./other" (re-export)', () => { + expect(hasClassExports('export { Foo } from "./other"')).toBe(false) + }) + + test('export * from "./other" (wildcard re-export)', () => { + expect(hasClassExports('export * from "./other"')).toBe(false) + }) + + test('no class keyword at all', () => { + expect( + hasClassExports('export const x = 1; export function y() {}'), + ).toBe(false) + }) + + test('class in string literal', () => { + expect(hasClassExports('export const x = "class Foo {}"')).toBe(false) + }) + + test('class in comment', () => { + expect(hasClassExports('// class Foo {}\nexport const x = 1')).toBe( + false, + ) + }) + }) + }) + + describe('extractImportsFromModule', () => { + test('extracts named imports from relative paths', () => { + const code = `import { foo, bar } from './utils'` + const { imports } = extractImportsFromModule(code) + expect(imports.get('./utils')).toEqual(new Set(['foo', 'bar'])) + }) + + test('extracts default imports', () => { + const code = `import utils from './utils'` + const { imports } = extractImportsFromModule(code) + expect(imports.get('./utils')).toEqual(new Set(['default'])) + }) + + test('extracts mixed default and named imports', () => { + const code = `import utils, { foo, bar } from './utils'` + const { imports } = extractImportsFromModule(code) + expect(imports.get('./utils')).toEqual(new Set(['default', 'foo', 'bar'])) + }) + + test('skips type-only imports', () => { + const code = `import type { UserType } from './types'` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(0) + }) + + test('skips type-only specifiers in mixed imports', () => { + const code = `import { type UserType, processUser } from './utils'` + const { imports } = extractImportsFromModule(code) + expect(imports.get('./utils')).toEqual(new Set(['processUser'])) + }) + + test('skips imports with all type-only specifiers', () => { + const code = `import { type UserType, type Config } from './types'` + const { imports } = extractImportsFromModule(code) + // No entry for this source since all specifiers are type-only + expect(imports.has('./types')).toBe(false) + }) + + test('skips namespace imports', () => { + const code = `import * as utils from './utils'` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(0) + }) + + test('skips side-effect imports', () => { + const code = `import './polyfill'` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(0) + }) + + test('skips external (node_modules) imports', () => { + const code = `import { useState } from 'react'` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(0) + }) + + test('handles multiple import sources', () => { + const code = ` + import { foo } from './utils' + import { bar } from './helpers' + ` + const { imports } = extractImportsFromModule(code) + expect(imports.get('./utils')).toEqual(new Set(['foo'])) + expect(imports.get('./helpers')).toEqual(new Set(['bar'])) + }) + + test('skips imports that already have split-exports query', () => { + const code = `import { foo } from './utils?${SPLIT_EXPORTS_QUERY_KEY}=foo'` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(0) + }) + + test('extracts alias imports', () => { + const code = ` + import { foo } from './relative' + import { bar } from '~/utils/bar' + import { baz } from '@/components/Button' + ` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(3) + expect(imports.get('./relative')).toEqual(new Set(['foo'])) + expect(imports.get('~/utils/bar')).toEqual(new Set(['bar'])) + expect(imports.get('@/components/Button')).toEqual(new Set(['baz'])) + }) + + test('skips npm packages', () => { + const code = ` + import { foo } from './relative' + import { useState } from 'react' + import { useQuery } from '@tanstack/react-query' + import { bar } from '~/utils/bar' + ` + const { imports } = extractImportsFromModule(code) + expect(imports.size).toBe(2) + expect(imports.get('./relative')).toEqual(new Set(['foo'])) + expect(imports.get('~/utils/bar')).toEqual(new Set(['bar'])) + // npm packages should be excluded + expect(imports.has('react')).toBe(false) + expect(imports.has('@tanstack/react-query')).toBe(false) + }) + + test('extracts hash alias imports', () => { + const code = ` + import { internal } from '#/lib/internal' + ` + const { imports } = extractImportsFromModule(code) + expect(imports.get('#/lib/internal')).toEqual(new Set(['internal'])) + }) + + test('handles mixed scenario with aliases, npm packages, and namespace imports', () => { + const code = ` + import { seo } from '~/utils/seo' + import type { SEOProps } from '~/utils/seo' + import { Link } from '@tanstack/react-router' + import { db } from './db' + import * as utils from '~/all-utils' + ` + const { imports } = extractImportsFromModule(code) + // Should include: + // - '~/utils/seo' with 'seo' (type-only specifier excluded) + // - './db' with 'db' + // Should exclude: + // - '@tanstack/react-router' (npm package) + // - '~/all-utils' (namespace import) + expect(imports.size).toBe(2) + expect(imports.get('~/utils/seo')).toEqual(new Set(['seo'])) + expect(imports.get('./db')).toEqual(new Set(['db'])) + }) + }) + + describe('transformImports', () => { + test('transforms named imports', () => { + const code = `import { foo, bar } from './utils'` + const result = transformImports(code, 'test.ts') + expect(result).not.toBeNull() + expect(result!.code).toContain( + `./utils?${SPLIT_EXPORTS_QUERY_KEY}=bar,foo`, + ) + }) + + test('transforms default imports', () => { + const code = `import utils from './utils'` + const result = transformImports(code, 'test.ts') + expect(result).not.toBeNull() + expect(result!.code).toContain( + `./utils?${SPLIT_EXPORTS_QUERY_KEY}=default`, + ) + }) + + test('handles mixed imports from same source', () => { + const code = `import utils, { foo } from './utils'` + const result = transformImports(code, 'test.ts') + expect(result).not.toBeNull() + expect(result!.code).toContain( + `./utils?${SPLIT_EXPORTS_QUERY_KEY}=default,foo`, + ) + }) + + test('handles mixed type and value imports in same declaration', () => { + const code = `import { type UserType, type Config, processUser, formatData } from './utils'` + const result = transformImports(code, 'test.ts') + expect(result).not.toBeNull() + // Should only include value imports in the query, not type imports + expect(result!.code).toContain( + `./utils?${SPLIT_EXPORTS_QUERY_KEY}=formatData,processUser`, + ) + // Type imports should not be in the query string + expect(result!.code).not.toContain('tss-split-exports=UserType') + expect(result!.code).not.toContain('tss-split-exports=Config') + expect(result!.code).not.toContain(',UserType') + expect(result!.code).not.toContain(',Config') + }) + + test('preserves existing query params', () => { + const code = `import { foo } from './utils?v=123'` + const result = transformImports(code, 'test.ts') + expect(result).not.toBeNull() + expect(result!.code).toContain( + `./utils?v=123&${SPLIT_EXPORTS_QUERY_KEY}=foo`, + ) + }) + + test('does not transform namespace imports', () => { + const code = `import * as utils from './utils'` + const result = transformImports(code, 'test.ts') + expect(result).toBeNull() + }) + + test('does not transform external imports', () => { + const code = `import { useState } from 'react'` + const result = transformImports(code, 'test.ts') + expect(result).toBeNull() + }) + + test('does not transform type-only imports', () => { + const code = `import type { User } from './types'` + const result = transformImports(code, 'test.ts') + expect(result).toBeNull() + }) + + test('does not transform imports with all type-only specifiers', () => { + const code = `import { type UserType, type Config } from './types'` + const result = transformImports(code, 'test.ts') + // No value imports to transform, so return null + expect(result).toBeNull() + }) + + test('transforms multiple import statements', () => { + const code = ` + import { foo } from './utils' + import { bar } from './helpers' + ` + const result = transformImports(code, 'test.ts') + expect(result).not.toBeNull() + expect(result!.code).toContain(`./utils?${SPLIT_EXPORTS_QUERY_KEY}=foo`) + expect(result!.code).toContain(`./helpers?${SPLIT_EXPORTS_QUERY_KEY}=bar`) + }) + }) + + describe('transformExports', () => { + test('returns null when all exports are kept', () => { + const code = ` + export const foo = () => 'foo' + export const bar = () => 'bar' + ` + const result = transformExports(code, 'test.ts', new Set(['foo', 'bar'])) + expect(result).toBeNull() + }) + + test('keeps only requested named exports', () => { + const code = ` + export const foo = () => 'foo' + export const bar = () => 'bar' + export const baz = () => 'baz' + ` + const result = transformExports(code, 'test.ts', new Set(['foo'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export const foo') + expect(result!.code).not.toContain('export const bar') + expect(result!.code).not.toContain('export const baz') + }) + + test('keeps default export when requested', () => { + const code = ` + export default function main() { return 'main' } + export const unused = 'unused' + ` + const result = transformExports(code, 'test.ts', new Set(['default'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export default function main') + expect(result!.code).not.toContain('export const unused') + }) + + test('removes default export when not requested', () => { + const code = ` + export default function main() { return 'main' } + export const used = 'used' + ` + const result = transformExports(code, 'test.ts', new Set(['used'])) + expect(result).not.toBeNull() + expect(result!.code).not.toContain('export default') + expect(result!.code).toContain('export const used') + }) + + test('handles re-exports', () => { + const code = ` + export { foo, bar } from './source' + ` + const result = transformExports(code, 'test.ts', new Set(['foo'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('foo') + expect(result!.code).not.toContain('bar') + }) + + test('preserves star re-exports', () => { + const code = ` + export * from './source' + export { foo, bar } from './other' + ` + // Keep only 'foo', so 'bar' should be removed but 'export *' should stay + const result = transformExports(code, 'test.ts', new Set(['foo'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export * from') + expect(result!.code).toContain('foo') + expect(result!.code).not.toContain('bar') + }) + + test('handles multiple declarators in single export', () => { + const code = `export const a = 1, b = 2, c = 3` + const result = transformExports(code, 'test.ts', new Set(['a', 'c'])) + expect(result).not.toBeNull() + // Both a and c should be exported (may be combined in single declaration) + expect(result!.code).toContain('export const a') + expect(result!.code).toContain('c = 3') + // b should not be exported + expect(result!.code).not.toContain('export const b') + expect(result!.code).not.toContain('b = 2') + }) + + test('runs dead code elimination', () => { + const code = ` + const helper = () => 'helper' + const unused = () => 'unused' + export const foo = () => helper() + export const bar = () => unused() + ` + const result = transformExports(code, 'test.ts', new Set(['foo'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('helper') + expect(result!.code).not.toContain('unused') + }) + + test('handles function exports', () => { + const code = ` + export function myFunc() { return 'func' } + export function unused() { return 'unused' } + ` + const result = transformExports(code, 'test.ts', new Set(['myFunc'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export function myFunc') + expect(result!.code).not.toContain('unused') + }) + + test('handles class exports', () => { + const code = ` + export class MyClass { getValue() { return 'class' } } + export class Unused { getValue() { return 'unused' } } + ` + const result = transformExports(code, 'test.ts', new Set(['MyClass'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export class MyClass') + expect(result!.code).not.toContain('export class Unused') + }) + + test('real-world: eliminates server-only code', () => { + const code = ` + import { db } from './db' + + export const formatUser = (user) => user.name.toUpperCase() + + export const getUser = async (id) => db.users.findOne({ id }) + + export const deleteUser = async (id) => db.users.delete({ id }) + ` + const result = transformExports(code, 'test.ts', new Set(['formatUser'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export const formatUser') + expect(result!.code).not.toContain('getUser') + expect(result!.code).not.toContain('deleteUser') + // db import should be eliminated by DCE since nothing uses it + expect(result!.code).not.toContain('db') + }) + }) + + describe('snapshot tests', () => { + describe('import transformations', async () => { + const testFilesDir = path.resolve(import.meta.dirname, './test-files') + const importTestFiles = [ + 'namedImports.ts', + 'defaultImport.ts', + 'mixedImports.ts', + 'mixedTypeValueImports.ts', + 'typeOnlyImports.ts', + 'externalImports.ts', + 'existingQuery.ts', + ] + + for (const filename of importTestFiles) { + test(`transforms imports in ${filename}`, async () => { + const filePath = path.resolve(testFilesDir, filename) + const code = await readFile(filePath, 'utf-8') + const result = transformImports(code, filename) + + if (result) { + await expect(result.code).toMatchFileSnapshot( + `./snapshots/imports/${filename}`, + ) + } else { + // For files that should not be transformed, create a snapshot of "null" + await expect('// No transformation needed').toMatchFileSnapshot( + `./snapshots/imports/${filename}`, + ) + } + }) + } + }) + + describe('export transformations', async () => { + const testFilesDir = path.resolve(import.meta.dirname, './test-files') + + const exportTestCases = [ + { file: 'namedExports.ts', keep: ['foo', 'bar'] }, + { file: 'defaultExport.ts', keep: ['default'] }, + { file: 'reExports.ts', keep: ['foo', 'renamedHelper'] }, + { file: 'starReExport.ts', keep: ['foo'] }, + { file: 'multipleDeclarators.ts', keep: ['a', 'processA'] }, + { file: 'functionExports.ts', keep: ['myFunction'] }, + { file: 'serverOnlyCode.ts', keep: ['formatUser'] }, + ] + + for (const { file, keep } of exportTestCases) { + test(`transforms exports in ${file} keeping [${keep.join(', ')}]`, async () => { + const filePath = path.resolve(testFilesDir, file) + const code = await readFile(filePath, 'utf-8') + const result = transformExports(code, file, new Set(keep)) + + expect(result).not.toBeNull() + await expect(result!.code).toMatchFileSnapshot( + `./snapshots/exports/${file}`, + ) + }) + } + }) + }) + + describe('getAllExportNames', () => { + test('extracts named exports from const declarations', () => { + const code = ` + export const foo = 1 + export const bar = 2 + ` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['foo', 'bar'])) + expect(result.hasWildcardReExport).toBe(false) + }) + + test('extracts multiple declarators from single export', () => { + const code = `export const a = 1, b = 2, c = 3` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['a', 'b', 'c'])) + }) + + test('extracts function exports', () => { + const code = ` + export function myFunc() { return 1 } + export function otherFunc() { return 2 } + ` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['myFunc', 'otherFunc'])) + }) + + test('extracts class exports', () => { + const code = ` + export class MyClass {} + export class OtherClass {} + ` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['MyClass', 'OtherClass'])) + }) + + test('extracts default export', () => { + const code = `export default function main() { return 1 }` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['default'])) + }) + + test('extracts re-exports', () => { + const code = `export { foo, bar as baz } from './source'` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['foo', 'baz'])) + }) + + test('extracts export specifiers without source', () => { + const code = ` + const foo = 1 + const bar = 2 + export { foo, bar as baz } + ` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual(new Set(['foo', 'baz'])) + }) + + test('detects wildcard re-exports', () => { + const code = ` + export * from './source' + export const foo = 1 + ` + const result = getAllExportNames(code) + expect(result.hasWildcardReExport).toBe(true) + // Still collects the named exports it can enumerate + expect(result.exportNames).toContain('foo') + }) + + test('handles mixed export types', () => { + const code = ` + export const foo = 1 + export function bar() {} + export class Baz {} + export default function main() {} + export { qux } from './source' + ` + const result = getAllExportNames(code) + expect(result.exportNames).toEqual( + new Set(['foo', 'bar', 'Baz', 'default', 'qux']), + ) + }) + + test('returns the parsed AST for reuse', () => { + const code = `export const foo = 1` + const result = getAllExportNames(code) + expect(result.ast).toBeDefined() + expect(result.ast.program).toBeDefined() + }) + }) + + describe('transformExports early bailout', () => { + test('bails out early when all exports are requested (no transformation)', () => { + const code = ` + export const foo = 1 + export const bar = 2 + export function baz() {} + ` + // Request all exports + const result = transformExports( + code, + 'test.ts', + new Set(['foo', 'bar', 'baz']), + ) + expect(result).toBeNull() + }) + + test('bails out early when requesting more exports than exist', () => { + const code = ` + export const foo = 1 + export const bar = 2 + ` + // Request all exports plus extra ones that don't exist + const result = transformExports( + code, + 'test.ts', + new Set(['foo', 'bar', 'extra', 'nonexistent']), + ) + expect(result).toBeNull() + }) + + test('does not bail out when some exports are not requested', () => { + const code = ` + export const foo = 1 + export const bar = 2 + export const baz = 3 + ` + // Only request some exports + const result = transformExports(code, 'test.ts', new Set(['foo', 'bar'])) + expect(result).not.toBeNull() + expect(result!.code).toContain('export const foo') + expect(result!.code).toContain('export const bar') + expect(result!.code).not.toContain('export const baz') + }) + + test('does not bail out with wildcard re-exports even if named exports match', () => { + const code = ` + export * from './source' + export const foo = 1 + ` + // We can't know what's in 'export *', so we must transform + // Even though 'foo' is in the request, we can't bail out + const result = transformExports(code, 'test.ts', new Set(['foo'])) + // Should still transform because we can't enumerate all exports + // The result could be null if foo is kept and export * is preserved as-is + // Let's just verify it handles this case without errors + // (The implementation preserves export * and removes non-requested named exports) + if (result) { + expect(result.code).toContain('export * from') + } + }) + + test('bails out for default export only module when default is requested', () => { + const code = `export default function main() { return 1 }` + const result = transformExports(code, 'test.ts', new Set(['default'])) + expect(result).toBeNull() + }) + + test('bails out for complex module when all exports are requested', () => { + const code = ` + const helper = () => 'helper' + export const foo = () => helper() + export function bar() { return 'bar' } + export class Baz {} + export default { foo: 'default' } + export { qux as quux } from './other' + ` + const result = transformExports( + code, + 'test.ts', + new Set(['foo', 'bar', 'Baz', 'default', 'quux']), + ) + expect(result).toBeNull() + }) + }) +}) + +describe('split-exports-plugin utils', () => { + describe('isParseableFile', () => { + const parseableExtensions = [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', + ] + const nonParseableExtensions = [ + '.css', + '.scss', + '.sass', + '.less', + '.png', + '.jpg', + '.svg', + '.woff2', + '.json', + ] + + test.each(parseableExtensions)('returns true for %s files', (ext) => { + expect(isParseableFile(`/path/to/file${ext}`)).toBe(true) + }) + + test.each(nonParseableExtensions)('returns false for %s files', (ext) => { + expect(isParseableFile(`/path/to/file${ext}`)).toBe(false) + }) + + describe('handles query strings', () => { + test('strips query string before checking extension', () => { + expect(isParseableFile('/path/to/file.ts?v=123')).toBe(true) + }) + + test('correctly identifies CSS with query string', () => { + expect(isParseableFile('/path/to/styles.css?direct')).toBe(false) + }) + + test('correctly identifies CSS with multiple query params', () => { + expect( + isParseableFile( + '/path/to/styles.css?direct&tss-split-exports=default', + ), + ).toBe(false) + }) + }) + + describe('edge cases', () => { + test('returns true for files without extension (virtual modules)', () => { + expect(isParseableFile('/virtual/module')).toBe(true) + }) + + test('handles uppercase extensions', () => { + expect(isParseableFile('/path/to/file.TS')).toBe(true) + }) + + test('handles mixed case extensions', () => { + expect(isParseableFile('/path/to/file.Tsx')).toBe(true) + }) + }) + }) + + describe('shouldExclude', () => { + describe('node_modules exclusion', () => { + test('excludes files in node_modules', () => { + expect( + shouldExclude( + '/project/node_modules/lodash/index.js', + undefined, + undefined, + ), + ).toBe(true) + }) + + test('excludes files in nested node_modules', () => { + expect( + shouldExclude( + '/project/packages/foo/node_modules/bar/index.js', + undefined, + undefined, + ), + ).toBe(true) + }) + }) + + describe('srcDirectory filtering', () => { + test('includes files inside srcDirectory', () => { + expect( + shouldExclude( + '/project/src/components/Button.tsx', + '/project/src', + undefined, + ), + ).toBe(false) + }) + + test('includes files in nested directories inside srcDirectory', () => { + expect( + shouldExclude( + '/project/src/features/auth/Login.tsx', + '/project/src', + undefined, + ), + ).toBe(false) + }) + + test('excludes files outside srcDirectory', () => { + expect( + shouldExclude( + '/monorepo/packages/react-start/src/index.ts', + '/project/src', + undefined, + ), + ).toBe(true) + }) + + test('excludes files in sibling directories', () => { + expect( + shouldExclude('/project/lib/utils.ts', '/project/src', undefined), + ).toBe(true) + }) + + test('handles query strings correctly', () => { + expect( + shouldExclude( + '/project/src/utils.ts?v=123', + '/project/src', + undefined, + ), + ).toBe(false) + }) + + test('handles query strings for excluded files', () => { + expect( + shouldExclude('/other/path/file.ts?v=123', '/project/src', undefined), + ).toBe(true) + }) + + test('works without srcDirectory (fallback behavior)', () => { + // When srcDirectory is undefined, should not exclude (except node_modules) + expect( + shouldExclude('/some/random/path/file.ts', undefined, undefined), + ).toBe(false) + }) + + test('still excludes node_modules even when srcDirectory is set', () => { + expect( + shouldExclude( + '/project/src/node_modules/bad-package/index.js', + '/project/src', + undefined, + ), + ).toBe(true) + }) + }) + + describe('custom exclude patterns', () => { + test('excludes files matching string pattern', () => { + expect( + shouldExclude('/project/src/generated/types.ts', '/project/src', [ + 'generated', + ]), + ).toBe(true) + }) + + test('excludes files matching regex pattern', () => { + expect( + shouldExclude( + '/project/src/components/Button.test.tsx', + '/project/src', + [/\.test\./], + ), + ).toBe(true) + }) + + test('does not exclude files not matching patterns', () => { + expect( + shouldExclude('/project/src/components/Button.tsx', '/project/src', [ + 'generated', + /\.test\./, + ]), + ).toBe(false) + }) + }) + + describe('path normalization', () => { + test('normalizes paths with trailing slashes', () => { + // srcDirectory might have trailing slash, file path won't + expect( + shouldExclude('/project/src/utils.ts', '/project/src/', undefined), + ).toBe(false) + }) + + test('handles paths with double slashes', () => { + expect( + shouldExclude('/project//src/utils.ts', '/project/src', undefined), + ).toBe(false) + }) + }) + }) +}) diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/defaultExport.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/defaultExport.ts new file mode 100644 index 00000000000..cb497bf5ab6 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/defaultExport.ts @@ -0,0 +1,8 @@ +// Test file: Default export +const helper = () => 'helper' + +export default function main() { + return helper() +} + +export const unused = () => 'unused' diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/defaultImport.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/defaultImport.ts new file mode 100644 index 00000000000..b4cd8edfe69 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/defaultImport.ts @@ -0,0 +1,7 @@ +// Test file: Default import from relative module +import utils from './utils' +import config from '../config' + +export function main() { + return utils.process(config) +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/existingQuery.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/existingQuery.ts new file mode 100644 index 00000000000..dbc77319cbb --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/existingQuery.ts @@ -0,0 +1,7 @@ +// Test file: Imports with existing query string +import { foo } from './utils?v=123' +import { bar } from './helpers' + +export function main() { + return foo() + bar() +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/externalImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/externalImports.ts new file mode 100644 index 00000000000..30cc2bba88a --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/externalImports.ts @@ -0,0 +1,8 @@ +// Test file: External (node_modules) imports should be skipped +import { useState } from 'react' +import { foo } from './utils' + +export function Component() { + const [state] = useState(foo()) + return state +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/functionExports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/functionExports.ts new file mode 100644 index 00000000000..b0ded78c9bb --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/functionExports.ts @@ -0,0 +1,10 @@ +// Test file: Multiple function exports +export function myFunction() { + return 'function' +} + +export function anotherFunction() { + return 'another' +} + +export const unused = 'unused' diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/mixedImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/mixedImports.ts new file mode 100644 index 00000000000..127025f7d08 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/mixedImports.ts @@ -0,0 +1,6 @@ +// Test file: Mixed default and named imports +import utils, { foo, bar } from './utils' + +export function main() { + return utils.process() + foo() + bar() +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/mixedTypeValueImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/mixedTypeValueImports.ts new file mode 100644 index 00000000000..19d34ddca73 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/mixedTypeValueImports.ts @@ -0,0 +1,7 @@ +// Test file: Mixed type and value imports in same declaration +import { type UserType, type Config, processUser, formatData } from './utils' +import { type HelperType, helper } from './helpers' + +export function main(user: UserType, config: Config) { + return processUser(user) + formatData(config) + helper() +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/multipleDeclarators.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/multipleDeclarators.ts new file mode 100644 index 00000000000..f05376c166b --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/multipleDeclarators.ts @@ -0,0 +1,8 @@ +// Test file: Export with declaration containing multiple declarators +export const a = 1, + b = 2, + c = 3 + +export function processA() { + return a * 2 +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/namedExports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/namedExports.ts new file mode 100644 index 00000000000..40da3538405 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/namedExports.ts @@ -0,0 +1,7 @@ +// Test file: Multiple named exports - some should be removed +export const foo = () => 'foo' +export const bar = () => 'bar' +export const baz = () => 'baz' + +const internal = 'internal' +export const qux = () => internal diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/namedImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/namedImports.ts new file mode 100644 index 00000000000..c8e8989e38f --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/namedImports.ts @@ -0,0 +1,7 @@ +// Test file: Named imports from relative module +import { foo, bar } from './utils' +import { helper } from '../shared/helpers' + +export function main() { + return foo() + bar() + helper() +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/namespaceImport.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/namespaceImport.ts new file mode 100644 index 00000000000..c4275444a9d --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/namespaceImport.ts @@ -0,0 +1,6 @@ +// Test file: Namespace imports should be skipped +import * as utils from './utils' + +export function main() { + return utils.foo() + utils.bar() +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/reExports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/reExports.ts new file mode 100644 index 00000000000..2957057e409 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/reExports.ts @@ -0,0 +1,4 @@ +// Test file: Re-exports from other modules +export { foo, bar } from './source' +export { helper as renamedHelper } from './helpers' +export { default as utils } from './utils' diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/serverOnlyCode.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/serverOnlyCode.ts new file mode 100644 index 00000000000..4a5f5e88f83 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/serverOnlyCode.ts @@ -0,0 +1,17 @@ +// Test file: Complex module with server-only code (real-world scenario) +import { db } from './db' // server-only + +// This is isomorphic - used on both client and server +export const formatUser = (user: { name: string }) => { + return user.name.toUpperCase() +} + +// This is server-only - should be eliminated when only formatUser is imported +export const getUser = async (id: string) => { + return db.users.findOne({ id }) +} + +// This is also server-only +export const deleteUser = async (id: string) => { + return db.users.delete({ id }) +} diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/starReExport.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/starReExport.ts new file mode 100644 index 00000000000..01d0f7b8cb4 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/starReExport.ts @@ -0,0 +1,3 @@ +// Test file: Star re-exports should be left as-is +export * from './source' +export { foo, bar } from './other' diff --git a/packages/start-plugin-core/tests/split-exports-plugin/test-files/typeOnlyImports.ts b/packages/start-plugin-core/tests/split-exports-plugin/test-files/typeOnlyImports.ts new file mode 100644 index 00000000000..ff422a0bb14 --- /dev/null +++ b/packages/start-plugin-core/tests/split-exports-plugin/test-files/typeOnlyImports.ts @@ -0,0 +1,7 @@ +// Test file: Type-only imports should be skipped +import type { UserType } from './types' +import { processUser } from './utils' + +export function main(user: UserType) { + return processUser(user) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f20ebf80cc..cf6995fb5fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2039,6 +2039,67 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/split-exports: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.15 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + srvx: + specifier: ^0.8.6 + version: 0.8.15 + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/virtual-routes: dependencies: '@tanstack/react-router':