diff --git a/e2e/react-start/basic/src/components/WindowSize.tsx b/e2e/react-start/basic/src/components/WindowSize.tsx new file mode 100644 index 00000000000..5365cec5c94 --- /dev/null +++ b/e2e/react-start/basic/src/components/WindowSize.tsx @@ -0,0 +1,19 @@ +/** + * This component accesses `window` at module scope, which would throw + * if this module is ever imported on the server. The compiler optimization + * for ensures this module is DCE'd from the server bundle + * entirely, preventing the error. + */ + +// This throws at module import time on the server since `window` doesn't exist +const initialWidth = window.innerWidth +const initialHeight = window.innerHeight + +export function WindowSize() { + return ( +
+

Window width: {initialWidth}

+

Window height: {initialHeight}

+
+ ) +} diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index ba75e2d05fb..19c499b986d 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as ClientOnlyRouteImport } from './routes/client-only' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' @@ -92,6 +93,11 @@ const DeferredRoute = DeferredRouteImport.update({ path: '/deferred', getParentRoute: () => rootRouteImport, } as any) +const ClientOnlyRoute = ClientOnlyRouteImport.update({ + id: '/client-only', + path: '/client-only', + getParentRoute: () => rootRouteImport, +} as any) const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, @@ -268,6 +274,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute @@ -307,6 +314,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute @@ -346,6 +354,7 @@ export interface FileRoutesById { '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren + '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute @@ -390,6 +399,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/client-only' | '/deferred' | '/inline-scripts' | '/links' @@ -429,6 +439,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/client-only' | '/deferred' | '/inline-scripts' | '/links' @@ -467,6 +478,7 @@ export interface FileRouteTypes { | '/not-found' | '/search-params' | '/_layout' + | '/client-only' | '/deferred' | '/inline-scripts' | '/links' @@ -511,6 +523,7 @@ export interface RootRouteChildren { NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren + ClientOnlyRoute: typeof ClientOnlyRoute DeferredRoute: typeof DeferredRoute InlineScriptsRoute: typeof InlineScriptsRoute LinksRoute: typeof LinksRoute @@ -586,6 +599,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DeferredRouteImport parentRoute: typeof rootRouteImport } + '/client-only': { + id: '/client-only' + path: '/client-only' + fullPath: '/client-only' + preLoaderRoute: typeof ClientOnlyRouteImport + parentRoute: typeof rootRouteImport + } '/_layout': { id: '/_layout' path: '' @@ -955,6 +975,7 @@ const rootRouteChildren: RootRouteChildren = { NotFoundRouteRoute: NotFoundRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, + ClientOnlyRoute: ClientOnlyRoute, DeferredRoute: DeferredRoute, InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, diff --git a/e2e/react-start/basic/src/routes/__root.tsx b/e2e/react-start/basic/src/routes/__root.tsx index c7d87cad188..1e77eabfc01 100644 --- a/e2e/react-start/basic/src/routes/__root.tsx +++ b/e2e/react-start/basic/src/routes/__root.tsx @@ -165,6 +165,14 @@ function RootDocument({ children }: { children: React.ReactNode }) { > redirect {' '} + + Client Only + {' '} +

Client Only Demo

+

+ The component below uses window APIs that only exist in the + browser. +

