diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bbb73cf81..a34e92180 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,11 @@ "Bash(mkdir:*)", "Bash(TEST_ONLY=dev yarn vitest --config ./vitest.config.rolldown.ts --run --reporter=dot --color=false api-rolldown.test.ts)", "Bash(bun llink:*)", - "Bash(bun:*)" + "Bash(bun:*)", + "Bash(npm run dev:*)", + "Bash(npm run build:*)", + "Bash(npm run test:dev:*)", + "Bash(mv:*)" ], "deny": [] } diff --git a/packages/one/src/vite/__tests__/server-client-only.test.ts b/packages/one/src/vite/__tests__/server-client-only.test.ts new file mode 100644 index 000000000..20fa3e422 --- /dev/null +++ b/packages/one/src/vite/__tests__/server-client-only.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +describe('server-only module', () => { + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env.VITE_ENVIRONMENT + vi.resetModules() + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.VITE_ENVIRONMENT = originalEnv + } else { + delete process.env.VITE_ENVIRONMENT + } + }) + + it('should throw an error when imported in client environment', async () => { + process.env.VITE_ENVIRONMENT = 'client' + + await expect(async () => { + await import('../server-only') + }).rejects.toThrow('This file should only be imported on the server! Current environment: client') + }) + + it('should not throw when imported in ssr environment', async () => { + process.env.VITE_ENVIRONMENT = 'ssr' + + await expect(import('../server-only')).resolves.not.toThrow() + }) + + it('should throw when VITE_ENVIRONMENT is not set', async () => { + delete process.env.VITE_ENVIRONMENT + + await expect(async () => { + await import('../server-only') + }).rejects.toThrow('This file should only be imported on the server! Current environment: undefined') + }) +}) + +describe('client-only module', () => { + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env.VITE_ENVIRONMENT + vi.resetModules() + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.VITE_ENVIRONMENT = originalEnv + } else { + delete process.env.VITE_ENVIRONMENT + } + }) + + it('should throw an error when imported in ssr environment', async () => { + process.env.VITE_ENVIRONMENT = 'ssr' + + await expect(async () => { + await import('../client-only') + }).rejects.toThrow('This file should only be imported on the client! Current environment: ssr') + }) + + it('should not throw when imported in client environment', async () => { + process.env.VITE_ENVIRONMENT = 'client' + + await expect(import('../client-only')).resolves.not.toThrow() + }) + + it('should throw when VITE_ENVIRONMENT is not set', async () => { + delete process.env.VITE_ENVIRONMENT + + await expect(async () => { + await import('../client-only') + }).rejects.toThrow('This file should only be imported on the client! Current environment: undefined') + }) +}) \ No newline at end of file diff --git a/packages/one/src/vite/client-only.ts b/packages/one/src/vite/client-only.ts new file mode 100644 index 000000000..b706dd16a --- /dev/null +++ b/packages/one/src/vite/client-only.ts @@ -0,0 +1,5 @@ +if (process.env.VITE_ENVIRONMENT !== 'client') { + throw new Error(`This file should only be imported on the client! Current environment: ${process.env.VITE_ENVIRONMENT}`) +} + +export {} \ No newline at end of file diff --git a/packages/one/src/vite/one.ts b/packages/one/src/vite/one.ts index 741948a40..2e5ad028f 100644 --- a/packages/one/src/vite/one.ts +++ b/packages/one/src/vite/one.ts @@ -19,6 +19,7 @@ import { generateFileSystemRouteTypesPlugin } from './plugins/generateFileSystem import { SSRCSSPlugin } from './plugins/SSRCSSPlugin' import { virtualEntryId } from './plugins/virtualEntryConstants' import { createVirtualEntry } from './plugins/virtualEntryPlugin' +import { serverClientOnlyPlugin } from './plugins/serverClientOnlyPlugin' import type { One } from './types' import type { ExpoManifestRequestHandlerPluginPluginOptions, @@ -143,6 +144,8 @@ export function one(options: One.PluginOptions = {}): PluginOption { __get: options, }, + serverClientOnlyPlugin(), + barrelOption === false ? null : (barrel({ diff --git a/packages/one/src/vite/plugins/__tests__/serverClientOnlyPlugin.test.ts b/packages/one/src/vite/plugins/__tests__/serverClientOnlyPlugin.test.ts new file mode 100644 index 000000000..6d08e3c8e --- /dev/null +++ b/packages/one/src/vite/plugins/__tests__/serverClientOnlyPlugin.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { serverClientOnlyPlugin } from '../serverClientOnlyPlugin' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +describe('serverClientOnlyPlugin', () => { + const plugin = serverClientOnlyPlugin() + + it('should have correct plugin name', () => { + expect(plugin.name).toBe('one:server-client-only') + }) + + it('should enforce pre position', () => { + expect(plugin.enforce).toBe('pre') + }) + + describe('config hook', () => { + it('should provide aliases for server-only and client-only modules', () => { + const config = plugin.config?.() + + expect(config).toEqual({ + resolve: { + alias: { + 'server-only': resolve(__dirname, '../../server-only.js'), + 'client-only': resolve(__dirname, '../../client-only.js'), + }, + }, + }) + }) + }) + + describe('resolveId hook', () => { + it('should resolve server-only module', () => { + const result = plugin.resolveId?.('server-only', undefined, {}) + + expect(result).toEqual({ + id: resolve(__dirname, '../../server-only.js'), + external: false, + }) + }) + + it('should resolve client-only module', () => { + const result = plugin.resolveId?.('client-only', undefined, {}) + + expect(result).toEqual({ + id: resolve(__dirname, '../../client-only.js'), + external: false, + }) + }) + + it('should return null for other modules', () => { + const result = plugin.resolveId?.('some-other-module', undefined, {}) + + expect(result).toBeNull() + }) + }) +}) \ No newline at end of file diff --git a/packages/one/src/vite/plugins/serverClientOnlyPlugin.ts b/packages/one/src/vite/plugins/serverClientOnlyPlugin.ts new file mode 100644 index 000000000..46655c779 --- /dev/null +++ b/packages/one/src/vite/plugins/serverClientOnlyPlugin.ts @@ -0,0 +1,34 @@ +import type { Plugin } from 'vite' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +export function serverClientOnlyPlugin(): Plugin { + return { + name: 'one:server-client-only', + enforce: 'pre', + + config() { + return { + resolve: { + alias: { + 'server-only': resolve(__dirname, '../server-only.js'), + 'client-only': resolve(__dirname, '../client-only.js'), + }, + }, + } + }, + + resolveId(source) { + if (source === 'server-only') { + return { id: resolve(__dirname, '../server-only.js'), external: false } + } + if (source === 'client-only') { + return { id: resolve(__dirname, '../client-only.js'), external: false } + } + return null + }, + } +} \ No newline at end of file diff --git a/packages/one/src/vite/server-only.ts b/packages/one/src/vite/server-only.ts new file mode 100644 index 000000000..9f74a8593 --- /dev/null +++ b/packages/one/src/vite/server-only.ts @@ -0,0 +1,5 @@ +if (process.env.VITE_ENVIRONMENT !== 'ssr') { + throw new Error(`This file should only be imported on the server! Current environment: ${process.env.VITE_ENVIRONMENT}`) +} + +export {} \ No newline at end of file diff --git a/packages/one/types/vite/client-only.d.ts b/packages/one/types/vite/client-only.d.ts new file mode 100644 index 000000000..a74e42e83 --- /dev/null +++ b/packages/one/types/vite/client-only.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=client-only.d.ts.map \ No newline at end of file diff --git a/packages/one/types/vite/plugins/serverClientOnlyPlugin.d.ts b/packages/one/types/vite/plugins/serverClientOnlyPlugin.d.ts new file mode 100644 index 000000000..b947d61d5 --- /dev/null +++ b/packages/one/types/vite/plugins/serverClientOnlyPlugin.d.ts @@ -0,0 +1,3 @@ +import type { Plugin } from 'vite'; +export declare function serverClientOnlyPlugin(): Plugin; +//# sourceMappingURL=serverClientOnlyPlugin.d.ts.map \ No newline at end of file diff --git a/packages/one/types/vite/server-only.d.ts b/packages/one/types/vite/server-only.d.ts new file mode 100644 index 000000000..b96d2bed6 --- /dev/null +++ b/packages/one/types/vite/server-only.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=server-only.d.ts.map \ No newline at end of file diff --git a/packages/vxrn/src/exports/build.ts b/packages/vxrn/src/exports/build.ts index 7f3aa6c46..53e4c32f4 100644 --- a/packages/vxrn/src/exports/build.ts +++ b/packages/vxrn/src/exports/build.ts @@ -243,7 +243,7 @@ export const build = async (optionsIn: VXRNOptions, buildArgs: BuildArgs = {}) = define: { 'process.env.TAMAGUI_IS_SERVER': '"1"', - 'process.env.VITE_ENVIRONMENT': '"server"', + 'process.env.VITE_ENVIRONMENT': '"ssr"', ...processEnvDefines, ...webBuildConfig.define, }, diff --git a/tests/test/app/index.tsx b/tests/test/app/index.tsx index a4336a3bd..ddba21294 100644 --- a/tests/test/app/index.tsx +++ b/tests/test/app/index.tsx @@ -53,6 +53,14 @@ export default () => { + + + + + + + + ) diff --git a/tests/test/app/test-client-only+spa.tsx b/tests/test/app/test-client-only+spa.tsx new file mode 100644 index 000000000..0129b65c3 --- /dev/null +++ b/tests/test/app/test-client-only+spa.tsx @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react' +import { Button, H2, Paragraph, YStack } from 'tamagui' + +export default function TestClientOnly() { + const [count, setCount] = useState(0) + const [browserInfo, setBrowserInfo] = useState(null) + + useEffect(() => { + // Only import client-only utilities on the client + if (typeof window !== 'undefined') { + import('../utils/client-utils').then(({ getBrowserInfo }) => { + setBrowserInfo(getBrowserInfo()) + }) + } + }, []) + + return ( + +

Client Only Test Page

+ This page uses client-only utilities + Count: {count} + + + Environment: {typeof window !== 'undefined' ? 'client' : 'server'} + + {browserInfo && ( + + Browser: {browserInfo.language} + + )} +
+ ) +} \ No newline at end of file diff --git a/tests/test/app/test-server-only+ssr.tsx b/tests/test/app/test-server-only+ssr.tsx new file mode 100644 index 000000000..8db0d8670 --- /dev/null +++ b/tests/test/app/test-server-only+ssr.tsx @@ -0,0 +1,26 @@ +import 'server-only' +import { useLoader } from 'one' +import { H2, Paragraph, YStack } from 'tamagui' +import { getServerTime, getServerEnvironment } from '../utils/server-utils' + +export async function loader() { + // This loader should only run on the server + const serverInfo = getServerEnvironment() + return { + message: 'This data was loaded on the server', + timestamp: getServerTime(), + ...serverInfo + } +} + +export default function TestServerOnly() { + const data = useLoader(loader) + + return ( + +

Server Only Test Page

+ This page imports 'server-only' at the top + Loaded data: {JSON.stringify(data, null, 2)} +
+ ) +} \ No newline at end of file diff --git a/tests/test/routes.d.ts b/tests/test/routes.d.ts index ef76fd05d..d102c734b 100644 --- a/tests/test/routes.d.ts +++ b/tests/test/routes.d.ts @@ -6,7 +6,7 @@ import type { OneRouter } from 'one' declare module 'one' { export namespace OneRouter { export interface __routes extends Record { - StaticRoutes: `/` | `/(auth-guard)` | `/(auth-guard)/auth-guard` | `/(blog)` | `/(blog)/blog/my-first-post` | `/(marketing)/about` | `/(sub-page-group)` | `/(sub-page-group)/sub-page` | `/(sub-page-group)/sub-page/sub` | `/(sub-page-group)/sub-page/sub2` | `/_sitemap` | `/about` | `/auth-guard` | `/blog/my-first-post` | `/expo-video` | `/hooks` | `/hooks/cases/navigating-into-nested-navigator` | `/hooks/cases/navigating-into-nested-navigator/nested-1` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2/page` | `/hooks/contents` | `/hooks/contents/page-1` | `/hooks/contents/page-2` | `/layouts` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]/` | `/loader` | `/loader/other` | `/middleware` | `/not-found/deep/test` | `/not-found/fallback/test` | `/not-found/test` | `/rn-features/platform-specific-extensions/test` | `/rn-features/platform-specific-extensions/test-route-1` | `/rn-features/platform-specific-extensions/test-route-2` | `/server-data` | `/sheet` | `/spa/spapage` | `/ssr` | `/ssr/` | `/ssr/basic` | `/sub-page` | `/sub-page/sub` | `/sub-page/sub2` | `/vite-features/import-meta-env` | `/web-extensions` + StaticRoutes: `/` | `/(auth-guard)` | `/(auth-guard)/auth-guard` | `/(blog)` | `/(blog)/blog/my-first-post` | `/(marketing)/about` | `/(sub-page-group)` | `/(sub-page-group)/sub-page` | `/(sub-page-group)/sub-page/sub` | `/(sub-page-group)/sub-page/sub2` | `/_sitemap` | `/about` | `/auth-guard` | `/blog/my-first-post` | `/expo-video` | `/hooks` | `/hooks/cases/navigating-into-nested-navigator` | `/hooks/cases/navigating-into-nested-navigator/nested-1` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2/page` | `/hooks/contents` | `/hooks/contents/page-1` | `/hooks/contents/page-2` | `/layouts` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]/` | `/loader` | `/loader/other` | `/middleware` | `/not-found/deep/test` | `/not-found/fallback/test` | `/not-found/test` | `/rn-features/platform-specific-extensions/test` | `/rn-features/platform-specific-extensions/test-route-1` | `/rn-features/platform-specific-extensions/test-route-2` | `/server-data` | `/sheet` | `/spa/spapage` | `/ssr` | `/ssr/` | `/ssr/basic` | `/sub-page` | `/sub-page/sub` | `/sub-page/sub2` | `/test-client-only` | `/test-server-only` | `/vite-features/import-meta-env` | `/web-extensions` DynamicRoutes: `/dynamic-folder-routes/${OneRouter.SingleRoutePart}/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-nested-slug/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-nested-slug/${OneRouter.SingleRoutePart}/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-slug/${OneRouter.SingleRoutePart}` | `/layouts/nested-layout/with-slug-layout-folder/${OneRouter.SingleRoutePart}` | `/not-found/+not-found` | `/not-found/deep/+not-found` | `/routes/subpath/${string}` | `/segments-stable-ids/${string}` | `/spa/${OneRouter.SingleRoutePart}` | `/ssr/${OneRouter.SingleRoutePart}` | `/ssr/${string}` DynamicRouteTemplate: `/dynamic-folder-routes/[serverId]/[channelId]` | `/hooks/contents/with-nested-slug/[folderSlug]` | `/hooks/contents/with-nested-slug/[folderSlug]/[fileSlug]` | `/hooks/contents/with-slug/[slug]` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]` | `/not-found/+not-found` | `/not-found/deep/+not-found` | `/routes/subpath/[...subpath]` | `/segments-stable-ids/[...segments]` | `/spa/[spaparams]` | `/ssr/[...rest]` | `/ssr/[param]` IsTyped: true diff --git a/tests/test/tests/server-client-only.test.ts b/tests/test/tests/server-client-only.test.ts new file mode 100644 index 000000000..c53e78e65 --- /dev/null +++ b/tests/test/tests/server-client-only.test.ts @@ -0,0 +1,105 @@ +import { type Browser, type BrowserContext, chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, test } from 'vitest' + +const serverUrl = process.env.ONE_SERVER_URL +const isDebug = !!process.env.DEBUG + +let browser: Browser +let context: BrowserContext + +beforeAll(async () => { + browser = await chromium.launch({ headless: !isDebug }) + context = await browser.newContext() +}) + +afterAll(async () => { + await browser.close() +}) + +describe('Server-Client Only Module Tests', () => { + it('server-only modules should work on the server', async () => { + const page = await context.newPage() + // Navigate to the server-only test page + await page.goto(`${serverUrl}/test-server-only`) + + // Wait for the page to load + await page.waitForSelector('text=Server Only Test Page') + + // Check that the page loaded successfully + const heading = await page.textContent('h2') + expect(heading).toBe('Server Only Test Page') + + // Check that server data was loaded + const loadedDataText = await page.textContent('p:has-text("Loaded data:")') + expect(loadedDataText).toContain('This data was loaded on the server') + + // Verify that the server environment info is present + const pageContent = await page.content() + expect(pageContent).toContain('nodeVersion') + expect(pageContent).toContain('platform') + + await page.close() + }) + + it('client-only modules should work on the client', async () => { + const page = await context.newPage() + // Navigate to the client-only test page + await page.goto(`${serverUrl}/test-client-only`) + + // Wait for the page to load + await page.waitForSelector('text=Client Only Test Page') + + // Check that the page loaded successfully + const heading = await page.textContent('h2') + expect(heading).toBe('Client Only Test Page') + + // Wait for client-side hydration + await page.waitForFunction(() => typeof window !== 'undefined') + + // The environment text shows 'client' after hydration + await page.waitForSelector('p:has-text("Environment: client")', { timeout: 5000 }) + + // Test client-side functionality + const button = await page.locator('button:has-text("Increment")') + + // Check initial count + let countText = await page.textContent('p:has-text("Count:")') + expect(countText).toBe('Count: 0') + + // Click increment button + await button.click() + + // Check updated count + countText = await page.textContent('p:has-text("Count:")') + expect(countText).toBe('Count: 1') + + // Wait for browser info to load (from dynamic import) + await page.waitForSelector('p:has-text("Browser:")', { timeout: 5000 }) + const browserText = await page.textContent('p:has-text("Browser:")') + expect(browserText).toContain('Browser:') + + await page.close() + }) + + it('server-only page should render server data', async () => { + const response = await fetch(`${serverUrl}/test-server-only`) + const html = await response.text() + + // Check that the page contains server-side rendered content + expect(html).toContain('Server Only Test Page') + expect(html).toContain('This page imports 'server-only' at the top') + expect(html).toContain('This data was loaded on the server') + }) + + it('client-only page should render as SPA', async () => { + const response = await fetch(`${serverUrl}/test-client-only`) + const html = await response.text() + + // Check that this is a SPA page + expect(html).toContain('__vxrnIsSPA') + expect(html).toContain('"mode":"spa"') + // SPA pages don't render content on the server + expect(html).not.toContain('Client Only Test Page') + expect(html).not.toContain('userAgent') + }) +}) \ No newline at end of file diff --git a/tests/test/utils/client-utils.ts b/tests/test/utils/client-utils.ts new file mode 100644 index 000000000..e589ad90e --- /dev/null +++ b/tests/test/utils/client-utils.ts @@ -0,0 +1,16 @@ +import 'client-only' + +export function getBrowserInfo() { + return { + userAgent: navigator.userAgent, + language: navigator.language, + onLine: navigator.onLine + } +} + +export function getViewportSize() { + return { + width: window.innerWidth, + height: window.innerHeight + } +} \ No newline at end of file diff --git a/tests/test/utils/server-utils.ts b/tests/test/utils/server-utils.ts new file mode 100644 index 000000000..b18b0438f --- /dev/null +++ b/tests/test/utils/server-utils.ts @@ -0,0 +1,13 @@ +import 'server-only' + +export function getServerTime() { + return new Date().toISOString() +} + +export function getServerEnvironment() { + return { + nodeVersion: process.version, + platform: process.platform, + env: process.env.VITE_ENVIRONMENT + } +} \ No newline at end of file