Skip to content

Commit f771089

Browse files
committed
test(rsc): add hydration mismatch example
1 parent ce84449 commit f771089

File tree

5 files changed

+127
-0
lines changed

5 files changed

+127
-0
lines changed

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,4 +952,17 @@ function defineTest(f: Fixture) {
952952
await waitForHydration(page)
953953
expect(errors).toEqual([])
954954
})
955+
956+
test('browser only', async ({ page, browser }) => {
957+
await page.goto(f.url())
958+
await expect(page.getByTestId('test-browser-only')).toHaveText(
959+
'test-browser-only: true',
960+
)
961+
962+
const pageNoJs = await browser.newPage({ javaScriptEnabled: false })
963+
await pageNoJs.goto(f.url())
964+
await expect(pageNoJs.getByTestId('test-browser-only')).toHaveText(
965+
'test-browser-only: loading...',
966+
)
967+
})
955968
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function BrowserDep() {
2+
return <>{String(!!window)}</>
3+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
5+
const BrowserDep = (
6+
import.meta.env.SSR ? undefined : React.lazy(() => import('./browser-dep'))
7+
)!
8+
9+
export function TestBrowserOnly() {
10+
return (
11+
<div data-testid="test-browser-only">
12+
test-browser-only:{' '}
13+
<BrowserOnly fallback={<>loading...</>}>
14+
<BrowserDep />
15+
</BrowserOnly>
16+
</div>
17+
)
18+
}
19+
20+
function BrowserOnly(props: React.SuspenseProps) {
21+
const hydrated = useHydrated()
22+
if (!hydrated) {
23+
return props.fallback
24+
}
25+
return <React.Suspense {...props} />
26+
}
27+
28+
const noopStore = () => () => {}
29+
30+
const useHydrated = () =>
31+
React.useSyncExternalStore(
32+
noopStore,
33+
() => true,
34+
() => false,
35+
)
36+
37+
/*
38+
If we were to implement this whole logic via `browserOnly` helper with transform:
39+
40+
======= input ======
41+
42+
const SomeDep = browserOnly(() => import('./some-dep'))
43+
44+
======= output ======
45+
46+
const __TmpLazy = import.meta.env.SSR ? undefined : React.lazy(() => import('./some-dep'}));
47+
48+
const SomeDep = ({ browserOnlyFallback, ...props }) => {
49+
const hydrated = useHydrated()
50+
if (!hydrated) {
51+
return browserOnlyFallback
52+
}
53+
return (
54+
<React.Suspense fallback={browserOnlyFallback}>
55+
<__TmpLazy {...props} />
56+
</React.Suspense>
57+
)
58+
}
59+
*/
60+
61+
// declare function browserOnly<T>(fn: () => Promise<{ default: React.ComponentType<T> }>): React.ComponentType<T & { browserOnlyFallback?: React.ReactNode }>

packages/plugin-rsc/examples/basic/src/routes/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { TestTailwindServer } from './tailwind/server'
2828
import { TestTemporaryReference } from './temporary-reference/client'
2929
import { TestUseCache } from './use-cache/server'
3030
import { TestHydrationMismatch } from './hydration-mismatch/server'
31+
import { TestBrowserOnly } from './browser-only/client'
3132

3233
export function Root(props: { url: URL }) {
3334
return (
@@ -67,6 +68,7 @@ export function Root(props: { url: URL }) {
6768
<TestServerInClient />
6869
<TestActionStateServer />
6970
<TestModuleInvalidationServer />
71+
<TestBrowserOnly />
7072
<TestUseCache />
7173
</body>
7274
</html>

packages/plugin-rsc/examples/basic/vite.config.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
44
import react from '@vitejs/plugin-react'
55
import { type Plugin, defineConfig, parseAstAsync } from 'vite'
66
import inspect from 'vite-plugin-inspect'
7+
import path from 'node:path'
78

89
// log unhandled rejection to debug e2e failures
910
if (!(globalThis as any).__debugHandlerRegisterd) {
@@ -23,6 +24,7 @@ export default defineConfig({
2324
tailwindcss(),
2425
react(),
2526
vitePluginUseCache(),
27+
vitePluginBrowserOnly(),
2628
rsc({
2729
entries: {
2830
client: './src/client.tsx',
@@ -96,6 +98,23 @@ export default defineConfig({
9698
}
9799
},
98100
},
101+
{
102+
name: 'test-browser-only',
103+
writeBundle(_options, bundle) {
104+
const moduleIds = Object.values(bundle).flatMap((c) =>
105+
c.type === 'chunk' ? [...c.moduleIds] : [],
106+
)
107+
const browserId = path.resolve(
108+
'src/routes/browser-only/browser-dep.tsx',
109+
)
110+
if (this.environment.name === 'client') {
111+
assert(moduleIds.includes(browserId))
112+
}
113+
if (this.environment.name === 'ssr') {
114+
assert(!moduleIds.includes(browserId))
115+
}
116+
},
117+
},
99118
{
100119
name: 'cf-build',
101120
enforce: 'post',
@@ -176,3 +195,32 @@ function vitePluginUseCache(): Plugin[] {
176195
},
177196
]
178197
}
198+
199+
function vitePluginBrowserOnly(): Plugin[] {
200+
return [
201+
{
202+
name: 'browser-only-component',
203+
load(id, code) {
204+
if (id.endsWith('?browser-only')) {
205+
if (this.environment.name === 'rsc') return
206+
207+
id = id.slice(0, -'?browser-only'.length)
208+
if (this.environment.name === 'ssr') {
209+
code
210+
// const Test = React.lazy(() => import('./test-browser-only?browser-only'))
211+
return `\
212+
export default () => {
213+
throw new Error('Browser-only component on SSR')
214+
};
215+
`
216+
}
217+
if (this.environment.name === 'client') {
218+
return `\
219+
export { default } from ${JSON.stringify(id)}
220+
`
221+
}
222+
}
223+
},
224+
},
225+
]
226+
}

0 commit comments

Comments
 (0)