+ Loading window size... + } + > + + + + ) +} diff --git a/e2e/react-start/basic/tests/client-only.spec.ts b/e2e/react-start/basic/tests/client-only.spec.ts new file mode 100644 index 00000000000..a3b6f99442d --- /dev/null +++ b/e2e/react-start/basic/tests/client-only.spec.ts @@ -0,0 +1,72 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +/** + * These tests verify the compiler optimization. + * + * The WindowSize component accesses `window` at module scope, which would + * throw if the module is ever imported on the server. The compiler optimization + * strips children from the server bundle, allowing DCE to remove + * the WindowSize import entirely. This prevents the server from crashing. + * + * If these tests pass in SSR mode, it proves the compiler is correctly + * removing client-only code from the server bundle. + */ + +test('ClientOnly renders fallback on server, then client content after hydration', async ({ + page, +}) => { + // Navigate directly to the client-only route + // If the compiler optimization isn't working, this would crash the server + // because WindowSize.tsx accesses `window` at module scope + await page.goto('/client-only') + await page.waitForURL('/client-only') + + // The heading should be visible + await expect(page.getByTestId('client-only-heading')).toContainText( + 'Client Only Demo', + ) + + // After hydration, the WindowSize component should render with actual values + // Wait for the client-side component to render + await expect(page.getByTestId('window-size')).toBeVisible() + await expect(page.getByTestId('window-width')).toContainText('Window width:') + await expect(page.getByTestId('window-height')).toContainText( + 'Window height:', + ) +}) + +test('ClientOnly works with client-side navigation', async ({ page }) => { + // Start from home + await page.goto('/') + await page.waitForURL('/') + + // Navigate to client-only route via client-side navigation + await page.getByRole('link', { name: 'Client Only' }).click() + await page.waitForURL('/client-only') + + // The WindowSize component should render + await expect(page.getByTestId('window-size')).toBeVisible() + await expect(page.getByTestId('window-width')).toContainText('Window width:') +}) + +test('ClientOnly component displays actual window dimensions', async ({ + page, +}) => { + // Set a specific viewport size + await page.setViewportSize({ width: 800, height: 600 }) + + await page.goto('/client-only') + await page.waitForURL('/client-only') + + // Wait for client-side hydration + await expect(page.getByTestId('window-size')).toBeVisible() + + // Check that the displayed dimensions match the viewport + await expect(page.getByTestId('window-width')).toContainText( + 'Window width: 800', + ) + await expect(page.getByTestId('window-height')).toContainText( + 'Window height: 600', + ) +}) 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 4c56d91ff69..ff203523858 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 @@ -10,6 +10,7 @@ import { handleCreateServerFn } from './handleCreateServerFn' import { handleCreateMiddleware } from './handleCreateMiddleware' import { handleCreateIsomorphicFn } from './handleCreateIsomorphicFn' import { handleEnvOnlyFn } from './handleEnvOnly' +import { handleClientOnlyJSX } from './handleClientOnlyJSX' import type { MethodChainPaths, RewriteCandidate } from './types' type Binding = @@ -38,6 +39,7 @@ export type LookupKind = | 'IsomorphicFn' | 'ServerOnlyFn' | 'ClientOnlyFn' + | 'ClientOnlyJSX' // Detection strategy for each kind type MethodChainSetup = { @@ -49,8 +51,12 @@ type MethodChainSetup = { allowRootAsCandidate?: boolean } type DirectCallSetup = { type: 'directCall' } +type JSXSetup = { type: 'jsx'; componentName: string } -const LookupSetup: Record = { +const LookupSetup: Record< + LookupKind, + MethodChainSetup | DirectCallSetup | JSXSetup +> = { ServerFn: { type: 'methodChain', candidateCallIdentifier: new Set(['handler']), @@ -66,6 +72,7 @@ const LookupSetup: Record = { }, ServerOnlyFn: { type: 'directCall' }, ClientOnlyFn: { type: 'directCall' }, + ClientOnlyJSX: { type: 'jsx', componentName: 'ClientOnly' }, } // Single source of truth for detecting which kinds are present in code @@ -78,6 +85,7 @@ export const KindDetectionPatterns: Record = { IsomorphicFn: /createIsomorphicFn/, ServerOnlyFn: /createServerOnlyFn/, ClientOnlyFn: /createClientOnlyFn/, + ClientOnlyJSX: /> = { 'IsomorphicFn', 'ServerOnlyFn', 'ClientOnlyFn', + 'ClientOnlyJSX', // Only transform on server to remove children ] as const), } @@ -169,7 +178,10 @@ interface ModuleInfo { function needsDirectCallDetection(kinds: Set): boolean { for (const kind of kinds) { const setup = LookupSetup[kind] - if (setup.type === 'directCall' || setup.allowRootAsCandidate) { + if ( + setup.type === 'directCall' || + (setup.type === 'methodChain' && setup.allowRootAsCandidate) + ) { return true } } @@ -186,6 +198,33 @@ function areAllKindsTopLevelOnly(kinds: Set): boolean { return kinds.size === 1 && kinds.has('ServerFn') } +/** + * Checks if we need to detect JSX elements (e.g., ). + */ +function needsJSXDetection(kinds: Set): boolean { + for (const kind of kinds) { + const setup = LookupSetup[kind] + if (setup.type === 'jsx') { + return true + } + } + return false +} + +/** + * Gets the set of JSX component names to detect. + */ +function getJSXComponentNames(kinds: Set): Set { + const names = new Set() + for (const kind of kinds) { + const setup = LookupSetup[kind] + if (setup.type === 'jsx') { + names.add(setup.componentName) + } + } + return names +} + /** * Checks if a CallExpression is a direct-call candidate for NESTED detection. * Returns true if the callee is a known factory function name. @@ -322,6 +361,17 @@ export class ServerFnCompiler { } libExports.set(config.rootExport, config.kind) + // For JSX lookups (e.g., ClientOnlyJSX), we only need the knownRootImports + // fast path to verify imports. Skip module resolution which may fail if + // the package isn't a direct dependency (e.g., @tanstack/react-router from + // within start-plugin-core). + if (config.kind !== 'Root') { + const setup = LookupSetup[config.kind] + if (setup.type === 'jsx') { + return + } + } + const libId = await this.resolveIdCached(config.libName) if (!libId) { throw new Error(`could not resolve "${config.libName}"`) @@ -530,6 +580,16 @@ export class ServerFnCompiler { babel.NodePath >() + // JSX candidates (e.g., ) + const jsxCandidatePaths: Array> = [] + const checkJSX = needsJSXDetection(fileKinds) + // Get target component names from JSX setup (e.g., 'ClientOnly') + const jsxTargetComponentNames = checkJSX + ? getJSXComponentNames(fileKinds) + : null + // Get module info that was just cached by ingestModule + const moduleInfo = this.moduleCache.get(id)! + if (canUseFastPath) { // Fast path: only visit top-level statements that have potential candidates @@ -632,10 +692,33 @@ export class ServerFnCompiler { } } }, + // Pattern 3: JSX element pattern (e.g., ) + // Collect JSX elements where the component name matches a known import + // that resolves to a target component (e.g., ClientOnly from @tanstack/react-router) + JSXElement: (path) => { + if (!checkJSX || !jsxTargetComponentNames) return + + const openingElement = path.node.openingElement + const nameNode = openingElement.name + + // Only handle simple identifier names (not namespaced or member expressions) + if (!t.isJSXIdentifier(nameNode)) return + + const componentName = nameNode.name + const binding = moduleInfo.bindings.get(componentName) + + // Must be an import binding + if (!binding || binding.type !== 'import') return + + // Check if the original import name matches a target component + if (jsxTargetComponentNames.has(binding.importedName)) { + jsxCandidatePaths.push(path) + } + }, }) } - if (candidatePaths.length === 0) { + if (candidatePaths.length === 0 && jsxCandidatePaths.length === 0) { return null } @@ -652,7 +735,7 @@ export class ServerFnCompiler { this.validLookupKinds.has(kind as LookupKind), ) as Array<{ path: babel.NodePath; kind: LookupKind }> - if (validCandidates.length === 0) { + if (validCandidates.length === 0 && jsxCandidatePaths.length === 0) { return null } @@ -745,6 +828,29 @@ export class ServerFnCompiler { } } + // Handle JSX candidates (e.g., ) + // Note: We only reach here on the server (ClientOnlyJSX is only in LookupKindsPerEnv.server) + // Verify import source using knownRootImports (same as function call resolution) + for (const jsxPath of jsxCandidatePaths) { + const openingElement = jsxPath.node.openingElement + const nameNode = openingElement.name + if (!t.isJSXIdentifier(nameNode)) continue + + const componentName = nameNode.name + const binding = moduleInfo.bindings.get(componentName) + if (!binding || binding.type !== 'import') continue + + // Verify the import source is a known TanStack router package + const knownExports = this.knownRootImports.get(binding.source) + if (!knownExports) continue + + // Verify the imported name resolves to ClientOnlyJSX kind + const kind = knownExports.get(binding.importedName) + if (kind !== 'ClientOnlyJSX') continue + + handleClientOnlyJSX(jsxPath, { env: 'server' }) + } + deadCodeElimination(ast, refIdents) return generateFromAst(ast, { diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/handleClientOnlyJSX.ts b/packages/start-plugin-core/src/create-server-fn-plugin/handleClientOnlyJSX.ts new file mode 100644 index 00000000000..9676e4b7bd2 --- /dev/null +++ b/packages/start-plugin-core/src/create-server-fn-plugin/handleClientOnlyJSX.ts @@ -0,0 +1,32 @@ +import type * as t from '@babel/types' +import type * as babel from '@babel/core' + +/** + * Handles JSX elements on the server side. + * + * On the server, the children of should be removed since they + * are client-only code. Only the fallback prop (if present) will be rendered. + * + * Transform: + * }>{clientOnlyContent} + * Into: + * } /> + * + * Or if no fallback: + * {clientOnlyContent} + * Into: + * + */ +export function handleClientOnlyJSX( + path: babel.NodePath, + _opts: { env: 'server' }, +): void { + const element = path.node + + // Remove all children - they are client-only code + element.children = [] + + // Make it a self-closing element since there are no children + element.openingElement.selfClosing = true + element.closingElement = null +} 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 832f2f1d19a..ca8afc1eb46 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 @@ -75,7 +75,15 @@ const getLookupConfigurationsForEnv = ( ...commonConfigs, ] } else { - return commonConfigs + // Server-only: add ClientOnly JSX component lookup + return [ + ...commonConfigs, + { + libName: `@tanstack/${framework}-router`, + rootExport: 'ClientOnly', + kind: 'ClientOnlyJSX', + }, + ] } } const SERVER_FN_LOOKUP = 'server-fn-module-lookup' diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/clientOnlyJSX.test.ts b/packages/start-plugin-core/tests/clientOnlyJSX/clientOnlyJSX.test.ts new file mode 100644 index 00000000000..33239babc01 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/clientOnlyJSX.test.ts @@ -0,0 +1,93 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, test } from 'vitest' + +import { + detectKindsInCode, + ServerFnCompiler, +} from '../../src/create-server-fn-plugin/compiler' + +async function compile(opts: { + env: 'client' | 'server' + code: string + id: string +}) { + const compiler = new ServerFnCompiler({ + ...opts, + loadModule: async () => { + // do nothing in test + }, + lookupKinds: new Set(['ClientOnlyJSX']), + lookupConfigurations: [ + // ClientOnly JSX component from TanStack router packages + { + libName: '@tanstack/react-router', + rootExport: 'ClientOnly', + kind: 'ClientOnlyJSX', + }, + ], + resolveId: async (id) => { + return id + }, + directive: 'use server', + }) + const result = await compiler.compile({ + code: opts.code, + id: opts.id, + isProviderFile: false, + }) + return result +} + +async function getFilenames() { + return await readdir(path.resolve(import.meta.dirname, './test-files')) +} + +describe('ClientOnlyJSX compiles correctly', async () => { + const filenames = await getFilenames() + + describe.each(filenames)('should handle "%s"', async (filename) => { + const file = await readFile( + path.resolve(import.meta.dirname, `./test-files/${filename}`), + ) + const code = file.toString() + + // ClientOnlyJSX is only transformed on server + test(`should compile for ${filename} server`, async () => { + const compiledResult = await compile({ + env: 'server', + code, + id: filename, + }) + + if (compiledResult) { + await expect(compiledResult.code).toMatchFileSnapshot( + `./snapshots/server/${filename}`, + ) + } else { + // No transformation - for files where ClientOnly is not from TanStack + await expect('no-transform').toMatchFileSnapshot( + `./snapshots/server/${filename}`, + ) + } + }) + }) + + test('ClientOnlyJSX should not be detected on client', () => { + const code = ` + import { ClientOnly } from '@tanstack/react-router' + test + ` + const detectedKinds = detectKindsInCode(code, 'client') + expect(detectedKinds.has('ClientOnlyJSX')).toBe(false) + }) + + test('ClientOnlyJSX should be detected on server', () => { + const code = ` + import { ClientOnly } from '@tanstack/react-router' + test + ` + const detectedKinds = detectKindsInCode(code, 'server') + expect(detectedKinds.has('ClientOnlyJSX')).toBe(true) + }) +}) diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyBasic.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyBasic.tsx new file mode 100644 index 00000000000..82195ff7392 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyBasic.tsx @@ -0,0 +1,6 @@ +import { ClientOnly } from '@tanstack/react-router'; +export function MyComponent() { + return
+ Loading...
} /> + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyMultiple.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyMultiple.tsx new file mode 100644 index 00000000000..f437f41b051 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyMultiple.tsx @@ -0,0 +1,8 @@ +import { ClientOnly } from '@tanstack/react-router'; +export function MyComponent() { + return
+ Loading video...} /> +

Some server content

+ Loading audio...} /> +
; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNested.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNested.tsx new file mode 100644 index 00000000000..9d48d6577db --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNested.tsx @@ -0,0 +1,6 @@ +import { ClientOnly } from '@tanstack/react-router'; +export function MyComponent() { + return
+ Outer loading
} /> + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNoFallback.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNoFallback.tsx new file mode 100644 index 00000000000..9224aeb1cf6 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNoFallback.tsx @@ -0,0 +1,6 @@ +import { ClientOnly } from '@tanstack/react-router'; +export function MyComponent() { + return
+ +
; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx new file mode 100644 index 00000000000..f91fe2854ca --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyNotFromTanstack.tsx @@ -0,0 +1,9 @@ +// This should NOT be transformed because ClientOnly is not from @tanstack/*-router +import { ClientOnly } from 'some-other-package'; +export function MyComponent() { + return
+ Loading...
}> +
This should remain as-is
+
+ ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyRenamed.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyRenamed.tsx new file mode 100644 index 00000000000..de9e376b635 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyRenamed.tsx @@ -0,0 +1,7 @@ +// Test: ClientOnly imported with an alias should still be transformed +import { ClientOnly as CO } from '@tanstack/react-router'; +export function MyComponent() { + return
+ Loading...
} /> + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyWrongImportName.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyWrongImportName.tsx new file mode 100644 index 00000000000..b35c36ab1b3 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/snapshots/server/clientOnlyWrongImportName.tsx @@ -0,0 +1 @@ +no-transform \ No newline at end of file diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyBasic.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyBasic.tsx new file mode 100644 index 00000000000..29cc8888301 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyBasic.tsx @@ -0,0 +1,13 @@ +import { ClientOnly } from '@tanstack/react-router' +import { Chart } from 'heavy-chart-library' +import { formatData } from './utils' + +export function MyComponent() { + return ( +
+ Loading...
}> + +
+ + ) +} diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyMultiple.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyMultiple.tsx new file mode 100644 index 00000000000..f3712aa7b3f --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyMultiple.tsx @@ -0,0 +1,17 @@ +import { ClientOnly } from '@tanstack/react-router' +import { VideoPlayer } from 'video-player-lib' +import { AudioVisualizer } from 'audio-viz-lib' + +export function MyComponent() { + return ( +
+ Loading video...}> + + +

Some server content

+ Loading audio...}> + + +
+ ) +} diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNested.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNested.tsx new file mode 100644 index 00000000000..222def87958 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNested.tsx @@ -0,0 +1,16 @@ +import { ClientOnly } from '@tanstack/react-router' + +export function MyComponent() { + return ( +
+ Outer loading
}> +
+ Outer content + Inner loading}> + Inner client only + +
+
+ + ) +} diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNoFallback.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNoFallback.tsx new file mode 100644 index 00000000000..a76c0fde460 --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNoFallback.tsx @@ -0,0 +1,12 @@ +import { ClientOnly } from '@tanstack/react-router' +import { HeavyAnimation } from 'animation-library' + +export function MyComponent() { + return ( +
+ + + +
+ ) +} diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNotFromTanstack.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNotFromTanstack.tsx new file mode 100644 index 00000000000..06fc40a1b9c --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyNotFromTanstack.tsx @@ -0,0 +1,12 @@ +// This should NOT be transformed because ClientOnly is not from @tanstack/*-router +import { ClientOnly } from 'some-other-package' + +export function MyComponent() { + return ( +
+ Loading...
}> +
This should remain as-is
+
+ + ) +} diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyRenamed.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyRenamed.tsx new file mode 100644 index 00000000000..311c9e1b86a --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyRenamed.tsx @@ -0,0 +1,13 @@ +// Test: ClientOnly imported with an alias should still be transformed +import { ClientOnly as CO } from '@tanstack/react-router' +import { WindowSize } from './WindowSize' + +export function MyComponent() { + return ( +
+ Loading...
}> + + + + ) +} diff --git a/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyWrongImportName.tsx b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyWrongImportName.tsx new file mode 100644 index 00000000000..4bd777c079a --- /dev/null +++ b/packages/start-plugin-core/tests/clientOnlyJSX/test-files/clientOnlyWrongImportName.tsx @@ -0,0 +1,11 @@ +// Test: A different component imported from TanStack should NOT be transformed +// even if aliased to "ClientOnly" - we check the original import name +import { Link as ClientOnly } from '@tanstack/react-router' + +export function MyComponent() { + return ( +
+ Go to About +
+ ) +}