diff --git a/packages/vite/src/node/__tests__/plugins/dynamicImportVar/__snapshots__/parse.test.ts.snap b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/__snapshots__/parse.test.ts.snap new file mode 100644 index 00000000000000..be73ec998bc834 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/__snapshots__/parse.test.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1 + +exports[`parse positives > ? in url 1`] = `"__variableDynamicImportRuntimeHelper((import.meta.glob(\\"./mo?ds/*.js\\", {\\"as\\":\\"raw\\",\\"import\\":\\"*\\"})), \`./mo?ds/\${base ?? foo}.js\`)"`; + +exports[`parse positives > ? in variables 1`] = `"__variableDynamicImportRuntimeHelper((import.meta.glob(\\"./mods/*.js\\", {\\"as\\":\\"raw\\",\\"import\\":\\"*\\"})), \`./mods/\${base ?? foo}.js\`)"`; + +exports[`parse positives > alias path 1`] = `"__variableDynamicImportRuntimeHelper((import.meta.glob(\\"./mods/*.js\\")), \`./mods/\${base}.js\`)"`; + +exports[`parse positives > basic 1`] = `"__variableDynamicImportRuntimeHelper((import.meta.glob(\\"./mods/*.js\\")), \`./mods/\${base}.js\`)"`; + +exports[`parse positives > with query raw 1`] = `"__variableDynamicImportRuntimeHelper((import.meta.glob(\\"./mods/*.js\\", {\\"as\\":\\"raw\\",\\"import\\":\\"*\\"})), \`./mods/\${base}.js\`)"`; + +exports[`parse positives > with query url 1`] = `"__variableDynamicImportRuntimeHelper((import.meta.glob(\\"./mods/*.js\\")), \`./mods/\${base}.js\`)"`; diff --git a/packages/vite/src/node/__tests__/plugins/dynamicImportVar/mods/hello.js b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/mods/hello.js new file mode 100644 index 00000000000000..67900ef0999962 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/mods/hello.js @@ -0,0 +1,3 @@ +export function hello() { + return 'hello' +} diff --git a/packages/vite/src/node/__tests__/plugins/dynamicImportVar/mods/hi.js b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/mods/hi.js new file mode 100644 index 00000000000000..45d3506803b2b6 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/mods/hi.js @@ -0,0 +1,3 @@ +export function hi() { + return 'hi' +} diff --git a/packages/vite/src/node/__tests__/plugins/dynamicImportVar/parse.test.ts b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/parse.test.ts new file mode 100644 index 00000000000000..ef1dcb2238a5b0 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/dynamicImportVar/parse.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { transformDynamicImport } from '../../../plugins/dynamicImportVars' +import { resolve } from 'path' + +async function run(input: string) { + const { glob, rawPattern } = await transformDynamicImport( + input, + resolve(__dirname, 'index.js'), + (id) => id.replace('@', resolve(__dirname, './mods/')) + ) + return `__variableDynamicImportRuntimeHelper(${glob}, \`${rawPattern}\`)` +} + +describe('parse positives', () => { + it('basic', async () => { + expect(await run('`./mods/${base}.js`')).toMatchSnapshot() + }) + + it('alias path', async () => { + expect(await run('`@/${base}.js`')).toMatchSnapshot() + }) + + it('with query raw', async () => { + expect(await run('`./mods/${base}.js?raw`')).toMatchSnapshot() + }) + + it('with query url', async () => { + expect(await run('`./mods/${base}.js?url`')).toMatchSnapshot() + }) + + it('? in variables', async () => { + expect(await run('`./mods/${base ?? foo}.js?raw`')).toMatchSnapshot() + }) + + it('? in url', async () => { + expect(await run('`./mo?ds/${base ?? foo}.js?raw`')).toMatchSnapshot() + }) +}) diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index d4d4085bb829ed..1bdfa5a35077e6 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -26,7 +26,6 @@ import { copyDir, emptyDir, lookupFile, normalizePath } from './utils' import { manifestPlugin } from './plugins/manifest' import commonjsPlugin from '@rollup/plugin-commonjs' import type { RollupCommonJSOptions } from 'types/commonjs' -import dynamicImportVars from '@rollup/plugin-dynamic-import-vars' import type { RollupDynamicImportVarsOptions } from 'types/dynamicImportVars' import type { Logger } from './logger' import type { TransformOptions } from 'esbuild' @@ -285,7 +284,6 @@ export function resolveBuildPlugins(config: ResolvedConfig): { watchPackageDataPlugin(config), commonjsPlugin(options.commonjsOptions), dataURIPlugin(), - dynamicImportVars(options.dynamicImportVarsOptions), assetImportMetaUrlPlugin(config), ...(options.rollupOptions.plugins ? (options.rollupOptions.plugins.filter(Boolean) as Plugin[]) diff --git a/packages/vite/src/node/plugins/dynamicImportVars.ts b/packages/vite/src/node/plugins/dynamicImportVars.ts new file mode 100644 index 00000000000000..c33590cf0343f4 --- /dev/null +++ b/packages/vite/src/node/plugins/dynamicImportVars.ts @@ -0,0 +1,217 @@ +import { posix } from 'path' +import MagicString from 'magic-string' +import { init, parse as parseImports } from 'es-module-lexer' +import type { ImportSpecifier } from 'es-module-lexer' +import type { Plugin } from '../plugin' +import type { ResolvedConfig } from '../config' +import { normalizePath, parseRequest, requestQuerySplitRE } from '../utils' +import { parse as parseJS } from 'acorn' +import { createFilter } from '@rollup/pluginutils' +import { dynamicImportToGlob } from '@rollup/plugin-dynamic-import-vars' + +export const dynamicImportHelperId = '/@vite/dynamic-import-helper' + +interface DynamicImportRequest { + as?: 'raw' +} + +interface DynamicImportPattern { + globParams: DynamicImportRequest | null + userPattern: string + rawPattern: string +} + +const dynamicImportHelper = (glob: Record, path: string) => { + const v = glob[path] + if (v) { + return typeof v === 'function' ? v() : Promise.resolve(v) + } + return new Promise((_, reject) => { + ;(typeof queueMicrotask === 'function' ? queueMicrotask : setTimeout)( + reject.bind(null, new Error('Unknown variable dynamic import: ' + path)) + ) + }) +} + +function parseDynamicImportPattern( + strings: string +): DynamicImportPattern | null { + const filename = strings.slice(1, -1) + const rawQuery = parseRequest(filename) + let globParams: DynamicImportRequest | null = null + const ast = ( + parseJS(strings, { + ecmaVersion: 'latest', + sourceType: 'module' + }) as any + ).body[0].expression + + const userPatternQuery = dynamicImportToGlob(ast, filename) + if (!userPatternQuery) { + return null + } + + const [userPattern] = userPatternQuery.split(requestQuerySplitRE, 2) + const [rawPattern] = filename.split(requestQuerySplitRE, 2) + + if (rawQuery?.raw !== undefined) { + globParams = { as: 'raw' } + } + + return { + globParams, + userPattern, + rawPattern + } +} + +export async function transformDynamicImport( + importSource: string, + importer: string, + resolve: ( + url: string, + importer?: string + ) => Promise | string | undefined +): Promise<{ + glob: string + pattern: string + rawPattern: string +} | null> { + if (importSource[1] !== '.' && importSource[1] !== '/') { + const resolvedFileName = await resolve(importSource.slice(1, -1), importer) + if (!resolvedFileName) { + return null + } + const relativeFileName = posix.relative( + posix.dirname(normalizePath(importer)), + normalizePath(resolvedFileName) + ) + importSource = normalizePath( + '`' + (relativeFileName[0] === '.' ? '' : './') + relativeFileName + '`' + ) + } + + const dynamicImportPattern = parseDynamicImportPattern(importSource) + if (!dynamicImportPattern) { + return null + } + const { globParams, rawPattern, userPattern } = dynamicImportPattern + const params = globParams + ? `, ${JSON.stringify({ ...globParams, import: '*' })}` + : '' + const exp = `(import.meta.glob(${JSON.stringify(userPattern)}${params}))` + + return { + rawPattern, + pattern: userPattern, + glob: exp + } +} + +export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { + const resolve = config.createResolver({ + preferRelative: true, + tryIndex: false, + extensions: [] + }) + const { include, exclude, warnOnError } = + config.build.dynamicImportVarsOptions + const filter = createFilter(include, exclude) + const isBuild = config.command === 'build' + return { + name: 'vite:dynamic-import-vars', + + resolveId(id) { + if (id === dynamicImportHelperId) { + return id + } + }, + + load(id) { + if (id === dynamicImportHelperId) { + return 'export default ' + dynamicImportHelper.toString() + } + }, + + async transform(source, importer) { + if (!filter(importer)) { + return + } + + await init + + let imports: readonly ImportSpecifier[] = [] + try { + imports = parseImports(source)[0] + } catch (e: any) { + // ignore as it might not be a JS file, the subsequent plugins will catch the error + return null + } + + if (!imports.length) { + return null + } + + let s: MagicString | undefined + let needDynamicImportHelper = false + + for (let index = 0; index < imports.length; index++) { + const { + s: start, + e: end, + ss: expStart, + se: expEnd, + d: dynamicIndex + } = imports[index] + + if (dynamicIndex === -1 || source[start] !== '`') { + continue + } + + s ||= new MagicString(source) + let result + try { + result = await transformDynamicImport( + source.slice(start, end), + importer, + resolve + ) + } catch (error) { + if (warnOnError) { + this.warn(error) + } else { + this.error(error) + } + } + + if (!result) { + continue + } + + const { rawPattern, glob } = result + + needDynamicImportHelper = true + s.overwrite( + expStart, + expEnd, + `__variableDynamicImportRuntimeHelper(${glob}, \`${rawPattern}\`)` + ) + } + + if (s) { + if (needDynamicImportHelper) { + s.prepend( + `import __variableDynamicImportRuntimeHelper from "${dynamicImportHelperId}";` + ) + } + return { + code: s.toString(), + map: + !isBuild || config.build.sourcemap + ? s.generateMap({ hires: true }) + : null + } + } + } + } +} diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index ed4a2bc934ab5a..f12426aa923cfe 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -490,7 +490,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const url = rawUrl .replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '') .trim() - if (!hasViteIgnore && !isSupportedDynamicImport(url)) { + if (!hasViteIgnore) { this.warn( `\n` + colors.cyan(importerModule.file) + @@ -651,27 +651,6 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { } } -/** - * https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations - * This is probably less accurate but is much cheaper than a full AST parse. - */ -function isSupportedDynamicImport(url: string) { - url = url.trim().slice(1, -1) - // must be relative - if (!url.startsWith('./') && !url.startsWith('../')) { - return false - } - // must have extension - if (!path.extname(url)) { - return false - } - // must be more specific if importing from same dir - if (url.startsWith('./${') && url.indexOf('/') === url.lastIndexOf('/')) { - return false - } - return true -} - type ImportNameSpecifier = { importedName: string; localName: string } /** diff --git a/packages/vite/src/node/plugins/importMetaGlob.ts b/packages/vite/src/node/plugins/importMetaGlob.ts index 5bf4d14a4dc9f0..ed46e35bea8ecf 100644 --- a/packages/vite/src/node/plugins/importMetaGlob.ts +++ b/packages/vite/src/node/plugins/importMetaGlob.ts @@ -11,7 +11,7 @@ import type { ViteDevServer } from '../server' import type { ModuleNode } from '../server/moduleGraph' import type { ResolvedConfig } from '../config' import { isCSSRequest } from './css' -import type { GeneralImportGlobOptions } from '../../../types/importGlob' +import type { GeneralImportGlobOptions } from 'types/importGlob' import { normalizePath, slash } from '../utils' export interface ParsedImportGlob { @@ -168,12 +168,13 @@ export async function parseImportGlob( for (const property of arg2.properties) { if ( property.type === 'SpreadElement' || - property.key.type !== 'Identifier' + (property.key.type !== 'Identifier' && + property.key.type !== 'Literal') ) throw err('Could only use literals') - const name = property.key.name as keyof GeneralImportGlobOptions - + const name = ((property.key as any).name || + (property.key as any).value) as keyof GeneralImportGlobOptions if (name === 'query') { if (property.value.type === 'ObjectExpression') { const data: Record = {} @@ -260,13 +261,22 @@ const importPrefix = '__vite_glob_' const { basename, dirname, relative, join } = posix +export interface TransformGlobImportResult { + s: MagicString + matches: ParsedImportGlob[] + files: Set +} + +/** + * @param optimizeExport for dynamicImportVar plugin don't need to optimize export. + */ export async function transformGlobImport( code: string, id: string, root: string, resolveId: IdResolver, restoreQueryExtension = false -) { +): Promise { id = slash(id) root = slash(root) const isVirtual = isVirtualModule(id) @@ -288,7 +298,7 @@ export async function transformGlobImport( } }) - if (!matches.length) return + if (!matches.length) return null const s = new MagicString(code) diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index 26d777cf919292..3fd283b07b4e47 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -19,6 +19,7 @@ import { ssrRequireHookPlugin } from './ssrRequireHook' import { workerImportMetaUrlPlugin } from './workerImportMetaUrl' import { ensureWatchPlugin } from './ensureWatch' import { metadataPlugin } from './metadata' +import { dynamicImportVarsPlugin } from './dynamicImportVars' import { importGlobPlugin } from './importMetaGlob' export async function resolvePlugins( @@ -73,6 +74,7 @@ export async function resolvePlugins( isBuild && buildHtmlPlugin(config), workerImportMetaUrlPlugin(config), ...buildPlugins.pre, + dynamicImportVarsPlugin(config), importGlobPlugin(config), ...postPlugins, ...buildPlugins.post, diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index ddcaced9832bc1..1f5ce1ffc4b764 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -19,7 +19,7 @@ import type { FSWatcher } from 'chokidar' import remapping from '@ampproject/remapping' import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping' import { performance } from 'perf_hooks' -import { parse as parseUrl, URLSearchParams } from 'url' +import { URLSearchParams } from 'url' export function slash(p: string): string { return p.replace(/\\/g, '/') @@ -723,6 +723,7 @@ export function toUpperCaseDriveLetter(pathName: string): string { export const multilineCommentsRE = /\/\*(.|[\r\n])*?\*\//gm export const singlelineCommentsRE = /\/\/.*/g +export const requestQuerySplitRE = /\?(?!.*[\/|\}])/ export const usingDynamicImport = typeof jest === 'undefined' /** @@ -739,11 +740,11 @@ export const dynamicImport = usingDynamicImport : require export function parseRequest(id: string): Record | null { - const { search } = parseUrl(id) + const [_, search] = id.split(requestQuerySplitRE, 2) if (!search) { return null } - return Object.fromEntries(new URLSearchParams(search.slice(1))) + return Object.fromEntries(new URLSearchParams(search)) } export const blankReplacer = (match: string) => ' '.repeat(match.length) diff --git a/packages/vite/types/shims.d.ts b/packages/vite/types/shims.d.ts index 0f49e9c2181e98..587b3344e6ce39 100644 --- a/packages/vite/types/shims.d.ts +++ b/packages/vite/types/shims.d.ts @@ -59,6 +59,7 @@ declare module 'postcss-modules' { declare module '@rollup/plugin-dynamic-import-vars' { import type { Plugin } from 'rollup' + import type { BaseNode } from 'estree' interface Options { include?: string | RegExp | (string | RegExp)[] @@ -68,6 +69,10 @@ declare module '@rollup/plugin-dynamic-import-vars' { const p: (o?: Options) => Plugin export default p + export function dynamicImportToGlob( + ast: BaseNode, + source: string + ): null | string } declare module 'rollup-plugin-web-worker-loader' { diff --git a/playground/dynamic-import/__tests__/dynamic-import.spec.ts b/playground/dynamic-import/__tests__/dynamic-import.spec.ts index 4730b5e990a1c3..95101a039e50f8 100644 --- a/playground/dynamic-import/__tests__/dynamic-import.spec.ts +++ b/playground/dynamic-import/__tests__/dynamic-import.spec.ts @@ -60,6 +60,30 @@ test('should load dynamic import with css', async () => { ) }) +test('should load dynamic import with vars', async () => { + await untilUpdated( + () => page.textContent('.dynamic-import-with-vars'), + 'hello', + true + ) +}) + +test('should load dynamic import with vars alias', async () => { + await untilUpdated( + () => page.textContent('.dynamic-import-with-vars-alias'), + 'hello', + true + ) +}) + +test('should load dynamic import with vars raw', async () => { + await untilUpdated( + () => page.textContent('.dynamic-import-with-vars-raw'), + 'export function hello()', + true + ) +}) + test('should load dynamic import with css in package', async () => { await page.click('.pkg-css') await untilUpdated(() => getColor('.pkg-css'), 'blue', true) diff --git a/playground/dynamic-import/alias/hello.js b/playground/dynamic-import/alias/hello.js new file mode 100644 index 00000000000000..67900ef0999962 --- /dev/null +++ b/playground/dynamic-import/alias/hello.js @@ -0,0 +1,3 @@ +export function hello() { + return 'hello' +} diff --git a/playground/dynamic-import/alias/hi.js b/playground/dynamic-import/alias/hi.js new file mode 100644 index 00000000000000..45d3506803b2b6 --- /dev/null +++ b/playground/dynamic-import/alias/hi.js @@ -0,0 +1,3 @@ +export function hi() { + return 'hi' +} diff --git a/playground/dynamic-import/index.html b/playground/dynamic-import/index.html index 8e18204a7e4296..997ad059ad6821 100644 --- a/playground/dynamic-import/index.html +++ b/playground/dynamic-import/index.html @@ -10,6 +10,20 @@ +

dynamic-import-with-vars

+
todo
+ +

dynamic-import-with-vars-alias

+
todo
+ +

dynamic-import-with-vars-raw

+
todo
+
+ diff --git a/playground/dynamic-import/nested/hello.js b/playground/dynamic-import/nested/hello.js new file mode 100644 index 00000000000000..67900ef0999962 --- /dev/null +++ b/playground/dynamic-import/nested/hello.js @@ -0,0 +1,3 @@ +export function hello() { + return 'hello' +} diff --git a/playground/dynamic-import/nested/index.js b/playground/dynamic-import/nested/index.js index f84ec00380d604..61f817ce7dd7bc 100644 --- a/playground/dynamic-import/nested/index.js +++ b/playground/dynamic-import/nested/index.js @@ -78,3 +78,17 @@ document.querySelector('.pkg-css').addEventListener('click', async () => { function text(el, text) { document.querySelector(el).textContent = text } + +const base = 'hello' + +import(`../alias/${base}.js`).then((mod) => { + text('.dynamic-import-with-vars', mod.hello()) +}) + +import(`@/${base}.js`).then((mod) => { + text('.dynamic-import-with-vars-alias', mod.hello()) +}) + +import(`../alias/${base}.js?raw`).then((mod) => { + text('.dynamic-import-with-vars-raw', JSON.stringify(mod)) +}) diff --git a/playground/dynamic-import/vite.config.js b/playground/dynamic-import/vite.config.js index 010e47d6308d30..50b90639fddd7f 100644 --- a/playground/dynamic-import/vite.config.js +++ b/playground/dynamic-import/vite.config.js @@ -1,7 +1,8 @@ const fs = require('fs') const path = require('path') +const vite = require('vite') -module.exports = { +module.exports = vite.defineConfig({ plugins: [ { name: 'copy', @@ -20,5 +21,10 @@ module.exports = { ) } } - ] -} + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'alias') + } + } +})