diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 494b80818..e7d63d11e 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -952,4 +952,17 @@ function defineTest(f: Fixture) { await waitForHydration(page) expect(errors).toEqual([]) }) + + test('browser only', async ({ page, browser }) => { + await page.goto(f.url()) + await expect(page.getByTestId('test-browser-only')).toHaveText( + 'test-browser-only: true', + ) + + const pageNoJs = await browser.newPage({ javaScriptEnabled: false }) + await pageNoJs.goto(f.url()) + await expect(pageNoJs.getByTestId('test-browser-only')).toHaveText( + 'test-browser-only: loading...', + ) + }) } diff --git a/packages/plugin-rsc/examples/basic/src/routes/browser-only/browser-dep.tsx b/packages/plugin-rsc/examples/basic/src/routes/browser-only/browser-dep.tsx new file mode 100644 index 000000000..b922367d1 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/browser-only/browser-dep.tsx @@ -0,0 +1,3 @@ +export default function BrowserDep() { + return <>{String(!!window)} +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/browser-only/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/browser-only/client.tsx new file mode 100644 index 000000000..21c76d2b6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/browser-only/client.tsx @@ -0,0 +1,65 @@ +'use client' + +import * as React from 'react' + +const BrowserDep = ( + import.meta.env.SSR ? undefined : React.lazy(() => import('./browser-dep')) +)! + +export function TestBrowserOnly() { + return ( +
+ test-browser-only:{' '} + loading...}> + + +
+ ) +} + +function BrowserOnly(props: React.SuspenseProps) { + const hydrated = useHydrated() + if (!hydrated) { + return props.fallback + } + return +} + +const noopStore = () => () => {} + +const useHydrated = () => + React.useSyncExternalStore( + noopStore, + () => true, + () => false, + ) + +/* +If we were to implement this whole logic via hypothetical `browserOnly` helper with transform: + +======= input ====== + +const SomeDep = browserOnly(() => import('./some-dep')) + +======= output ====== + +const __TmpLazy = import.meta.env.SSR ? undefined : React.lazy(() => import('./some-dep'})); + +const SomeDep = ({ browserOnlyFallback, ...props }) => { + const hydrated = useHydrated() + if (!hydrated) { + return browserOnlyFallback + } + return ( + + <__TmpLazy {...props} /> + + ) +} + +=== helper types === + +declare function browserOnly(fn: () => Promise<{ default: React.ComponentType }>): + React.ComponentType + +*/ diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx index 6214ae7d2..5a3421ed4 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx @@ -28,6 +28,7 @@ import { TestTailwindServer } from './tailwind/server' import { TestTemporaryReference } from './temporary-reference/client' import { TestUseCache } from './use-cache/server' import { TestHydrationMismatch } from './hydration-mismatch/server' +import { TestBrowserOnly } from './browser-only/client' export function Root(props: { url: URL }) { return ( @@ -67,6 +68,7 @@ export function Root(props: { url: URL }) { + diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts index 7eff64dcc..67c4404e5 100644 --- a/packages/plugin-rsc/examples/basic/vite.config.ts +++ b/packages/plugin-rsc/examples/basic/vite.config.ts @@ -2,8 +2,9 @@ import assert from 'node:assert' import rsc, { transformHoistInlineDirective } from '@vitejs/plugin-rsc' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' -import { type Plugin, defineConfig, parseAstAsync } from 'vite' +import { type Plugin, defineConfig, normalizePath, parseAstAsync } from 'vite' import inspect from 'vite-plugin-inspect' +import path from 'node:path' // log unhandled rejection to debug e2e failures if (!(globalThis as any).__debugHandlerRegisterd) { @@ -96,6 +97,23 @@ export default defineConfig({ } }, }, + { + name: 'test-browser-only', + writeBundle(_options, bundle) { + const moduleIds = Object.values(bundle).flatMap((c) => + c.type === 'chunk' ? [...c.moduleIds] : [], + ) + const browserId = normalizePath( + path.resolve('src/routes/browser-only/browser-dep.tsx'), + ) + if (this.environment.name === 'client') { + assert(moduleIds.includes(browserId)) + } + if (this.environment.name === 'ssr') { + assert(!moduleIds.includes(browserId)) + } + }, + }, { name: 'cf-build', enforce: 'post',