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 }) {
+