From ae52c7454e63b0a52d8f86603d30997be08f9209 Mon Sep 17 00:00:00 2001 From: Hana Date: Thu, 10 Sep 2026 17:18:26 +0800 Subject: [PATCH 1/2] feat(bundled-dev): support `import.meta.hot.acceptExports` Claude-Session: https://claude.ai/code/session_01BeL44SLuF1syUeXWWYH2BN --- packages/vite/src/client/bundledDevClient.ts | 3 + .../vite/src/client/bundledDevHmrClient.ts | 67 +++++++++++++++++- .../vite/src/node/plugins/clientInjections.ts | 4 ++ packages/vite/src/shared/hmr.ts | 6 +- playground/hmr/__tests__/hmr.spec.ts | 70 ++++++++----------- playground/hmr/vite.config.ts | 17 ++++- 6 files changed, 119 insertions(+), 48 deletions(-) diff --git a/packages/vite/src/client/bundledDevClient.ts b/packages/vite/src/client/bundledDevClient.ts index 58de0bc2afe4db..c1bd4f52c6018a 100644 --- a/packages/vite/src/client/bundledDevClient.ts +++ b/packages/vite/src/client/bundledDevClient.ts @@ -13,6 +13,8 @@ import { updateStyle, } from './client' +declare const __HMR_PARTIAL_ACCEPT__: boolean + // keep the same public exports as `client.ts`, which this entry replaces when inlined export { createHotContext, @@ -60,6 +62,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..46da4883adf89e 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' } @@ -41,6 +43,8 @@ export class BundledDevHMRClient extends HMRClient { private applyQueue = Promise.resolve() private lastSeq = 0 private reloadPending = false + private acceptedExportsMap = new Map>() + private partialAcceptCallbacks = new WeakSet<(...args: any[]) => void>() constructor( logger: HMRLogger, @@ -57,11 +61,39 @@ export class BundledDevHMRClient extends HMRClient { isSelfAccepted(id: string): boolean { return ( - this.hotModulesMap.get(id)?.callbacks.some((c) => c.deps.includes(id)) ?? - false + this.hotModulesMap + .get(id) + ?.callbacks.some( + (c) => c.deps.includes(id) && !this.partialAcceptCallbacks.has(c.fn), + ) ?? false ) } + registerAcceptedExports( + owner: string, + exportNames: string | readonly string[], + callback: (...args: any[]) => void, + ): void { + const accepted = this.acceptedExportsMap.get(owner) ?? new Set() + for (const name of typeof exportNames === 'string' + ? [exportNames] + : exportNames) { + accepted.add(name) + } + this.acceptedExportsMap.set(owner, accepted) + this.partialAcceptCallbacks.add(callback) + } + + clearAcceptedExports(owner: string): void { + this.acceptedExportsMap.delete(owner) + } + + 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)) + } + acceptsDep(parent: string, id: string): boolean { return ( this.hotModulesMap @@ -113,7 +145,11 @@ 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.acceptedExportsMap.get(id) + if ( + this.isSelfAccepted(id) || + (acceptedExports && this.acceptsAllExports(id, acceptedExports)) + ) { boundaries.push({ boundary: id, acceptedVia: id, @@ -121,16 +157,25 @@ export class BundledDevHMRClient extends HMRClient { }) return } + if (acceptedExports) { + boundaries.push({ + boundary: id, + acceptedVia: id, + isWithinCircularImport: this.isNodeWithinCircularImports(id, stack), + }) + } const parents = this.runtime .getImporters(id) .filter((p) => this.runtime.isExecuted(p)) if (!parents.length) { + if (acceptedExports) return return { type: 'full-reload', reason: `no hmr boundary found for module \`${id}\``, } } for (const parent of parents) { + if (acceptedExports && parent === id) continue const subChain = [...stack, parent] if (this.acceptsDep(parent, id)) { boundaries.push({ @@ -143,6 +188,12 @@ export class BundledDevHMRClient extends HMRClient { }) continue } + if (acceptedExports && this.options.partialAccept) { + const bindings = this.runtime.getImportedBindings?.(parent, id) + if (bindings && bindings.every((name) => acceptedExports.has(name))) { + continue + } + } if (!stack.includes(parent)) { const fullReload = this.bubble( parent, @@ -425,6 +476,16 @@ export class BundledDevHMRContext extends HMRContext { private owner: string, ) { super(bundledDevClient, owner) + bundledDevClient.clearAcceptedExports(owner) + } + + override acceptExports( + exportNames: string | readonly string[], + callback?: (data: any) => void, + ): void { + const fn = ([mod]: any[]) => callback?.(mod) + this.bundledDevClient.registerAcceptedExports(this.owner, exportNames, fn) + this.acceptDeps([this.owner], fn) } override invalidate(message: string): void { diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index e55e1e4a778052..690ea8da4fcb2c 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -122,6 +122,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, @@ -140,6 +143,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..458fe588a4fe25 100644 --- a/packages/vite/src/shared/hmr.ts +++ b/packages/vite/src/shared/hmr.ts @@ -75,8 +75,8 @@ export class HMRContext implements ViteHotContext { } } - // export names (first arg) are irrelevant on the client side, they're - // extracted in the server for propagation + // in unbundled dev the export names (first arg) are extracted by the server; + // the bundled-dev context overrides this method and reads them itself acceptExports( _: string | readonly string[], callback?: (data: any) => void, @@ -151,7 +151,7 @@ export class HMRContext implements ViteHotContext { this.hmrClient.send({ type: 'custom', event, data }) } - private acceptDeps( + protected acceptDeps( deps: string[], callback: HotCallback['fn'] = () => {}, ): void { diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 34da7ec17f5fcb..1cb3cecf93a068 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -15,6 +15,7 @@ import { removeFile, serverLogs, untilBrowserLogAfter, + viteServer, viteTestUrl, } from '~utils' @@ -513,26 +514,34 @@ 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 hotUpdated = (file: string) => + isBundledDev + ? `[vite] hot updated: playground-temp/hmr/${file}` + : `[vite] hot updated: /${file}` const baseDir = 'accept-exports' + const openPage = async (testDir: string) => { + if (isBundledDev) { + const bundledDev = viteServer.environments.client.bundledDev as any + await bundledDev.devEngine.ensureLatestBuildOutput() + } + await page.goto(`${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 +552,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( () => { @@ -555,10 +563,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual([ - 'reloaded >>> Y', - `[vite] hot updated: ${callbackUrl}`, - ]) + expect(logs).toEqual(['reloaded >>> Y', hotUpdated(callbackFile)]) }, ) @@ -572,7 +577,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ 'reloaded (2) >>> Z', - `[vite] hot updated: ${callbackUrl}`, + hotUpdated(callbackFile), ]) }, ) @@ -591,10 +596,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual([ - `<<<<<< A0 B0 D0 ; ${dep}`, - `[vite] hot updated: ${url}`, - ]) + expect(logs).toEqual([`<<<<<< A0 B0 D0 ; ${dep}`, hotUpdated(file)]) }, ) }) @@ -606,10 +608,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual([ - `<<<<<< A1 B1 D1 ; ${dep}`, - `[vite] hot updated: ${url}`, - ]) + expect(logs).toEqual([`<<<<<< A1 B1 D1 ; ${dep}`, hotUpdated(file)]) }, ) }) @@ -630,10 +629,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual([ - `<<<<<< A2 B2 D2 ; ${dep}`, - `[vite] hot updated: ${url}`, - ]) + expect(logs).toEqual([`<<<<<< A2 B2 D2 ; ${dep}`, hotUpdated(file)]) }, ) }) @@ -668,7 +664,7 @@ if (!isBuild) { beforeAll(async () => { await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< named: ${a} ; ${dep}`) @@ -731,7 +727,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 +744,7 @@ if (!isBuild) { (logs) => { expect(logs).toEqual([ '>>> side FX !!', - `[vite] hot updated: /${testDir}/${file}`, + hotUpdated(`${testDir}/${file}`), ]) }, ) @@ -760,10 +756,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 +774,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual(['-> unused <-', `[vite] hot updated: ${url}`]) + expect(logs).toEqual(['-> unused <-', hotUpdated(file)]) }, ) }) @@ -789,7 +784,7 @@ if (!isBuild) { const file = `${testDir}/${fileName}` await untilBrowserLogAfter( - () => page.goto(`${viteTestUrl}/${testDir}/`), + () => openPage(testDir), [CONNECTED, '-- used --'], (logs) => { expect(logs).toContain('-- used --') @@ -824,10 +819,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') @@ -841,10 +835,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual([ - 'all >>>>>> a1, b1, c1', - `[vite] hot updated: ${url}`, - ]) + expect(logs).toEqual(['all >>>>>> a1, b1, c1', hotUpdated(file)]) }, ) @@ -854,10 +845,7 @@ if (!isBuild) { }, HOT_UPDATED, (logs) => { - expect(logs).toEqual([ - 'all >>>>>> a2, b2, c2', - `[vite] hot updated: ${url}`, - ]) + expect(logs).toEqual(['all >>>>>> a2, b2, c2', hotUpdated(file)]) }, ) }) @@ -867,7 +855,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/vite.config.ts b/playground/hmr/vite.config.ts index 030775cd3fa3f2..046f42da1a114e 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -9,7 +9,22 @@ export default defineConfig(({ command }) => ({ 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'), + ...[ + '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`, + ), + ), + ]), path.resolve( import.meta.dirname, './unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', From 39bfe2b210be9c1a329e376ae5f1788c8d33bd7f Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 25 Sep 2026 19:01:41 +0800 Subject: [PATCH 2/2] test(hmr): cover acceptExports with hmrPartialAccept off --- .../hmr-no-partial-accept.spec.ts | 85 +++++++++++++++++++ .../hmr/vite.config-no-partial-accept.js | 8 ++ playground/vitestGlobalSetup.ts | 1 + 3 files changed, 94 insertions(+) create mode 100644 playground/hmr/__tests__/no-partial-accept/hmr-no-partial-accept.spec.ts create mode 100644 playground/hmr/vite.config-no-partial-accept.js 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..5809022e6fb559 --- /dev/null +++ b/playground/hmr/__tests__/no-partial-accept/hmr-no-partial-accept.spec.ts @@ -0,0 +1,85 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { + editFile, + isBuild, + isBundledDev, + page, + untilBrowserLogAfter, + viteServer, + viteTestUrl, +} from '~utils' + +// Only the cases where `experimental.hmrPartialAccept: false` changes the +// result, or could change it by mistake. The rest is covered in `hmr.spec.ts`. +describe.runIf(!isBuild)('acceptExports with hmrPartialAccept off', () => { + const HOT_UPDATED = /hot updated/ + const CONNECTED = /connected/ + const hotUpdated = (file: string) => + isBundledDev + ? `[vite] hot updated: playground-temp/hmr__no-partial-accept/${file}` + : `[vite] hot updated: /${file}` + + const openPage = async (testDir: string) => { + if (isBundledDev) { + const bundledDev = viteServer.environments.client.bundledDev as any + await bundledDev.devEngine.ensureLatestBuildOutput() + } + await page.goto(`${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', hotUpdated(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/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 = []