Skip to content

Commit f7e91ba

Browse files
authored
fix: make .client.js and .server.js work for dynamic imports (fix #1861) (#1884)
1 parent e62abbd commit f7e91ba

File tree

8 files changed

+186
-93
lines changed

8 files changed

+186
-93
lines changed

.github/workflows/ci/getTestJobs.mjs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,34 @@ async function getTestJobs() {
3131
const specFiles = projectFiles.filter((file) => file.includes('.spec.'))
3232
const testFiles = projectFiles.filter((file) => file.includes('.test.'))
3333

34+
const linux_nodeOld = {
35+
os: 'ubuntu-latest',
36+
node_version: '18'
37+
}
38+
const windows_nodeOld = {
39+
os: 'windows-latest',
40+
node_version: '18'
41+
}
42+
3443
/** @type { Job[] } */
3544
let jobs = [
3645
{
3746
jobName: 'Vitest (unit tests)',
3847
jobCmd: 'pnpm exec vitest run --project unit',
3948
jobTestFiles: specFiles,
40-
jobSetups: [{ os: 'ubuntu-latest', node_version: '18' }]
49+
jobSetups: [linux_nodeOld]
4150
},
4251
{
4352
jobName: 'Vitest (E2E tests)',
4453
jobCmd: 'pnpm exec vitest run --project e2e',
4554
jobTestFiles: specFiles,
46-
jobSetups: [{ os: 'ubuntu-latest', node_version: '18' }]
55+
jobSetups: [linux_nodeOld, windows_nodeOld]
4756
},
4857
// Check TypeScript types
4958
{
5059
jobName: 'TypeScript',
5160
jobCmd: 'pnpm exec test-types',
52-
jobSetups: [{ os: 'ubuntu-latest', node_version: '18' }]
61+
jobSetups: [linux_nodeOld]
5362
},
5463
// E2e tests
5564
...(await crawlE2eJobs(testFiles))
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react'
2+
import { isBrowser } from './isBrowser'
3+
4+
if (isBrowser) {
5+
import('./hello.client')
6+
} else {
7+
import('./hello.server')
8+
}
9+
10+
export function Page() {
11+
return (
12+
<>
13+
<p>Dyanmic import() of .client.js and .server.js</p>
14+
</>
15+
)
16+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { isBrowser } from './isBrowser'
2+
if (!isBrowser) throw new Error("I shouldn't be imported from the server")
3+
console.log('hello from client')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { isBrowser } from './isBrowser'
2+
if (isBrowser) throw new Error("I shouldn't be imported from the client")
3+
console.log('hello from server')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const isBrowser = typeof window === 'object'

test/misc/testRun.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { testRun }
22

3-
import { test, expect, fetch, fetchHtml, page, getServerUrl, autoRetry, sleep } from '@brillout/test-e2e'
3+
import { test, expect, fetch, fetchHtml, page, getServerUrl, autoRetry, sleep, expectLog } from '@brillout/test-e2e'
44
import { expectUrl, testCounter } from '../utils'
55
import { testRun as testRunClassic } from '../../examples/react-minimal/.testRun'
66
import fs from 'fs'
@@ -18,6 +18,7 @@ function testRun(cmd: 'npm run dev' | 'npm run preview' | 'npm run prod') {
1818
testPrerenderSettings()
1919
testRedirectMailto()
2020
testNavigateEarly()
21+
testDynamicImportFileEnv()
2122
testNestedConfigWorkaround()
2223
testHistoryPushState()
2324
}
@@ -98,6 +99,20 @@ function testNavigateEarly() {
9899
})
99100
}
100101

102+
function testDynamicImportFileEnv() {
103+
test('Dyanmic import() of .client.js and .server.js', async () => {
104+
await page.goto(getServerUrl() + '/dynamic-import-file-env')
105+
expect(await page.textContent('body')).toContain('Dyanmic import() of .client.js and .server.js')
106+
expectLog('hello from server', (log) => log.logSource === 'stdout')
107+
await autoRetry(
108+
() => {
109+
expectLog('hello from client', (log) => log.logSource === 'Browser Log')
110+
},
111+
{ timeout: 5000 }
112+
)
113+
})
114+
}
115+
101116
function testNestedConfigWorkaround() {
102117
// See comment in /test/misc/pages/+config.ts
103118
test('Nested config workaround', async () => {

test/preload/testRun.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ async function render(urlOriginal: '/' | '/preload-disabled' | '/preload-images'
4949
return { body, earlyHints }
5050
}
5151

52-
const workspaceRoot = path.join(__dirname, '..', '..')
52+
const workspaceRoot = getWorkspaceRoot()
5353
function stabilizePaths(str: string): string {
5454
str = str.replaceAll(workspaceRoot, '/$ROOT')
5555
return str
5656
}
57+
function getWorkspaceRoot() {
58+
let workspaceRoot = path.join(__dirname, '..', '..').split('\\').join('/')
59+
if (!workspaceRoot.startsWith('/')) workspaceRoot = '/' + workspaceRoot
60+
return workspaceRoot
61+
}

vike/node/plugin/plugins/fileEnv.ts

Lines changed: 129 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,155 @@
11
export { fileEnv }
22

33
// Implementation for https://vike.dev/file-env
4+
// Alternative implementations:
5+
// - Remix: https://github.com/remix-run/remix/blob/0e542779499b13ab9291cf20cd5e6b43e2905151/packages/remix-dev/vite/plugin.ts#L1504-L1594
6+
// - SvelteKit: https://github.com/sveltejs/kit/blob/6ea7abbc2f66e46cb83ff95cd459a5f548cb7e1e/packages/kit/src/exports/vite/index.js#L383-L401
47

5-
import type { Plugin, ResolvedConfig } from 'vite'
6-
import { assert, assertUsage, assertWarning, capitalizeFirstLetter } from '../utils.js'
8+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
9+
import { assert, assertUsage, assertWarning, capitalizeFirstLetter, joinEnglish } from '../utils.js'
710
import { extractAssetsRE } from './extractAssetsPlugin.js'
811
import { extractExportNamesRE } from './extractExportNamesPlugin.js'
912
import pc from '@brillout/picocolors'
1013
import { getModuleFilePathAbsolute } from '../shared/getFilePath.js'
14+
import { sourceMapRemove } from '../shared/rollupSourceMap.js'
15+
import { getExportNames } from '../shared/parseEsModule.js'
1116

1217
function fileEnv(): Plugin {
1318
let config: ResolvedConfig
14-
let isDev = false
19+
let viteDevServer: ViteDevServer | undefined
1520
return {
1621
name: 'vike:fileEnv',
17-
// - We need to set `enforce: 'pre'` because, otherwise, the resolvedId() hook of Vite's internal plugin `vite:resolve` is called before and it doesn't seem to call `this.resolve()` which means that the resolveId() hook below is never called.
18-
// - Vite's `vite:resolve` plugin: https://github.com/vitejs/vite/blob/d649daba7682791178b711d9a3e44a6b5d00990c/packages/vite/src/node/plugins/resolve.ts#L105
19-
// - It's actually a good thing if the resolveId() hook below is the first one to be called because it doesn't actually resolve any ID, so all other resolveId() hooks will be called as normal. And with `this.resolve()` we get the information we want from all other resolvedId() hooks.
20-
// - Path aliases are already resolved, even when using `enforce: 'pre'`. For example:
21-
// ```js
22-
// // /pages/index/+Page.tsx
23-
//
24-
// // The value of `source` is `/home/rom/code/vike/examples/path-aliases/components/Counter` (instead of `#root/components/Counter`)
25-
// // The value of `importer` is `/home/rom/code/vike/examples/path-aliases/pages/index/+Page.tsx`
26-
// import { Counter } from '#root/components/Counter'
27-
// ```
28-
enforce: 'pre',
29-
resolveId: {
30-
/* I don't know why, but path aliases aren't resolved anymore when setting `order: 'pre'`. (In principle, I'd assume that `this.resolve()` would resolve the alias but it doesn't.)
31-
order: 'pre',
32-
*/
33-
async handler(source, importer, options) {
34-
// It seems like Vite's scan doesn't apply transformers. (We need the `.telefunc.js` transformer to apply for our analysis to be correct.)
35-
// @ts-expect-error Vite's type is wrong
36-
if (options.scan) return
22+
load(id, options) {
23+
// In build, we use generateBundle() instead of the load() hook. Using load() works for dynamic imports in dev thanks to Vite's lazy transpiling, but it doesn't work in build because Rollup transpiles any dynamically imported module even if it's never actually imported.
24+
if (!viteDevServer) return
25+
if (skip(id)) return
26+
const moduleInfo = viteDevServer.moduleGraph.getModuleById(id)
27+
assert(moduleInfo)
28+
const importers: string[] = Array.from(moduleInfo.importers)
29+
.map((m) => m.id)
30+
.filter((id) => id !== null)
31+
assertFileEnv(
32+
id,
33+
!!options?.ssr,
34+
importers,
35+
// In dev, we only show a warning because we don't want to disrupt when the user plays with settings such as [ssr](https://vike.dev/ssr).
36+
true
37+
)
38+
},
39+
// In production, we have to use transform() to replace modules with a runtime error because generateBundle() doesn't work for dynamic imports. In production, dynamic imports can only be verified at runtime.
40+
async transform(code, id, options) {
41+
// In dev, only using load() is enough as it also works for dynamic imports (see sibling comment).
42+
if (viteDevServer) return
43+
if (skip(id)) return
44+
const isServerSide = !!options?.ssr
45+
if (!isWrongEnv(id, isServerSide)) return
46+
const { importers } = this.getModuleInfo(id)!
47+
// Throwing a verbose error doesn't waste client-side KBs as dynamic imports are code splitted.
48+
const errMsg = getErrorMessage(id, isServerSide, importers, false, true)
49+
// We have to inject empty exports to avoid Rollup complaining about missing exports, see https://gist.github.com/brillout/5ea45776e65bd65100a52ecd7bfda3ff
50+
const { exportNames } = await getExportNames(code)
51+
return sourceMapRemove(
52+
[
53+
`throw new Error(${JSON.stringify(errMsg)});`,
54+
...exportNames.map((name) =>
55+
name === 'default' ? 'export default undefined;' : `export const ${name} = undefined;`
56+
)
57+
].join('\n')
58+
)
59+
},
60+
generateBundle() {
61+
Array.from(this.getModuleIds())
62+
.filter((id) => !skip(id))
63+
.forEach((moduleId) => {
64+
const { importers, dynamicImporters } = this.getModuleInfo(moduleId)!
65+
if (importers.length === 0) {
66+
// Dynamic imports can only be verified at runtime
67+
assert(dynamicImporters.length > 0)
68+
return
69+
}
70+
assertFileEnv(moduleId, !!config.build.ssr, importers, false)
71+
})
72+
},
73+
configResolved(config_) {
74+
config = config_
75+
},
76+
configureServer(viteDevServer_) {
77+
viteDevServer = viteDevServer_
78+
}
79+
}
3780

38-
// TODO/v1-release: remove
39-
if (extractAssetsRE.test(source) || extractExportNamesRE.test(source)) return
81+
function assertFileEnv(
82+
moduleId: string,
83+
isServerSide: boolean,
84+
importers: string[] | readonly string[],
85+
onlyWarn: boolean
86+
) {
87+
if (!isWrongEnv(moduleId, isServerSide)) return
88+
const errMsg = getErrorMessage(moduleId, isServerSide, importers, onlyWarn, false)
89+
if (onlyWarn) {
90+
assertWarning(false, errMsg, { onlyOnce: true })
91+
} else {
92+
assertUsage(false, errMsg)
93+
}
94+
}
4095

41-
// Seems like Vite is doing some funky stuff here.
42-
if (importer?.endsWith('.html')) return
96+
function getErrorMessage(
97+
moduleId: string,
98+
isServerSide: boolean,
99+
importers: string[] | readonly string[],
100+
onlyWarn: boolean,
101+
noColor: boolean
102+
) {
103+
const modulePath = getModulePath(moduleId)
43104

44-
const resolved = await this.resolve(source, importer, {
45-
// Needed for old Vite plugins: https://vitejs.dev/guide/migration#rollup-4:~:text=For%20Vite%20plugins%2C%20this.resolve%20skipSelf%20option%20is%20now%20true%20by%20default.
46-
skipSelf: true,
47-
...options
48-
})
49-
// resolved is null when import path is erroneous and doesn't actually point to a file
50-
if (!resolved) return
51-
const moduleId = resolved.id
52-
const modulePath = moduleId.split('?')[0]!
105+
const envActual = isServerSide ? 'server' : 'client'
106+
const envExpect = isServerSide ? 'client' : 'server'
53107

54-
// `.server.js` and `.client.js` should only apply to user files
55-
if (modulePath.includes('/node_modules/')) return
108+
let errMsg: string
109+
let modulePathPretty = getModuleFilePathAbsolute(modulePath, config)
110+
if (!noColor) {
111+
const suffix = getSuffix(envExpect)
112+
modulePathPretty = modulePathPretty.replaceAll(suffix, pc.bold(suffix))
113+
}
114+
errMsg = `${capitalizeFirstLetter(
115+
envExpect
116+
)}-only file ${modulePathPretty} (https://vike.dev/file-env) imported on the ${envActual}-side`
56117

57-
// TODO/v1-release: remove
58-
// - I don't remember exactly, but I think I've added `TODO/v1-release: remove` because I vaguely remember that we can remove this after we remove the 0.4 design.
59-
if (modulePath.endsWith('.css')) return
118+
if (importers.length > 0) {
119+
const importPaths = importers.map((importer) => getModuleFilePathAbsolute(importer, config))
120+
errMsg += ` by ${joinEnglish(importPaths, 'and')}`
121+
}
60122

61-
const isServerSide = options?.ssr
62-
const envActual = isServerSide ? 'server' : 'client'
63-
const envExpect = isServerSide ? 'client' : 'server'
64-
const suffix = `.${envExpect}.` as const
123+
if (onlyWarn) {
124+
errMsg += ' and, therefore, Vike will prevent building your app for production.'
125+
}
65126

66-
// Everything is good
67-
if (!modulePath.includes(suffix)) return
127+
return errMsg
128+
}
68129

69-
// Show error message
70-
let errMsg: string
71-
let modulePathPretty = getModuleFilePathAbsolute(moduleId, config)
72-
modulePathPretty = modulePathPretty.replaceAll(suffix, pc.bold(suffix))
73-
errMsg = `${capitalizeFirstLetter(
74-
envExpect
75-
)}-only file ${modulePathPretty} (https://vike.dev/file-env) imported on the ${envActual}-side`
130+
function isWrongEnv(moduleId: string, isServerSide: boolean): boolean {
131+
const modulePath = getModulePath(moduleId)
132+
const suffixWrong = getSuffix(isServerSide ? 'client' : 'server')
133+
return modulePath.includes(suffixWrong)
134+
}
76135

77-
if (
78-
importer &&
79-
// Don't show Vike's virtual modules that import the entry plus files such as /pages/about/+Page.js
80-
!importer.includes('virtual:vike:') &&
81-
// I don't know why and who sets importer to '<stdin>' (I guess Vite?)
82-
importer !== '<stdin>'
83-
) {
84-
const importerPath = getModuleFilePathAbsolute(importer, config)
85-
errMsg += ` by ${importerPath}`
86-
}
136+
function skip(id: string): boolean {
137+
// TODO/v1-release: remove
138+
if (extractAssetsRE.test(id) || extractExportNamesRE.test(id)) return true
139+
if (!id.includes(getSuffix('client')) && !id.includes(getSuffix('server'))) return true
140+
if (getModulePath(id).endsWith('.css')) return true
141+
// Apply `.server.js` and `.client.js` only to user files
142+
if (id.includes('/node_modules/')) return true
143+
// Only user files
144+
if (!id.startsWith(config.root)) return true
145+
return false
146+
}
87147

88-
if (isDev) {
89-
errMsg += ' and, therefore, Vike will prevent building your app for production.'
90-
assertWarning(false, errMsg, { onlyOnce: true })
91-
} else {
92-
assertUsage(false, errMsg)
93-
}
94-
}
95-
},
96-
configResolved(config_) {
97-
config = config_
98-
},
99-
configureServer() {
100-
isDev = true
101-
},
102-
// Ensure this plugin works
103-
transform(_code, id, options): void {
104-
if (isDev) return
105-
// TODO/v1-release: remove
106-
if (extractAssetsRE.test(id) || extractExportNamesRE.test(id)) return
107-
if (id.split('?')[0]!.endsWith('.css')) return
148+
function getSuffix(env: 'client' | 'server') {
149+
return `.${env}.` as const
150+
}
108151

109-
const isServerSide = options?.ssr
110-
const envWrong = isServerSide ? 'client' : 'server'
111-
assert(!id.includes(`.${envWrong}.`))
112-
}
152+
function getModulePath(moduleId: string) {
153+
return moduleId.split('?')[0]!
113154
}
114155
}

0 commit comments

Comments
 (0)