Skip to content

Commit 47d02d0

Browse files
Copilothi-ogawa
andauthored
feat(rsc): validate client-only and server-only import during resolve (#624)
Co-authored-by: Hiroshi Ogawa <[email protected]>
1 parent d28356f commit 47d02d0

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { test, expect } from '@playwright/test'
2+
import { setupInlineFixture, useFixture, type Fixture } from './fixture'
3+
import { x } from 'tinyexec'
4+
import { expectNoPageError, waitForHydration } from './helper'
5+
6+
test.describe('validate imports', () => {
7+
test.describe('valid imports', () => {
8+
const root = 'examples/e2e/temp/validate-imports'
9+
test.beforeAll(async () => {
10+
await setupInlineFixture({
11+
src: 'examples/starter',
12+
dest: root,
13+
files: {
14+
'src/client.tsx': /* tsx */ `
15+
"use client";
16+
import 'client-only';
17+
18+
export function TestClient() {
19+
return <div>[test-client]</div>
20+
}
21+
`,
22+
'src/root.tsx': /* tsx */ `
23+
import { TestClient } from './client.tsx'
24+
import 'server-only';
25+
26+
export function Root() {
27+
return (
28+
<html lang="en">
29+
<head>
30+
<meta charSet="UTF-8" />
31+
</head>
32+
<body>
33+
<div>[test-server]</div>
34+
<TestClient />
35+
</body>
36+
</html>
37+
)
38+
}
39+
`,
40+
},
41+
})
42+
})
43+
44+
test.describe('dev', () => {
45+
const f = useFixture({ root, mode: 'dev' })
46+
defineTest(f)
47+
})
48+
49+
test.describe('build', () => {
50+
const f = useFixture({ root, mode: 'build' })
51+
defineTest(f)
52+
})
53+
54+
function defineTest(f: Fixture) {
55+
test('basic', async ({ page }) => {
56+
using _ = expectNoPageError(page)
57+
await page.goto(f.url())
58+
await waitForHydration(page)
59+
})
60+
}
61+
})
62+
63+
test.describe('server-only on client', () => {
64+
const root = 'examples/e2e/temp/validate-server-only'
65+
test.beforeAll(async () => {
66+
await setupInlineFixture({
67+
src: 'examples/starter',
68+
dest: root,
69+
files: {
70+
'src/client.tsx': /* tsx */ `
71+
"use client";
72+
import 'server-only';
73+
74+
export function TestClient() {
75+
return <div>[test-client]</div>
76+
}
77+
`,
78+
'src/root.tsx': /* tsx */ `
79+
import { TestClient } from './client.tsx'
80+
import 'server-only';
81+
82+
export function Root() {
83+
return (
84+
<html lang="en">
85+
<head>
86+
<meta charSet="UTF-8" />
87+
</head>
88+
<body>
89+
<div>[test-server]</div>
90+
<TestClient />
91+
</body>
92+
</html>
93+
)
94+
}
95+
`,
96+
},
97+
})
98+
})
99+
100+
test('build', async () => {
101+
const result = await x('pnpm', ['build'], {
102+
throwOnError: false,
103+
nodeOptions: { cwd: root },
104+
})
105+
expect(result.stderr).toContain(
106+
`'server-only' cannot be imported in client build`,
107+
)
108+
expect(result.exitCode).not.toBe(0)
109+
})
110+
})
111+
112+
test.describe('client-only on server', () => {
113+
const root = 'examples/e2e/temp/validate-client-only'
114+
test.beforeAll(async () => {
115+
await setupInlineFixture({
116+
src: 'examples/starter',
117+
dest: root,
118+
files: {
119+
'src/client.tsx': /* tsx */ `
120+
"use client";
121+
import 'client-only';
122+
123+
export function TestClient() {
124+
return <div>[test-client]</div>
125+
}
126+
`,
127+
'src/root.tsx': /* tsx */ `
128+
import { TestClient } from './client.tsx'
129+
import 'client-only';
130+
131+
export function Root() {
132+
return (
133+
<html lang="en">
134+
<head>
135+
<meta charSet="UTF-8" />
136+
</head>
137+
<body>
138+
<div>[test-server]</div>
139+
<TestClient />
140+
</body>
141+
</html>
142+
)
143+
}
144+
`,
145+
},
146+
})
147+
})
148+
149+
test('build', async () => {
150+
const result = await x('pnpm', ['build'], {
151+
throwOnError: false,
152+
nodeOptions: { cwd: root },
153+
})
154+
expect(result.stderr).toContain(
155+
`'client-only' cannot be imported in server build`,
156+
)
157+
expect(result.exitCode).not.toBe(0)
158+
})
159+
})
160+
})

packages/plugin-rsc/src/plugin.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ export type RscPluginOptions = {
117117

118118
/** Escape hatch for Waku's `allowServer` */
119119
keepUseCientProxy?: boolean
120+
121+
/**
122+
* Enable build-time validation of 'client-only' and 'server-only' imports
123+
* @default true
124+
*/
125+
validateImports?: boolean
120126
}
121127

122128
export default function vitePluginRsc(
@@ -828,6 +834,9 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage;
828834
...vitePluginDefineEncryptionKey(rscPluginOptions),
829835
...vitePluginFindSourceMapURL(),
830836
...vitePluginRscCss({ rscCssTransform: rscPluginOptions.rscCssTransform }),
837+
...(rscPluginOptions.validateImports !== false
838+
? [validateImportPlugin()]
839+
: []),
831840
scanBuildStripPlugin(),
832841
]
833842
}
@@ -1968,6 +1977,49 @@ export function __fix_cloudflare(): Plugin {
19681977
}
19691978
}
19701979

