Skip to content

Commit ba16c34

Browse files
authored
fix(rsc): show import chain for server-only and client-only import error (#867)
1 parent c30cf1a commit ba16c34

File tree

1 file changed

+121
-16
lines changed

1 file changed

+121
-16
lines changed
Lines changed: 121 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Plugin } from 'vite'
1+
import path from 'node:path'
2+
import type { DevEnvironment, Plugin, Rollup } from 'vite'
23

34
// https://github.com/vercel/next.js/blob/90f564d376153fe0b5808eab7b83665ee5e08aaf/packages/next/src/build/webpack-config.ts#L1249-L1280
45
// https://github.com/pcattori/vite-env-only/blob/68a0cc8546b9a37c181c0b0a025eb9b62dbedd09/src/deny-imports.ts
@@ -8,37 +9,141 @@ export function validateImportPlugin(): Plugin {
89
name: 'rsc:validate-imports',
910
resolveId: {
1011
order: 'pre',
11-
async handler(source, importer, options) {
12+
async handler(source, _importer, options) {
1213
// optimizer is not aware of server/client boudnary so skip
1314
if ('scan' in options && options.scan) {
1415
return
1516
}
1617

1718
// 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-
)
19+
if (source === 'client-only' || source === 'server-only') {
20+
if (
21+
(source === 'client-only' && this.environment.name === 'rsc') ||
22+
(source === 'server-only' && this.environment.name !== 'rsc')
23+
) {
24+
return {
25+
id: `\0virtual:vite-rsc/validate-imports/invalid/${source}`,
26+
moduleSideEffects: true,
27+
}
2328
}
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-
)
29+
return {
30+
id: `\0virtual:vite-rsc/validate-imports/valid/${source}`,
31+
moduleSideEffects: false,
3132
}
32-
return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false }
3333
}
3434

3535
return
3636
},
3737
},
3838
load(id) {
39-
if (id.startsWith('\0virtual:vite-rsc/empty')) {
39+
if (id.startsWith('\0virtual:vite-rsc/validate-imports/invalid/')) {
40+
// it should surface as build error but we make a runtime error just in case.
41+
const source = id.slice(id.lastIndexOf('/') + 1)
42+
return `throw new Error("invalid import of '${source}'")`
43+
}
44+
if (id.startsWith('\0virtual:vite-rsc/validate-imports/')) {
4045
return `export {}`
4146
}
4247
},
48+
// for dev, use DevEnvironment.moduleGraph during post transform
49+
transform: {
50+
order: 'post',
51+
async handler(_code, id) {
52+
if (this.environment.mode === 'dev') {
53+
if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) {
54+
const chain = getImportChainDev(this.environment, id)
55+
validateImportChain(
56+
chain,
57+
this.environment.name,
58+
this.environment.config.root,
59+
)
60+
}
61+
}
62+
},
63+
},
64+
// for build, use PluginContext.getModuleInfo during buildEnd.
65+
// rollup shows multiple errors if there are other build error from `buildEnd(error)`.
66+
buildEnd() {
67+
if (this.environment.mode === 'build') {
68+
const serverOnly = getImportChainBuild(
69+
this,
70+
'\0virtual:vite-rsc/validate-imports/invalid/server-only',
71+
)
72+
validateImportChain(
73+
serverOnly,
74+
this.environment.name,
75+
this.environment.config.root,
76+
)
77+
const clientOnly = getImportChainBuild(
78+
this,
79+
'\0virtual:vite-rsc/validate-imports/invalid/client-only',
80+
)
81+
validateImportChain(
82+
clientOnly,
83+
this.environment.name,
84+
this.environment.config.root,
85+
)
86+
}
87+
},
88+
}
89+
}
90+
91+
function getImportChainDev(environment: DevEnvironment, id: string) {
92+
const chain: string[] = []
93+
const recurse = (id: string) => {
94+
if (chain.includes(id)) return
95+
const info = environment.moduleGraph.getModuleById(id)
96+
if (!info) return
97+
chain.push(id)
98+
const next = [...info.importers][0]
99+
if (next && next.id) {
100+
recurse(next.id)
101+
}
102+
}
103+
recurse(id)
104+
return chain
105+
}
106+
107+
function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] {
108+
const chain: string[] = []
109+
const recurse = (id: string) => {
110+
if (chain.includes(id)) return
111+
const info = ctx.getModuleInfo(id)
112+
if (!info) return
113+
chain.push(id)
114+
const next = info.importers[0]
115+
if (next) {
116+
recurse(next)
117+
}
118+
}
119+
recurse(id)
120+
return chain
121+
}
122+
123+
function validateImportChain(
124+
chain: string[],
125+
environmentName: string,
126+
root: string,
127+
) {
128+
if (chain.length === 0) return
129+
const id = chain[0]!
130+
const source = id.slice(id.lastIndexOf('/') + 1)
131+
const buildName = source === 'server-only' ? 'client' : 'server'
132+
let result = `'${source}' cannot be imported in ${buildName} build ('${environmentName}' environment):\n`
133+
result += chain
134+
.slice(1, 6)
135+
.map(
136+
(id, i) =>
137+
' '.repeat(i + 1) +
138+
`imported by ${path.relative(root, id).replaceAll('\0', '')}\n`,
139+
)
140+
.join('')
141+
if (chain.length > 6) {
142+
result += ' '.repeat(7) + '...\n'
143+
}
144+
const error = new Error(result)
145+
if (chain[1]) {
146+
Object.assign(error, { id: chain[1] })
43147
}
148+
throw error
44149
}

0 commit comments

Comments
 (0)