diff --git a/packages/vite/src/client/bundledDevClient.ts b/packages/vite/src/client/bundledDevClient.ts index aa15cdf60e54f3..d13287550d201e 100644 --- a/packages/vite/src/client/bundledDevClient.ts +++ b/packages/vite/src/client/bundledDevClient.ts @@ -11,6 +11,8 @@ import { transport, } from './client' +declare const __HMR_PARTIAL_ACCEPT__: boolean + // keep the same public exports as `client.ts`, which this entry replaces when inlined export { createHotContext, @@ -55,6 +57,7 @@ if (typeof DevRuntime !== 'undefined') { runtime, { base, + partialAccept: __HMR_PARTIAL_ACCEPT__, beforeApply: clearOverlayOrReloadOnFirstUpdate, }, ) diff --git a/packages/vite/src/client/bundledDevHmrClient.ts b/packages/vite/src/client/bundledDevHmrClient.ts index 60d11e3addfa5e..e7d09e3884687a 100644 --- a/packages/vite/src/client/bundledDevHmrClient.ts +++ b/packages/vite/src/client/bundledDevHmrClient.ts @@ -9,6 +9,7 @@ import type { NormalizedModuleRunnerTransport } from '../shared/moduleRunnerTran /** the subset of `__rolldown_runtime__` the HMR client uses */ export interface RolldownRuntimeLike { getImporters(id: string): string[] + getImportedBindings(importer: string, id: string): string[] | undefined isExecuted(id: string): boolean hasFactory(id: string): boolean removeModuleCache(id: string): void @@ -33,6 +34,7 @@ type HmrUpdate = export interface BundledDevHMRClientOptions { base: string + partialAccept: boolean /** returning `'reload'` aborts the apply — the hook reloads the page itself */ beforeApply: () => 'reload' | 'continue' } @@ -56,17 +58,43 @@ export class BundledDevHMRClient extends HMRClient { } isSelfAccepted(id: string): boolean { - return ( - this.hotModulesMap.get(id)?.callbacks.some((c) => c.deps.includes(id)) ?? - false - ) + return this.acceptsDep(id, id) } acceptsDep(parent: string, id: string): boolean { return ( this.hotModulesMap .get(parent) - ?.callbacks.some((c) => c.deps.includes(id)) ?? false + ?.callbacks.some((c) => !c.exports && c.deps.includes(id)) ?? false + ) + } + + private getAcceptedExports(id: string): Set | undefined { + let accepted: Set | undefined + for (const c of this.hotModulesMap.get(id)?.callbacks ?? []) { + if (!c.exports) continue + accepted ??= new Set() + for (const name of c.exports) accepted.add(name) + } + return accepted + } + + private acceptsAllExports(id: string, accepted: Set): boolean { + const exports = this.runtime.loadExports(id) + if (typeof exports !== 'object' || exports == null) return false + return Object.keys(exports).every((name) => accepted.has(name)) + } + + private importsOnlyAccepted( + parent: string, + id: string, + accepted: Set, + ): boolean { + if (!this.options.partialAccept) return false + return ( + this.runtime + .getImportedBindings(parent, id) + ?.every((name) => accepted.has(name)) ?? false ) } @@ -113,18 +141,23 @@ export class BundledDevHMRClient extends HMRClient { reason: `update propagated back to ${firstInvalidatedBy}, which already called \`import.meta.hot.invalidate()\``, } } - if (this.isSelfAccepted(id)) { + const acceptedExports = this.getAcceptedExports(id) + const selfAccepted = + this.isSelfAccepted(id) || + (acceptedExports !== undefined && + this.acceptsAllExports(id, acceptedExports)) + if (selfAccepted || acceptedExports) { boundaries.push({ boundary: id, acceptedVia: id, isWithinCircularImport: this.isNodeWithinCircularImports(id, stack), }) - return + if (selfAccepted) return } const parents = this.runtime .getImporters(id) .filter((p) => this.runtime.isExecuted(p)) - if (!parents.length) { + if (!acceptedExports && !parents.length) { return { type: 'full-reload', reason: `no hmr boundary found for module \`${id}\``, @@ -143,6 +176,12 @@ export class BundledDevHMRClient extends HMRClient { }) continue } + if ( + acceptedExports && + this.importsOnlyAccepted(parent, id, acceptedExports) + ) { + continue + } if (!stack.includes(parent)) { const fullReload = this.bubble( parent, diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 9b80996c048aaf..cbbaad96268753 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -126,6 +126,9 @@ async function createClientConfigValueReplacer( const hmrTimeoutReplacement = escapeReplacement(timeout) const hmrEnableOverlayReplacement = escapeReplacement(overlay) const hmrConfigNameReplacement = escapeReplacement(hmrConfigName) + const hmrPartialAcceptReplacement = escapeReplacement( + config.experimental.hmrPartialAccept, + ) const wsTokenReplacement = escapeReplacement(config.webSocketToken) const serverForwardConsoleReplacement = escapeReplacement( config.server.forwardConsole as any, @@ -144,6 +147,7 @@ async function createClientConfigValueReplacer( .replace(`__HMR_TIMEOUT__`, hmrTimeoutReplacement) .replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement) .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) + .replace(`__HMR_PARTIAL_ACCEPT__`, hmrPartialAcceptReplacement) .replace(`__WS_TOKEN__`, wsTokenReplacement) .replace(`__SERVER_FORWARD_CONSOLE__`, serverForwardConsoleReplacement) } diff --git a/packages/vite/src/shared/hmr.ts b/packages/vite/src/shared/hmr.ts index c8cec93608dee0..6e2ef7de0de852 100644 --- a/packages/vite/src/shared/hmr.ts +++ b/packages/vite/src/shared/hmr.ts @@ -14,6 +14,7 @@ interface HotCallback { // the dependencies must be fetchable paths deps: string[] fn: (modules: Array) => void + exports?: readonly string[] } export interface HMRLogger { @@ -75,13 +76,15 @@ export class HMRContext implements ViteHotContext { } } - // export names (first arg) are irrelevant on the client side, they're - // extracted in the server for propagation acceptExports( - _: string | readonly string[], + exportNames: string | readonly string[], callback?: (data: any) => void, ): void { - this.acceptDeps([this.ownerPath], ([mod]) => callback?.(mod)) + this.acceptDeps( + [this.ownerPath], + ([mod]) => callback?.(mod), + typeof exportNames === 'string' ? [exportNames] : exportNames, + ) } dispose(cb: (data: any) => void): void { @@ -154,6 +157,7 @@ export class HMRContext implements ViteHotContext { private acceptDeps( deps: string[], callback: HotCallback['fn'] = () => {}, + exports?: readonly string[], ): void { const mod: HotModule = this.hmrClient.hotModulesMap.get(this.ownerPath) || { id: this.ownerPath, @@ -162,6 +166,7 @@ export class HMRContext implements ViteHotContext { mod.callbacks.push({ deps, fn: callback, + exports, }) this.hmrClient.hotModulesMap.set(this.ownerPath, mod) } diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 34da7ec17f5fcb..8f640f455fa117 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -8,6 +8,8 @@ import { editFile, getBg, getColor, + gotoLatestBuild, + hotUpdatedLog, isBuild, isBundledDev, page, @@ -513,26 +515,25 @@ if (!isBuild) { ) }) - // bundled dev: partial accept (`import.meta.hot.acceptExports`) is not - // supported (rolldown#10061) - describe.skipIf(isBundledDev)('acceptExports', () => { + describe('acceptExports', () => { const HOT_UPDATED = /hot updated/ const CONNECTED = /connected/ const baseDir = 'accept-exports' + const openPage = (testDir: string) => + gotoLatestBuild(`${viteTestUrl}/${testDir}/`) describe('when all used exports are accepted', () => { const testDir = baseDir + '/main-accepted' const fileName = 'target.ts' const file = `${testDir}/${fileName}` - const url = '/' + file let dep = 'dep0' beforeAll(async () => { await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) @@ -543,7 +544,6 @@ if (!isBuild) { it('the callback is called with the new version the module', async () => { const callbackFile = `${testDir}/callback.ts` - const callbackUrl = '/' + callbackFile await untilBrowserLogAfter( () => { @@ -557,7 +557,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ 'reloaded >>> Y', - `[vite] hot updated: ${callbackUrl}`, + hotUpdatedLog(callbackFile), ]) }, ) @@ -572,7 +572,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ 'reloaded (2) >>> Z', - `[vite] hot updated: ${callbackUrl}`, + hotUpdatedLog(callbackFile), ]) }, ) @@ -593,7 +593,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ `<<<<<< A0 B0 D0 ; ${dep}`, - `[vite] hot updated: ${url}`, + hotUpdatedLog(file), ]) }, ) @@ -608,7 +608,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ `<<<<<< A1 B1 D1 ; ${dep}`, - `[vite] hot updated: ${url}`, + hotUpdatedLog(file), ]) }, ) @@ -632,7 +632,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ `<<<<<< A2 B2 D2 ; ${dep}`, - `[vite] hot updated: ${url}`, + hotUpdatedLog(file), ]) }, ) @@ -668,7 +668,7 @@ if (!isBuild) { beforeAll(async () => { await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< named: ${a} ; ${dep}`) @@ -731,7 +731,7 @@ if (!isBuild) { const file = 'side-effects.ts' await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, />>>/], (logs) => { expect(logs).toContain('>>> side FX') @@ -748,7 +748,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ '>>> side FX !!', - `[vite] hot updated: /${testDir}/${file}`, + hotUpdatedLog(`${testDir}/${file}`), ]) }, ) @@ -760,10 +760,9 @@ if (!isBuild) { test('accepts itself if no exports are imported', async () => { const fileName = 'unused.ts' const file = `${testDir}/${fileName}` - const url = '/' + file await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, '-- unused --'], (logs) => { expect(logs).toContain('-- unused --') @@ -779,7 +778,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual(['-> unused <-', `[vite] hot updated: ${url}`]) + expect(logs).toEqual(['-> unused <-', hotUpdatedLog(file)]) }, ) }) @@ -789,7 +788,7 @@ if (!isBuild) { const file = `${testDir}/${fileName}` await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, '-- used --'], (logs) => { expect(logs).toContain('-- used --') @@ -824,10 +823,9 @@ if (!isBuild) { it('accepts itself if all its exports are accepted', async () => { const fileName = 'deps-all-accepted.ts' const file = `${testDir}/${fileName}` - const url = '/' + file await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, '>>> ready <<<'], (logs) => { expect(logs).toContain('loaded:all:a0b0c0default0') @@ -843,7 +841,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ 'all >>>>>> a1, b1, c1', - `[vite] hot updated: ${url}`, + hotUpdatedLog(file), ]) }, ) @@ -856,7 +854,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ 'all >>>>>> a2, b2, c2', - `[vite] hot updated: ${url}`, + hotUpdatedLog(file), ]) }, ) @@ -867,7 +865,7 @@ if (!isBuild) { const file = `${testDir}/${fileName}` await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, '>>> ready <<<'], (logs) => { expect(logs).toContain('loaded:some:a0b0c0default0') diff --git a/playground/hmr/__tests__/no-partial-accept/hmr-no-partial-accept.spec.ts b/playground/hmr/__tests__/no-partial-accept/hmr-no-partial-accept.spec.ts new file mode 100644 index 00000000000000..5f242bff704e03 --- /dev/null +++ b/playground/hmr/__tests__/no-partial-accept/hmr-no-partial-accept.spec.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { + editFile, + gotoLatestBuild, + hotUpdatedLog, + isBuild, + page, + untilBrowserLogAfter, + viteTestUrl, +} from '~utils' + +describe.runIf(!isBuild)('acceptExports with hmrPartialAccept off', () => { + const CONNECTED = /connected/ + const openPage = (testDir: string) => + gotoLatestBuild(`${viteTestUrl}/${testDir}/`) + + describe('importer that uses only accepted exports', () => { + const testDir = 'accept-exports/main-accepted' + const file = `${testDir}/target.ts` + + beforeAll(async () => { + await untilBrowserLogAfter( + () => openPage(testDir), + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain('<<<<<< A0 B0 D0 ; dep0') + expect(logs).toContain('>>>>>> A0 D0') + }, + ) + }) + + it('is not skipped, so the update reaches the root and reloads the page', async () => { + await untilBrowserLogAfter( + async () => { + const loadPromise = page.waitForEvent('load') + editFile(file, (code) => code.replace(/([ABD])0/g, '$11') + '\n') + await loadPromise + }, + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain('<<<<<< A1 B1 D1 ; dep0') + expect(logs).toContain('>>>>>> A1 D1') + }, + ) + }) + }) + + describe('module that accepts all its exports', () => { + const testDir = 'accept-exports/star-imports' + const file = `${testDir}/deps-all-accepted.ts` + + it('still accepts itself', async () => { + await untilBrowserLogAfter( + () => openPage(testDir), + [CONNECTED, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('all >>>>>> a0, b0, c0') + }, + ) + + await untilBrowserLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11') + '\n') + }, + /hot updated/, + (logs) => { + expect(logs).toEqual(['all >>>>>> a1, b1, c1', hotUpdatedLog(file)]) + }, + ) + }) + }) +}) diff --git a/playground/hmr/vite.config-no-partial-accept.js b/playground/hmr/vite.config-no-partial-accept.js new file mode 100644 index 00000000000000..cfd880942b0330 --- /dev/null +++ b/playground/hmr/vite.config-no-partial-accept.js @@ -0,0 +1,8 @@ +import { defineConfig, mergeConfig } from 'vite' +import baseConfig from './vite.config.ts' + +export default defineConfig((env) => + mergeConfig(baseConfig(env), { + experimental: { hmrPartialAccept: false }, + }), +) diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index 030775cd3fa3f2..152e32ddea69cc 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -4,12 +4,26 @@ import { defineConfig } from 'vite' import type { Plugin } from 'vite' import { TestCssLinkPlugin } from './css-link/plugin.ts' +const acceptExportsPages = [ + 'main-accepted', + 'main-non-accepted', + 'side-effects', + 'unused-exports', + 'star-imports', + 'dynamic-imports', +].map((dir) => + path.resolve(import.meta.dirname, `./accept-exports/${dir}/index.html`), +) + export default defineConfig(({ command }) => ({ input: [ path.resolve(import.meta.dirname, './index.html'), ...(command === 'build' ? [] - : [path.resolve(import.meta.dirname, './missing-import/index.html')]), + : [ + path.resolve(import.meta.dirname, './missing-import/index.html'), + ...acceptExportsPages, + ]), path.resolve( import.meta.dirname, './unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', diff --git a/playground/test-utils.ts b/playground/test-utils.ts index 9bc4a5a2c7f7cf..40c65f0be92ec2 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -18,7 +18,14 @@ import type { import type { DepOptimizationMetadata, Manifest } from 'vite' import { normalizePath } from 'vite' import { expect } from 'vitest' -import { isWindows, page, sourcemapSnapshot, testDir } from './vitestSetup' +import { + isBundledDev, + isWindows, + page, + sourcemapSnapshot, + testDir, + viteServer, +} from './vitestSetup' export * from './vitestSetup' @@ -273,6 +280,20 @@ export function readDepOptimizationMetadata( ) } +export async function gotoLatestBuild(url: string): Promise { + if (isBundledDev) { + const bundledDev = viteServer.environments.client.bundledDev as any + await bundledDev.devEngine.ensureLatestBuildOutput() + } + await page.goto(url) +} + +export function hotUpdatedLog(file: string): string { + return isBundledDev + ? `[vite] hot updated: ${normalizePath(path.relative(process.cwd(), testDir))}/${file}` + : `[vite] hot updated: /${file}` +} + type UntilBrowserLogAfterCallback = (logs: string[]) => PromiseLike | void export async function untilBrowserLogAfter( diff --git a/playground/vitestGlobalSetup.ts b/playground/vitestGlobalSetup.ts index 1e9637bd178ed2..29983e7c756e73 100644 --- a/playground/vitestGlobalSetup.ts +++ b/playground/vitestGlobalSetup.ts @@ -42,6 +42,7 @@ export async function setup(project: TestProject): Promise { const dedicatedCopyList: Record = { assets: ['encoded-base', 'relative-base', 'runtime-base', 'url-base'], css: ['lightningcss'], + hmr: ['no-partial-accept'], 'transform-plugin': ['base'], } const cpPromises = []