1980+
// https://github.com/vercel/next.js/blob/90f564d376153fe0b5808eab7b83665ee5e08aaf/packages/next/src/build/webpack-config.ts#L1249-L1280
1981+
// https://github.com/pcattori/vite-env-only/blob/68a0cc8546b9a37c181c0b0a025eb9b62dbedd09/src/deny-imports.ts
1982+
// https://github.com/sveltejs/kit/blob/84298477a014ec471839adf7a4448d91bc7949e4/packages/kit/src/exports/vite/index.js#L513
1983+
function validateImportPlugin(): Plugin {
1984+
return {
1985+
name: 'rsc:validate-imports',
1986+
resolveId: {
1987+
order: 'pre',
1988+
async handler(source, importer, options) {
1989+
// optimizer is not aware of server/client boudnary so skip
1990+
if ('scan' in options && options.scan) {
1991+
return
1992+
}
1993+
1994+
// Validate client-only imports in server environments
1995+
if (source === 'client-only') {
1996+
if (this.environment.name === 'rsc') {
1997+
throw new Error(
1998+
`'client-only' cannot be imported in server build (importer: '${importer ?? 'unknown'}', environment: ${this.environment.name})`,
1999+
)
2000+
}
2001+
return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false }
2002+
}
2003+
if (source === 'server-only') {
2004+
if (this.environment.name !== 'rsc') {
2005+
throw new Error(
2006+
`'server-only' cannot be imported in client build (importer: '${importer ?? 'unknown'}', environment: ${this.environment.name})`,
2007+
)
2008+
}
2009+
return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false }
2010+
}
2011+
2012+
return
2013+
},
2014+
},
2015+
load(id) {
2016+
if (id.startsWith('\0virtual:vite-rsc/empty')) {
2017+
return `export {}`
2018+
}
2019+
},
2020+
}
2021+
}
2022+
19712023
function sortObject<T extends object>(o: T) {
19722024
return Object.fromEntries(
19732025
Object.entries(o).sort(([a], [b]) => a.localeCompare(b)),

0 commit comments

Comments
 (0)