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