Skip to content

Commit cad0753

Browse files
committed
fix(rsc): show import chain for server-only and client-only import error
1 parent a96a6b2 commit cad0753

File tree

1 file changed

+110
-16
lines changed

1 file changed

+110
-16
lines changed
Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Plugin } from 'vite'
1+
import type { DevEnvironment, Plugin, Rollup } from 'vite'
22

33
// https://github.com/vercel/next.js/blob/90f564d376153fe0b5808eab7b83665ee5e08aaf/packages/next/src/build/webpack-config.ts#L1249-L1280
44
// https://github.com/pcattori/vite-env-only/blob/68a0cc8546b9a37c181c0b0a025eb9b62dbedd09/src/deny-imports.ts
@@ -8,37 +8,131 @@ export function validateImportPlugin(): Plugin {
88
name: 'rsc:validate-imports',
99
resolveId: {
1010
order: 'pre',
11-
async handler(source, importer, options) {
11+
async handler(source, _importer, options) {
1212
// optimizer is not aware of server/client boudnary so skip
1313
if ('scan' in options && options.scan) {
1414
return
1515
}
1616

1717
// Validate client-only imports in server environments
18-
if (source === 'client-only') {
19-
if (this.environment.name === 'rsc') {
20-
throw new Error(
21-
`'client-only' cannot be imported in server build (importer: '${importer ?? 'unknown'}', environment: ${this.environment.name})`,
22-
)
18+
if (source === 'client-only' || source === 'server-only') {
19+
if (
20+
(source === 'client-only' && this.environment.name === 'rsc') ||
21+
(source === 'server-only' && this.environment.name !== 'rsc')
22+
) {
23+
return {
24+
id: `\0virtual:vite-rsc/validate-imports/invalid/${source}`,
25+
moduleSideEffects: true,
26+
}
2327
}
24-
return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false }
25-
}
26-
if (source === 'server-only') {
27-
if (this.environment.name !== 'rsc') {
28-
throw new Error(
29-
`'server-only' cannot be imported in client build (importer: '${importer ?? 'unknown'}', environment: ${this.environment.name})`,
30-
)
28+
return {
29+
id: `\0virtual:vite-rsc/validate-imports/valid/${source}`,
30+
moduleSideEffects: false,
3131
}
32-
return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false }
3332
}
3433

3534
return
3635
},
3736
},
3837
load(id) {
39-
if (id.startsWith('\0virtual:vite-rsc/empty')) {
38+
if (id.startsWith('\0virtual:vite-rsc/validate-imports/invalid/')) {
39+
// it should surface as build error but we make a runtime error just in case.
40+
const source = id.slice(id.lastIndexOf('/') + 1)
41+
return `throw new Error("invalid import of '${source}'")`
42+
}
43+
if (id.startsWith('\0virtual:vite-rsc/validate-imports/')) {
4044
return `export {}`
4145
}
4246
},
47+
// need a different way to probe module graph for dev and build
48+
transform: {
49+
order: 'post',
50+
async handler(_code, id) {
51+
if (this.environment.mode === 'dev') {
52+
if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) {
53+
const chain = getImportChainDev(this.environment, id)
54+
const error = formatError(chain, this.environment.name)
55+
if (error) {
56+
this.error({
57+
id: chain[1],
58+
message: error,
59+
})
60+
}
61+
}
62+
}
63+
},
64+
},
65+
generateBundle() {
66+
if (this.environment.mode === 'build') {
67+
const serverOnly = getImportChainBuild(
68+
this,
69+
'\0virtual:vite-rsc/validate-imports/invalid/server-only',
70+
)
71+
const serverOnlyError = formatError(serverOnly, this.environment.name)
72+
if (serverOnlyError) {
73+
throw new Error(serverOnlyError)
74+
}
75+
const clientOnly = getImportChainBuild(
76+
this,
77+
'\0virtual:vite-rsc/validate-imports/invalid/client-only',
78+
)
79+
const clientOnlyError = formatError(clientOnly, this.environment.name)
80+
if (clientOnlyError) {
81+
throw new Error(clientOnlyError)
82+
}
83+
}
84+
},
85+
}
86+
}
87+
88+
function getImportChainDev(environment: DevEnvironment, id: string) {
89+
const chain: string[] = []
90+
const recurse = (id: string) => {
91+
if (chain.includes(id)) return
92+
const info = environment.moduleGraph.getModuleById(id)
93+
if (!info) return
94+
chain.push(id)
95+
const next = [...info.importers][0]
96+
if (next && next.id) {
97+
recurse(next.id)
98+
}
99+
}
100+
recurse(id)
101+
return chain
102+
}
103+
104+
function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] {
105+
const chain: string[] = []
106+
const recurse = (id: string) => {
107+
if (chain.includes(id)) return
108+
const info = ctx.getModuleInfo(id)
109+
if (!info) return
110+
chain.push(id)
111+
const next = info.importers[0]
112+
if (next) {
113+
recurse(next)
114+
}
115+
}
116+
recurse(id)
117+
return chain
118+
}
119+
120+
function formatError(
121+
chain: string[],
122+
environmentName: string,
123+
): string | undefined {
124+
if (chain.length === 0) return
125+
const id = chain[0]!
126+
const source = id.slice(id.lastIndexOf('/') + 1)
127+
let result = `'${source}' cannot be imported in '${environmentName}' environment:\n`
128+
result += chain
129+
.slice(1, 6)
130+
.map(
131+
(id, i) => ' '.repeat(i + 1) + `imported by ${id.replaceAll('\0', '')}\n`,
132+
)
133+
.join('')
134+
if (chain.length > 6) {
135+
result += ' '.repeat(7) + '...\n'
43136
}
137+
return result
44138
}

0 commit comments

Comments
 (0)