diff --git a/packages/vite/misc/rolldown-runtime.js b/packages/vite/misc/rolldown-runtime.js index 9a1ab16ffa7dbe..2dd968ae9adf50 100644 --- a/packages/vite/misc/rolldown-runtime.js +++ b/packages/vite/misc/rolldown-runtime.js @@ -82,10 +82,30 @@ var __toBinary = /* @__PURE__ */ (() => { } })() +/** + * @typedef {(runtime: unknown) => void} ModuleFactory + * @typedef {Record} ModuleFactoryMap + * @typedef {{ exports: unknown, parents: string[], hot: any }} ModuleCacheEntry + * @typedef {Record} ModuleCache + */ + self.__rolldown_runtime = { + /** + * @type {string[]} + */ executeModuleStack: [], + /** + * @type {ModuleCache} + */ moduleCache: {}, + /** + * @type {ModuleFactoryMap} + */ moduleFactoryMap: {}, + /** + * @param {string} id + * @returns {unknown} + */ require: function (id) { const parent = this.executeModuleStack.at(-1) if (this.moduleCache[id]) { @@ -122,9 +142,10 @@ self.__rolldown_runtime = { }) this.executeModuleStack.push(id) factory({ - require: this.require.bind(this), module, exports: module.exports, + require: this.require.bind(this), + ensureChunk: this.ensureChunk.bind(this), __toCommonJS, __toESM, __export, @@ -133,6 +154,9 @@ self.__rolldown_runtime = { this.executeModuleStack.pop() return module.exports }, + /** + * @param {ModuleFactoryMap} newModuleFactoryMap + */ patch: function (newModuleFactoryMap) { var boundaries = [] var invalidModuleIds = [] @@ -228,4 +252,22 @@ self.__rolldown_runtime = { } } }, + /** @type {{ chunks: Record }} */ + manifest: {}, + /** + * @param {string} chunkName + */ + async ensureChunk(chunkName) { + await this.ensureChunkDeps(chunkName) + const file = this.manifest.chunks[chunkName].fileName + await import(`/${file}`) + }, + /** + * @param {string} chunkName + */ + async ensureChunkDeps(chunkName) { + for (const file of this.manifest.chunks[chunkName].imports) { + await import(`/${file}`) + } + }, } diff --git a/packages/vite/src/node/server/environments/rolldown.ts b/packages/vite/src/node/server/environments/rolldown.ts index 0ce3d989b4a707..7b94d580a21476 100644 --- a/packages/vite/src/node/server/environments/rolldown.ts +++ b/packages/vite/src/node/server/environments/rolldown.ts @@ -8,7 +8,7 @@ import MagicString from 'magic-string' import type * as rolldown from 'rolldown' import * as rolldownExperimental from 'rolldown/experimental' import sirv from 'sirv' -import { createLogger } from '../../publicUtils' +import { createLogger, normalizePath } from '../../publicUtils' import { DevEnvironment } from '../environment' import type { ConfigEnv, @@ -294,20 +294,22 @@ class RolldownEnvironment extends DevEnvironment { ) } - async buildHmr( - file: string, - ): Promise { + async buildHmr(file: string): Promise<{ + manifest: ChunkManifest + chunk?: rolldown.RolldownOutputChunk + }> { logger.info(`hmr '${file}'`, { timestamp: true }) console.time(`[rolldown:${this.name}:rebuild]`) await this.buildInner() console.timeEnd(`[rolldown:${this.name}:rebuild]`) + const manifest = getChunkManifest(this.result.output) const chunk = this.result.output.find( (v) => v.type === 'chunk' && v.name === 'hmr-update', ) if (chunk) { assert(chunk.type === 'chunk') - return chunk } + return { manifest, chunk } } async handleUpdate(ctx: HmrContext): Promise { @@ -327,17 +329,32 @@ class RolldownEnvironment extends DevEnvironment { this.rolldownDevOptions.hmr || this.rolldownDevOptions.ssrModuleRunner ) { - const chunk = await this.buildHmr(ctx.file) - if (!chunk) { - return - } + const result = await this.buildHmr(ctx.file) if (this.name === 'client') { - ctx.server.ws.send('rolldown:hmr', chunk.fileName) + ctx.server.ws.send('rolldown:hmr', { + manifest: result.manifest, + fileName: result.chunk?.fileName, + }) + // full reload on html + // TODO: what's the general way to handle this? + // should plugin (vite:build-html) be responsible of handling this? + if (ctx.file.endsWith('.html')) { + ctx.server.ws.send({ + type: 'full-reload', + path: + '/' + normalizePath(path.relative(this.config.root, ctx.file)), + }) + } } else { - this.getRunner().evaluate( - chunk.code, - path.join(this.outDir, chunk.fileName), - ) + // TODO: manifest + if (result.chunk) { + await ( + await this.getRunner() + ).evaluate( + result.chunk.code, + path.join(this.outDir, result.chunk.fileName), + ) + } } } else { await this.build() @@ -349,20 +366,20 @@ class RolldownEnvironment extends DevEnvironment { runner!: RolldownModuleRunner - getRunner() { + async getRunner() { if (!this.runner) { const output = this.result.output[0] const filepath = path.join(this.outDir, output.fileName) this.runner = new RolldownModuleRunner() const code = fs.readFileSync(filepath, 'utf-8') - this.runner.evaluate(code, filepath) + await this.runner.evaluate(code, filepath) } return this.runner } async import(input: string): Promise { if (this.outputOptions.format === 'experimental-app') { - return this.getRunner().import(input) + return (await this.getRunner()).import(input) } // input is no use const output = this.result.output[0] @@ -390,14 +407,14 @@ class RolldownModuleRunner { return mod.exports } - evaluate(code: string, sourceURL: string) { + async evaluate(code: string, sourceURL: string) { // extract sourcemap and move to the bottom const sourcemap = code.match(/^\/\/# sourceMappingURL=.*/m)?.[0] ?? '' if (sourcemap) { code = code.replace(sourcemap, '') } code = `\ -'use strict';(${Object.keys(this.context).join(',')})=>{{${code} +'use strict';async (${Object.keys(this.context).join(',')})=>{{${code} }} //# sourceURL=${sourceURL} //# sourceMappingSource=rolldown-module-runner @@ -405,7 +422,7 @@ ${sourcemap} ` const fn = (0, eval)(code) try { - fn(...Object.values(this.context)) + await fn(...Object.values(this.context)) } catch (e) { console.error('[RolldownModuleRunner:ERROR]', e) throw e @@ -416,53 +433,91 @@ ${sourcemap} function patchRuntimePlugin(environment: RolldownEnvironment): rolldown.Plugin { return { name: 'vite:rolldown-patch-runtime', - renderChunk(code, chunk) { - // TODO: this magic string is heavy - - if (chunk.name === 'hmr-update') { - const output = new MagicString(code) - output.append(` -self.__rolldown_runtime.patch(__rolldown_modules); -`) - return { - code: output.toString(), - map: output.generateMap({ hires: 'boundary' }), - } + renderChunk(code) { + // TODO: source map is broken otherwise + const output = new MagicString(code) + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), } - - if (chunk.isEntry) { - const output = new MagicString(code) - assert(chunk.facadeModuleId) - const stableId = path.relative( - environment.config.root, - chunk.facadeModuleId, - ) - output.append(` + }, + generateBundle(_options, bundle) { + // inject chunk manifest + const manifest = getChunkManifest(Object.values(bundle)) + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + if (chunk.isEntry) { + chunk.code += + '\n;' + + fs.readFileSync( + path.join(VITE_PACKAGE_DIR, 'misc', 'rolldown-runtime.js'), + 'utf-8', + ) + if (environment.name === 'client') { + chunk.code += getRolldownClientCode() + } + if (environment.rolldownDevOptions.reactRefresh) { + chunk.code += getReactRefreshRuntimeCode() + } + chunk.code += ` +self.__rolldown_runtime.manifest = ${JSON.stringify(manifest, null, 2)}; +` + } + if (chunk.name === 'hmr-update') { + chunk.code += ` +self.__rolldown_runtime.patch(__rolldown_modules); +` + } else { + // TODO: avoid top-level-await? + chunk.code += ` Object.assign(self.__rolldown_runtime.moduleFactoryMap, __rolldown_modules); +await self.__rolldown_runtime.ensureChunkDeps(${JSON.stringify(chunk.name)}); +` + } + if (chunk.isEntry) { + assert(chunk.facadeModuleId) + const stableId = path.relative( + environment.config.root, + chunk.facadeModuleId, + ) + chunk.code += ` self.__rolldown_runtime.require(${JSON.stringify(stableId)}); -`) - - // inject runtime - const runtimeCode = fs.readFileSync( - path.join(VITE_PACKAGE_DIR, 'misc', 'rolldown-runtime.js'), - 'utf-8', - ) - output.prepend(runtimeCode) - if (environment.name === 'client') { - output.prepend(getRolldownClientCode()) - } - if (environment.rolldownDevOptions.reactRefresh) { - output.prepend(getReactRefreshRuntimeCode()) - } - return { - code: output.toString(), - map: output.generateMap({ hires: 'boundary' }), +` + } + chunk.code = moveInlineSourcemapToEnd(chunk.code) } } }, } } +export type ChunkManifest = { + chunks: Record +} + +function getChunkManifest( + outputs: (rolldown.RolldownOutputChunk | rolldown.RolldownOutputAsset)[], +): ChunkManifest { + const manifest: ChunkManifest = { + chunks: {}, + } + for (const chunk of outputs) { + if (chunk.type === 'chunk') { + const { fileName, imports } = chunk + manifest.chunks[chunk.name] = { fileName, imports } + } + } + return manifest +} + +function moveInlineSourcemapToEnd(code: string) { + const sourcemap = code.match(/^\/\/# sourceMappingURL=.*/m)?.[0] + if (sourcemap) { + code = code.replace(sourcemap, '') + '\n' + sourcemap + } + return code +} + // patch vite:css transform for hmr function patchCssPlugin(): rolldown.Plugin { return { @@ -518,12 +573,15 @@ function getRolldownClientCode() { code += ` const hot = createHotContext("/__rolldown"); hot.on("rolldown:hmr", (data) => { - import("/" + data + "?t=" + Date.now()); + self.__rolldown_runtime.manifest = data.manifest; + if (data.fileName) { + import("/" + data.fileName + "?t=" + Date.now()); + } }); self.__rolldown_hot = hot; self.__rolldown_updateStyle = updateStyle; ` - return `;(() => {/*** @vite/client ***/\n${code}}\n)();` + return `\n;(() => {/*** @vite/client ***/\n${code}}\n)();\n` } function reactRefreshPlugin(): rolldown.Plugin { @@ -561,7 +619,7 @@ function getReactRefreshRuntimeCode() { 'utf-8', ) const output = new MagicString(code) - output.prepend('self.__react_refresh_runtime = {};\n') + output.prepend('\n;self.__react_refresh_runtime = {};\n') output.replaceAll('process.env.NODE_ENV !== "production"', 'true') output.replaceAll(/\bexports\./g, '__react_refresh_runtime.') output.append(` diff --git a/playground/rolldown-dev-mpa/__tests__/basic.spec.ts b/playground/rolldown-dev-mpa/__tests__/basic.spec.ts index 36184a5432a684..1ff5c869cdb17a 100644 --- a/playground/rolldown-dev-mpa/__tests__/basic.spec.ts +++ b/playground/rolldown-dev-mpa/__tests__/basic.spec.ts @@ -2,10 +2,13 @@ import { test } from 'vitest' import { page } from '../../test-utils' test('basic', async () => { + page.setDefaultTimeout(1000) await page.getByRole('heading', { name: 'Home' }).click() await page.getByText('Rendered by /index.js').click() + await page.getByText('shared: [ok]').click() await page.getByRole('link', { name: 'About' }).click() await page.waitForURL(/\/about/) await page.getByRole('heading', { name: 'About' }).click() await page.getByText('Rendered by /about/index.js').click() + await page.getByText('shared: [ok]').click() }) diff --git a/playground/rolldown-dev-mpa/about/index.html b/playground/rolldown-dev-mpa/about/index.html index 6c295a1671186f..378dd87df6754d 100644 --- a/playground/rolldown-dev-mpa/about/index.html +++ b/playground/rolldown-dev-mpa/about/index.html @@ -6,7 +6,7 @@

About

diff --git a/playground/rolldown-dev-mpa/about/index.js b/playground/rolldown-dev-mpa/about/index.js index 2ed4b10fa69e81..84863e60802935 100644 --- a/playground/rolldown-dev-mpa/about/index.js +++ b/playground/rolldown-dev-mpa/about/index.js @@ -1,3 +1,6 @@ +import shared from '../shared' + document.getElementById('root').innerHTML = `

Rendered by /about/index.js: ${Math.random().toString(36).slice(2)}

+
shared: ${shared}
` diff --git a/playground/rolldown-dev-mpa/index.html b/playground/rolldown-dev-mpa/index.html index 0564e5db9b089f..c4bed2f788fb57 100644 --- a/playground/rolldown-dev-mpa/index.html +++ b/playground/rolldown-dev-mpa/index.html @@ -6,7 +6,7 @@

Home

diff --git a/playground/rolldown-dev-mpa/index.js b/playground/rolldown-dev-mpa/index.js index a80b0213257e3a..250f54a1f9c8d7 100644 --- a/playground/rolldown-dev-mpa/index.js +++ b/playground/rolldown-dev-mpa/index.js @@ -1,3 +1,6 @@ +import shared from './shared' + document.getElementById('root').innerHTML = `

Rendered by /index.js: ${Math.random().toString(36).slice(2)}

+
shared: ${shared}
` diff --git a/playground/rolldown-dev-mpa/shared.js b/playground/rolldown-dev-mpa/shared.js new file mode 100644 index 00000000000000..ca3fb00232166e --- /dev/null +++ b/playground/rolldown-dev-mpa/shared.js @@ -0,0 +1 @@ +export default '[ok]' diff --git a/playground/rolldown-dev-react/__tests__/basic.spec.ts b/playground/rolldown-dev-react/__tests__/basic.spec.ts index 193301c1de2385..342bf8c7677c88 100644 --- a/playground/rolldown-dev-react/__tests__/basic.spec.ts +++ b/playground/rolldown-dev-react/__tests__/basic.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' import { addFile, editFile, isBuild, page, viteTestUrl } from '../../test-utils' test('basic', async () => { @@ -92,3 +92,28 @@ test.runIf(!isBuild)('hmr new file', async () => { await page.getByRole('button', { name: 'Count-[new-file:ok]: 1' }).click() }) + +test('dynamic import chunk', async () => { + await page.goto(viteTestUrl) + await page.locator('.test-dynamic-import button').click() + await expect + .poll(() => page.textContent('.test-dynamic-import')) + .toContain('[ok]') +}) + +test.runIf(!isBuild)('dynamic import chunk update', async () => { + await page.goto(viteTestUrl) + editFile('./src/dynamic-import-dep.ts', (s) => s.replace('[ok]', '[ok-edit]')) + await vi.waitFor( + async () => { + await page.locator('.test-dynamic-import button').click() + await expect + .poll(() => page.textContent('.test-dynamic-import')) + .toContain('[ok-edit]') + }, + { + timeout: 2000, + interval: 500, + }, + ) +}) diff --git a/playground/rolldown-dev-react/src/app.tsx b/playground/rolldown-dev-react/src/app.tsx index 4138c54e249a0f..4ef63cbcc10ff9 100644 --- a/playground/rolldown-dev-react/src/app.tsx +++ b/playground/rolldown-dev-react/src/app.tsx @@ -7,6 +7,7 @@ import { throwError } from './error' import './test-style.css' import testStyleInline from './test-style-inline.css?inline' import testStyleUrl from './test-style-url.css?url' +import { DynamicImport } from './dynamic-import' // TODO: isolating finalizer doesn't rewrite yet // const testAssetTxt = new URL('./test-asset.txt', import.meta.url).href; @@ -36,6 +37,7 @@ export function App() {
           [css?inline] orange
         
+ ) diff --git a/playground/rolldown-dev-react/src/dynamic-import-dep.ts b/playground/rolldown-dev-react/src/dynamic-import-dep.ts new file mode 100644 index 00000000000000..ca3fb00232166e --- /dev/null +++ b/playground/rolldown-dev-react/src/dynamic-import-dep.ts @@ -0,0 +1 @@ +export default '[ok]' diff --git a/playground/rolldown-dev-react/src/dynamic-import.tsx b/playground/rolldown-dev-react/src/dynamic-import.tsx new file mode 100644 index 00000000000000..ac046da3dd4321 --- /dev/null +++ b/playground/rolldown-dev-react/src/dynamic-import.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +export function DynamicImport() { + const [value, setValue] = React.useState('???') + + return ( +
+ {' '} + {value} +
+ ) +}