diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index 995c28ad9fad20..9bfb71d673183a 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -1963,6 +1963,35 @@ Repository: git+https://github.com/json5/json5.git --------------------------------------- +## jsonc-parser +License: MIT +By: Microsoft Corporation +Repository: https://github.com/microsoft/node-jsonc-parser + +> The MIT License (MIT) +> +> Copyright (c) Microsoft +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +--------------------------------------- + ## launch-editor License: MIT By: Evan You @@ -2162,6 +2191,34 @@ Repository: git://github.com/isaacs/minimatch.git --------------------------------------- +## mlly +License: MIT +Repository: unjs/mlly + +> MIT License +> +> Copyright (c) 2022 UnJS +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +--------------------------------------- + ## mrmime License: MIT By: Luke Edwards diff --git a/packages/vite/package.json b/packages/vite/package.json index ba62eeea7be1a7..774f561a4779c3 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -99,6 +99,7 @@ "launch-editor-middleware": "^2.4.0", "magic-string": "^0.26.2", "micromatch": "^4.0.5", + "mlly": "^0.5.1", "mrmime": "^1.0.1", "node-forge": "^1.3.1", "okie": "^1.0.1", diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 6762786218fc42..928ba2106fa59e 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -452,6 +452,12 @@ export function createHotContext(ownerPath: string): ViteHotContext { } }, + // export names (first arg) are irrelevant on the client side, they're + // extracted in the server for propagation + acceptExports(_: string | readonly string[], callback?: any) { + acceptDeps([ownerPath], callback && (([mod]) => callback(mod))) + }, + dispose(cb) { disposeMap.set(ownerPath, cb) }, diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index d6ba20fb610d08..61c8dd5bdfae4f 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -247,6 +247,14 @@ export interface ExperimentalOptions { * @default false */ importGlobRestoreExtension?: boolean + + /** + * Enables support of HMR partial accept via `import.meta.hot.acceptExports`. + * + * @experimental + * @default false + */ + hmrPartialAccept?: boolean } export interface LegacyOptions { diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index f868f290c36834..eaa8ee4b51b32f 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -258,9 +258,11 @@ export function cssPlugin(config: ResolvedConfig): Plugin { moduleGraph.updateModuleInfo( thisModule, depModules, + null, // The root CSS proxy module is self-accepting and should not // have an explicit accept list new Set(), + null, isSelfAccepting, ssr ) diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 99275492fa642e..c7b4d5c8bb3f1b 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -7,6 +7,7 @@ import type { ImportSpecifier } from 'es-module-lexer' import { init, parse as parseImports } from 'es-module-lexer' import { parse as parseJS } from 'acorn' import type { Node } from 'estree' +import { findStaticImports, parseStaticImport } from 'mlly' import { makeLegalIdentifier } from '@rollup/pluginutils' import type { ViteDevServer } from '..' import { @@ -20,7 +21,8 @@ import { import { debugHmr, handlePrunedModules, - lexAcceptedHmrDeps + lexAcceptedHmrDeps, + lexAcceptedHmrExports } from '../server/hmr' import { cleanUrl, @@ -84,6 +86,48 @@ function markExplicitImport(url: string) { return url } +async function extractImportedBindings( + id: string, + source: string, + importSpec: ImportSpecifier, + importedBindings: Map> +) { + let bindings = importedBindings.get(id) + if (!bindings) { + bindings = new Set() + importedBindings.set(id, bindings) + } + + const isDynamic = importSpec.d > -1 + const isMeta = importSpec.d === -2 + if (isDynamic || isMeta) { + // this basically means the module will be impacted by any change in its dep + bindings.add('*') + return + } + + const exp = source.slice(importSpec.ss, importSpec.se) + const [match0] = findStaticImports(exp) + if (!match0) { + return + } + const parsed = parseStaticImport(match0) + if (!parsed) { + return + } + if (parsed.namespacedImport) { + bindings.add('*') + } + if (parsed.defaultImport) { + bindings.add('default') + } + if (parsed.namedImports) { + for (const name of Object.keys(parsed.namedImports)) { + bindings.add(name) + } + } +} + /** * Server-only plugin that lexes, resolves, rewrites and analyzes url imports. * @@ -116,6 +160,7 @@ function markExplicitImport(url: string) { export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const { root, base } = config const clientPublicPath = path.posix.join(base, CLIENT_PUBLIC_PATH) + const enablePartialAccept = config.experimental?.hmrPartialAccept let server: ViteDevServer return { @@ -143,9 +188,10 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const start = performance.now() await init let imports: readonly ImportSpecifier[] = [] + let exports: readonly string[] = [] source = stripBomTag(source) try { - imports = parseImports(source)[0] + ;[imports, exports] = parseImports(source) } catch (e: any) { const isVue = importer.endsWith('.vue') const maybeJSX = !isVue && isJSRequest(importer) @@ -204,6 +250,11 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { start: number end: number }>() + let isPartiallySelfAccepting = false + const acceptedExports = new Set() + const importedBindings = enablePartialAccept + ? new Map>() + : null const toAbsoluteUrl = (url: string) => path.posix.resolve(path.posix.dirname(importerModule.url), url) @@ -344,7 +395,14 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { hasHMR = true if (source.slice(end + 4, end + 11) === '.accept') { // further analyze accepted modules - if ( + if (source.slice(end + 4, end + 18) === '.acceptExports') { + lexAcceptedHmrExports( + source, + source.indexOf('(', end + 18) + 1, + acceptedExports + ) + isPartiallySelfAccepting = true + } else if ( lexAcceptedHmrDeps( source, source.indexOf('(', end + 11) + 1, @@ -464,6 +522,16 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // make sure to normalize away base const urlWithoutBase = url.replace(base, '/') importedUrls.add(urlWithoutBase) + + if (enablePartialAccept && importedBindings) { + extractImportedBindings( + resolvedId, + source, + imports[index], + importedBindings + ) + } + if (!isDynamicImport) { // for pre-transforming staticImportedUrls.add({ url: urlWithoutBase, id: resolvedId }) @@ -531,6 +599,8 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { `${ isSelfAccepting ? `[self-accepts]` + : isPartiallySelfAccepting + ? `[accepts-exports]` : acceptedUrls.size ? `[accepts-deps]` : `[detected api usage]` @@ -585,10 +655,22 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { if (ssr && importerModule.isSelfAccepting) { isSelfAccepting = true } + // a partially accepted module that accepts all its exports + // behaves like a self-accepted module in practice + if ( + !isSelfAccepting && + isPartiallySelfAccepting && + acceptedExports.size >= exports.length && + exports.every((name) => acceptedExports.has(name)) + ) { + isSelfAccepting = true + } const prunedImports = await moduleGraph.updateModuleInfo( importerModule, importedUrls, + importedBindings, normalizedAcceptedUrls, + isPartiallySelfAccepting ? acceptedExports : null, isSelfAccepting, ssr ) diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index fe7a60cbf421f9..e12f1a2effd244 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -201,6 +201,18 @@ export async function handleFileAddUnlink( } } +function areAllImportsAccepted( + importedBindings: Set, + acceptedExports: Set +) { + for (const binding of importedBindings) { + if (!acceptedExports.has(binding)) { + return false + } + } + return true +} + function propagateUpdate( node: ModuleNode, boundaries: Set<{ @@ -233,18 +245,30 @@ function propagateUpdate( return false } - if (!node.importers.size) { - return true - } + // A partially accepted module with no importers is considered self accepting, + // because the deal is "there are parts of myself I can't self accept if they + // are used outside of me". + // Also, the imported module (this one) must be updated before the importers, + // so that they do get the fresh imported module when/if they are reloaded. + if (node.acceptedHmrExports) { + boundaries.add({ + boundary: node, + acceptedVia: node + }) + } else { + if (!node.importers.size) { + return true + } - // #3716, #3913 - // For a non-CSS file, if all of its importers are CSS files (registered via - // PostCSS plugins) it should be considered a dead end and force full reload. - if ( - !isCSSRequest(node.url) && - [...node.importers].every((i) => isCSSRequest(i.url)) - ) { - return true + // #3716, #3913 + // For a non-CSS file, if all of its importers are CSS files (registered via + // PostCSS plugins) it should be considered a dead end and force full reload. + if ( + !isCSSRequest(node.url) && + [...node.importers].every((i) => isCSSRequest(i.url)) + ) { + return true + } } for (const importer of node.importers) { @@ -257,6 +281,16 @@ function propagateUpdate( continue } + if (node.id && node.acceptedHmrExports && importer.importedBindings) { + const importedBindingsFromNode = importer.importedBindings.get(node.id) + if ( + importedBindingsFromNode && + areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports) + ) { + continue + } + } + if (currentChain.includes(importer)) { // circular deps is considered dead end return true @@ -423,6 +457,19 @@ export function lexAcceptedHmrDeps( return false } +export function lexAcceptedHmrExports( + code: string, + start: number, + exportNames: Set +): boolean { + const urls = new Set<{ url: string; start: number; end: number }>() + lexAcceptedHmrDeps(code, start, urls) + for (const { url } of urls) { + exportNames.add(url) + } + return urls.size > 0 +} + function error(pos: number) { const err = new Error( `import.meta.hot.accept() can only accept string literals or an ` + diff --git a/packages/vite/src/node/server/moduleGraph.ts b/packages/vite/src/node/server/moduleGraph.ts index 19a58107ba8aa6..e925ed85b06a22 100644 --- a/packages/vite/src/node/server/moduleGraph.ts +++ b/packages/vite/src/node/server/moduleGraph.ts @@ -29,6 +29,8 @@ export class ModuleNode { importers = new Set() importedModules = new Set() acceptedHmrDeps = new Set() + acceptedHmrExports: Set | null = null + importedBindings: Map> | null = null isSelfAccepting?: boolean transformResult: TransformResult | null = null ssrTransformResult: TransformResult | null = null @@ -136,7 +138,9 @@ export class ModuleGraph { async updateModuleInfo( mod: ModuleNode, importedModules: Set, + importedBindings: Map> | null, acceptedModules: Set, + acceptedExports: Set | null, isSelfAccepting: boolean, ssr?: boolean ): Promise | undefined> { @@ -172,6 +176,9 @@ export class ModuleGraph { : accepted deps.add(dep) } + // update accepted hmr exports + mod.acceptedHmrExports = acceptedExports + mod.importedBindings = importedBindings return noLongerImported } diff --git a/packages/vite/types/hot.d.ts b/packages/vite/types/hot.d.ts index 33299ec0f36354..985f3a47197c43 100644 --- a/packages/vite/types/hot.d.ts +++ b/packages/vite/types/hot.d.ts @@ -14,6 +14,13 @@ export interface ViteHotContext { deps: readonly string[], cb: (mods: Array) => void ): void + + acceptExports(exportNames: string | readonly string[]): void + acceptExports( + exportNames: string | readonly string[], + cb: (mod: ModuleNamespace | undefined) => void + ): void + dispose(cb: (data: any) => void): void decline(): void invalidate(): void diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 2ccdf96d84b970..c3a155098d7f1a 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -4,6 +4,7 @@ import { getBg, isBuild, page, + untilBrowserLogAfter, untilUpdated, viteTestUrl } from '~utils' @@ -142,9 +143,8 @@ if (!isBuild) { ) const el = await page.$('#app') expect(await el.textContent()).toBe('title') - await editFile( - 'unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', - (code) => code.replace('title', 'title2') + editFile('unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', (code) => + code.replace('title', 'title2') ) await page.waitForEvent('load') await untilUpdated( @@ -217,6 +217,368 @@ if (!isBuild) { expect(await btn.textContent()).toBe('Counter 1') }) + describe('acceptExports', () => { + const HOT_UPDATED = /hot updated/ + const CONNECTED = /connected/ + + const baseDir = 'accept-exports' + + 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}/`), + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) + expect(logs).toContain('>>>>>> A0 D0') + } + ) + }) + + it('the callback is called with the new version the module', async () => { + const callbackFile = `${testDir}/callback.ts` + const callbackUrl = '/' + callbackFile + + await untilBrowserLogAfter( + () => { + editFile(callbackFile, (code) => + code + .replace("x = 'X'", "x = 'Y'") + .replace('reloaded >>>', 'reloaded (2) >>>') + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'reloaded >>> Y', + `[vite] hot updated: ${callbackUrl}` + ]) + } + ) + + await untilBrowserLogAfter( + () => { + editFile(callbackFile, (code) => code.replace("x = 'Y'", "x = 'Z'")) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'reloaded (2) >>> Z', + `[vite] hot updated: ${callbackUrl}` + ]) + } + ) + }) + + it('stops HMR bubble on dependency change', async () => { + const depFileName = 'dep.ts' + const depFile = `${testDir}/${depFileName}` + + await untilBrowserLogAfter( + () => { + editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A0 B0 D0 ; ${dep}`, + `[vite] hot updated: ${url}` + ]) + } + ) + }) + + it('accepts itself and refreshes on change', async () => { + await untilBrowserLogAfter( + () => { + editFile(file, (code) => code.replace(/(\b[A-Z])0/g, '$11')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A1 B1 D1 ; ${dep}`, + `[vite] hot updated: ${url}` + ]) + } + ) + }) + + it('accepts itself and refreshes on 2nd change', async () => { + await untilBrowserLogAfter( + () => { + editFile(file, (code) => + code + .replace(/(\b[A-Z])1/g, '$12') + .replace( + "acceptExports(['a', 'default']", + "acceptExports(['b', 'default']" + ) + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A2 B2 D2 ; ${dep}`, + `[vite] hot updated: ${url}` + ]) + } + ) + }) + + it('does not accept itself anymore after acceptedExports change', async () => { + await untilBrowserLogAfter( + async () => { + editFile(file, (code) => code.replace(/(\b[A-Z])2/g, '$13')) + await page.waitForEvent('load') + }, + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<<<<< A3 B3 D3 ; ${dep}`) + expect(logs).toContain('>>>>>> A3 D3') + } + ) + }) + }) + + describe('when some used exports are not accepted', () => { + const testDir = baseDir + '/main-non-accepted' + + const namedFileName = 'named.ts' + const namedFile = `${testDir}/${namedFileName}` + const defaultFileName = 'default.ts' + const defaultFile = `${testDir}/${defaultFileName}` + const depFileName = 'dep.ts' + const depFile = `${testDir}/${depFileName}` + + const a = 'A0' + let dep = 'dep0' + + beforeAll(async () => { + await untilBrowserLogAfter( + () => page.goto(`${viteTestUrl}/${testDir}/`), + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: ${a} ; ${dep}`) + expect(logs).toContain(`<<< default: def0`) + expect(logs).toContain(`>>>>>> ${a} def0`) + } + ) + }) + + it('does not stop the HMR bubble on change to dep', async () => { + await untilBrowserLogAfter( + async () => { + editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) + await page.waitForEvent('load') + }, + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: ${a} ; ${dep}`) + } + ) + }) + + describe('does not stop the HMR bubble on change to self', () => { + it('with named exports', async () => { + await untilBrowserLogAfter( + async () => { + editFile(namedFile, (code) => code.replace(a, 'A1')) + await page.waitForEvent('load') + }, + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: A1 ; ${dep}`) + } + ) + }) + + it('with default export', async () => { + await untilBrowserLogAfter( + async () => { + editFile(defaultFile, (code) => code.replace('def0', 'def1')) + await page.waitForEvent('load') + }, + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< default: def1`) + } + ) + }) + }) + }) + + test('accepts itself when imported for side effects only (no bindings imported)', async () => { + const testDir = baseDir + '/side-effects' + const file = 'side-effects.ts' + + await untilBrowserLogAfter( + () => page.goto(`${viteTestUrl}/${testDir}/`), + [CONNECTED, />>>/], + (logs) => { + expect(logs).toContain('>>> side FX') + } + ) + + await untilBrowserLogAfter( + () => { + editFile(`${testDir}/${file}`, (code) => + code.replace('>>> side FX', '>>> side FX !!') + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + '>>> side FX !!', + `[vite] hot updated: /${testDir}/${file}` + ]) + } + ) + }) + + describe('acceptExports([])', () => { + const testDir = baseDir + '/unused-exports' + + 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}/`), + [CONNECTED, '-- unused --'], + (logs) => { + expect(logs).toContain('-- unused --') + } + ) + + await untilBrowserLogAfter( + () => { + editFile(file, (code) => + code.replace('-- unused --', '-> unused <-') + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['-> unused <-', `[vite] hot updated: ${url}`]) + } + ) + }) + + test("doesn't accept itself if any of its exports is imported", async () => { + const fileName = 'used.ts' + const file = `${testDir}/${fileName}` + + await untilBrowserLogAfter( + () => page.goto(`${viteTestUrl}/${testDir}/`), + [CONNECTED, '-- used --'], + (logs) => { + expect(logs).toContain('-- used --') + expect(logs).toContain('used:foo0') + } + ) + + await untilBrowserLogAfter( + async () => { + editFile(file, (code) => + code.replace('foo0', 'foo1').replace('-- used --', '-> used <-') + ) + await page.waitForEvent('load') + }, + [CONNECTED, /used:foo/], + (logs) => { + expect(logs).toContain('-> used <-') + expect(logs).toContain('used:foo1') + } + ) + }) + }) + + describe('indiscriminate imports: import *', () => { + const testStarExports = (testDirName: string) => { + const testDir = `${baseDir}/${testDirName}` + + 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}/`), + [CONNECTED, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:all:a0b0c0default0') + expect(logs).toContain('all >>>>>> a0, b0, c0') + } + ) + + await untilBrowserLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'all >>>>>> a1, b1, c1', + `[vite] hot updated: ${url}` + ]) + } + ) + + await untilBrowserLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])1/g, '$12')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'all >>>>>> a2, b2, c2', + `[vite] hot updated: ${url}` + ]) + } + ) + }) + + it("doesn't accept itself if one export is not accepted", async () => { + const fileName = 'deps-some-accepted.ts' + const file = `${testDir}/${fileName}` + + await untilBrowserLogAfter( + () => page.goto(`${viteTestUrl}/${testDir}/`), + '>>> ready <<<', + (logs) => { + expect(logs).toContain('loaded:some:a0b0c0default0') + expect(logs).toContain('some >>>>>> a0, b0, c0') + } + ) + + await untilBrowserLogAfter( + async () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + await page.waitForEvent('load') + }, + '>>> ready <<<', + (logs) => { + expect(logs).toContain('loaded:some:a1b1c1default0') + expect(logs).toContain('some >>>>>> a1, b1, c1') + } + ) + }) + } + + describe('import * from ...', () => testStarExports('star-imports')) + + describe('dynamic import(...)', () => testStarExports('dynamic-imports')) + }) + }) + test('css in html hmr', async () => { await page.goto(viteTestUrl) expect(await getBg('.import-image')).toMatch('icon') diff --git a/playground/hmr/accept-exports/dynamic-imports/deps-all-accepted.ts b/playground/hmr/accept-exports/dynamic-imports/deps-all-accepted.ts new file mode 100644 index 00000000000000..b837884b0615ee --- /dev/null +++ b/playground/hmr/accept-exports/dynamic-imports/deps-all-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +console.log(`all >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'c', 'default']) +} diff --git a/playground/hmr/accept-exports/dynamic-imports/deps-some-accepted.ts b/playground/hmr/accept-exports/dynamic-imports/deps-some-accepted.ts new file mode 100644 index 00000000000000..05fd5b9fb4e510 --- /dev/null +++ b/playground/hmr/accept-exports/dynamic-imports/deps-some-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +console.log(`some >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'default']) +} diff --git a/playground/hmr/accept-exports/dynamic-imports/dynamic-imports.ts b/playground/hmr/accept-exports/dynamic-imports/dynamic-imports.ts new file mode 100644 index 00000000000000..0314ff68b90134 --- /dev/null +++ b/playground/hmr/accept-exports/dynamic-imports/dynamic-imports.ts @@ -0,0 +1,9 @@ +Promise.all([import('./deps-all-accepted'), import('./deps-some-accepted')]) + .then(([all, some]) => { + console.log('loaded:all:' + all.a + all.b + all.c + all.default) + console.log('loaded:some:' + some.a + some.b + some.c + some.default) + console.log('>>> ready <<<') + }) + .catch((err) => { + console.error(err) + }) diff --git a/playground/hmr/accept-exports/dynamic-imports/index.html b/playground/hmr/accept-exports/dynamic-imports/index.html new file mode 100644 index 00000000000000..18fc78767ede6b --- /dev/null +++ b/playground/hmr/accept-exports/dynamic-imports/index.html @@ -0,0 +1,3 @@ + + +
diff --git a/playground/hmr/accept-exports/export-from/depA.ts b/playground/hmr/accept-exports/export-from/depA.ts new file mode 100644 index 00000000000000..e2eda670ed0097 --- /dev/null +++ b/playground/hmr/accept-exports/export-from/depA.ts @@ -0,0 +1 @@ +export const a = 'Ax' diff --git a/playground/hmr/accept-exports/export-from/export-from.ts b/playground/hmr/accept-exports/export-from/export-from.ts new file mode 100644 index 00000000000000..991dee89fe8fb8 --- /dev/null +++ b/playground/hmr/accept-exports/export-from/export-from.ts @@ -0,0 +1,8 @@ +import { a } from './hub' + +console.log(a) + +if (import.meta.hot) { + import.meta.hot.accept() +} else { +} diff --git a/playground/hmr/accept-exports/export-from/hub.ts b/playground/hmr/accept-exports/export-from/hub.ts new file mode 100644 index 00000000000000..5bd0dc05608909 --- /dev/null +++ b/playground/hmr/accept-exports/export-from/hub.ts @@ -0,0 +1 @@ +export * from './depA' diff --git a/playground/hmr/accept-exports/export-from/index.html b/playground/hmr/accept-exports/export-from/index.html new file mode 100644 index 00000000000000..0dde1345f085e2 --- /dev/null +++ b/playground/hmr/accept-exports/export-from/index.html @@ -0,0 +1,3 @@ + + +
diff --git a/playground/hmr/accept-exports/main-accepted/callback.ts b/playground/hmr/accept-exports/main-accepted/callback.ts new file mode 100644 index 00000000000000..3890c8d4a550f2 --- /dev/null +++ b/playground/hmr/accept-exports/main-accepted/callback.ts @@ -0,0 +1,7 @@ +export const x = 'X' + +if (import.meta.hot) { + import.meta.hot.acceptExports(['x'], (m) => { + console.log(`reloaded >>> ${m.x}`) + }) +} diff --git a/playground/hmr/accept-exports/main-accepted/dep.ts b/playground/hmr/accept-exports/main-accepted/dep.ts new file mode 100644 index 00000000000000..b9f67fd33a75f8 --- /dev/null +++ b/playground/hmr/accept-exports/main-accepted/dep.ts @@ -0,0 +1 @@ +export default 'dep0' diff --git a/playground/hmr/accept-exports/main-accepted/index.html b/playground/hmr/accept-exports/main-accepted/index.html new file mode 100644 index 00000000000000..8d576b0e135457 --- /dev/null +++ b/playground/hmr/accept-exports/main-accepted/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr/accept-exports/main-accepted/main-accepted.ts b/playground/hmr/accept-exports/main-accepted/main-accepted.ts new file mode 100644 index 00000000000000..f48bc7011ff652 --- /dev/null +++ b/playground/hmr/accept-exports/main-accepted/main-accepted.ts @@ -0,0 +1,7 @@ +import def, { a } from './target' +import { x } from './callback' + +// we don't want to polute other checks' logs... +if (0 > 1) console.log(x) + +console.log(`>>>>>> ${a} ${def}`) diff --git a/playground/hmr/accept-exports/main-accepted/target.ts b/playground/hmr/accept-exports/main-accepted/target.ts new file mode 100644 index 00000000000000..d34a7e261bbd13 --- /dev/null +++ b/playground/hmr/accept-exports/main-accepted/target.ts @@ -0,0 +1,16 @@ +import dep from './dep' + +export const a = 'A0' + +const bValue = 'B0' +export { bValue as b } + +const def = 'D0' + +export default def + +console.log(`<<<<<< ${a} ${bValue} ${def} ; ${dep}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'default']) +} diff --git a/playground/hmr/accept-exports/main-non-accepted/default.ts b/playground/hmr/accept-exports/main-non-accepted/default.ts new file mode 100644 index 00000000000000..7cb33058a0fa7a --- /dev/null +++ b/playground/hmr/accept-exports/main-non-accepted/default.ts @@ -0,0 +1,11 @@ +export const x = 'y' + +const def = 'def0' + +export default def + +console.log(`<<< default: ${def}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['x']) +} diff --git a/playground/hmr/accept-exports/main-non-accepted/dep.ts b/playground/hmr/accept-exports/main-non-accepted/dep.ts new file mode 100644 index 00000000000000..b9f67fd33a75f8 --- /dev/null +++ b/playground/hmr/accept-exports/main-non-accepted/dep.ts @@ -0,0 +1 @@ +export default 'dep0' diff --git a/playground/hmr/accept-exports/main-non-accepted/index.html b/playground/hmr/accept-exports/main-non-accepted/index.html new file mode 100644 index 00000000000000..8630d9a8739a3a --- /dev/null +++ b/playground/hmr/accept-exports/main-non-accepted/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr/accept-exports/main-non-accepted/main-non-accepted.ts b/playground/hmr/accept-exports/main-non-accepted/main-non-accepted.ts new file mode 100644 index 00000000000000..99b41b1fd2d376 --- /dev/null +++ b/playground/hmr/accept-exports/main-non-accepted/main-non-accepted.ts @@ -0,0 +1,4 @@ +import { a } from './named' +import def from './default' + +console.log(`>>>>>> ${a} ${def}`) diff --git a/playground/hmr/accept-exports/main-non-accepted/named.ts b/playground/hmr/accept-exports/main-non-accepted/named.ts new file mode 100644 index 00000000000000..847717b7982964 --- /dev/null +++ b/playground/hmr/accept-exports/main-non-accepted/named.ts @@ -0,0 +1,11 @@ +import dep from './dep' + +export const a = 'A0' + +export const b = 'B0' + +console.log(`<<< named: ${a} ; ${dep}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['b']) +} diff --git a/playground/hmr/accept-exports/reexports.bak/accept-named.ts b/playground/hmr/accept-exports/reexports.bak/accept-named.ts new file mode 100644 index 00000000000000..e63b352a5247fc --- /dev/null +++ b/playground/hmr/accept-exports/reexports.bak/accept-named.ts @@ -0,0 +1,10 @@ +export { a, b } from './source' + +if (import.meta.hot) { + // import.meta.hot.accept('./source', (m) => { + // console.log(`accept-named reexport:${m.a},${m.b}`) + // }) + import.meta.hot.acceptExports('a', (m) => { + console.log(`accept-named reexport:${m.a},${m.b}`) + }) +} diff --git a/playground/hmr/accept-exports/reexports.bak/index.html b/playground/hmr/accept-exports/reexports.bak/index.html new file mode 100644 index 00000000000000..241054bca8256f --- /dev/null +++ b/playground/hmr/accept-exports/reexports.bak/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr/accept-exports/reexports.bak/reexports.ts b/playground/hmr/accept-exports/reexports.bak/reexports.ts new file mode 100644 index 00000000000000..56fd0d32f3cbbc --- /dev/null +++ b/playground/hmr/accept-exports/reexports.bak/reexports.ts @@ -0,0 +1,5 @@ +import { a } from './accept-named' + +console.log('accept-named:' + a) + +console.log('>>> ready') diff --git a/playground/hmr/accept-exports/reexports.bak/source.ts b/playground/hmr/accept-exports/reexports.bak/source.ts new file mode 100644 index 00000000000000..7f736004a8e9fa --- /dev/null +++ b/playground/hmr/accept-exports/reexports.bak/source.ts @@ -0,0 +1,2 @@ +export const a = 'a0' +export const b = 'b0' diff --git a/playground/hmr/accept-exports/side-effects/index.html b/playground/hmr/accept-exports/side-effects/index.html new file mode 100644 index 00000000000000..7b94f06a5a6a8a --- /dev/null +++ b/playground/hmr/accept-exports/side-effects/index.html @@ -0,0 +1,3 @@ + + +
diff --git a/playground/hmr/accept-exports/side-effects/side-effects.ts b/playground/hmr/accept-exports/side-effects/side-effects.ts new file mode 100644 index 00000000000000..a9c4644fdd656b --- /dev/null +++ b/playground/hmr/accept-exports/side-effects/side-effects.ts @@ -0,0 +1,13 @@ +export const x = 'x' + +export const y = 'y' + +export default 'z' + +console.log('>>> side FX') + +document.querySelector('.app').textContent = 'hey' + +if (import.meta.hot) { + import.meta.hot.acceptExports(['default']) +} diff --git a/playground/hmr/accept-exports/star-imports/deps-all-accepted.ts b/playground/hmr/accept-exports/star-imports/deps-all-accepted.ts new file mode 100644 index 00000000000000..b837884b0615ee --- /dev/null +++ b/playground/hmr/accept-exports/star-imports/deps-all-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +console.log(`all >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'c', 'default']) +} diff --git a/playground/hmr/accept-exports/star-imports/deps-some-accepted.ts b/playground/hmr/accept-exports/star-imports/deps-some-accepted.ts new file mode 100644 index 00000000000000..05fd5b9fb4e510 --- /dev/null +++ b/playground/hmr/accept-exports/star-imports/deps-some-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +console.log(`some >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'default']) +} diff --git a/playground/hmr/accept-exports/star-imports/index.html b/playground/hmr/accept-exports/star-imports/index.html new file mode 100644 index 00000000000000..742ddd4dd2a3f8 --- /dev/null +++ b/playground/hmr/accept-exports/star-imports/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr/accept-exports/star-imports/star-imports.ts b/playground/hmr/accept-exports/star-imports/star-imports.ts new file mode 100644 index 00000000000000..e65d00aaf389be --- /dev/null +++ b/playground/hmr/accept-exports/star-imports/star-imports.ts @@ -0,0 +1,6 @@ +import * as all from './deps-all-accepted' +import * as some from './deps-some-accepted' + +console.log('loaded:all:' + all.a + all.b + all.c + all.default) +console.log('loaded:some:' + some.a + some.b + some.c + some.default) +console.log('>>> ready <<<') diff --git a/playground/hmr/accept-exports/unused-exports/index.html b/playground/hmr/accept-exports/unused-exports/index.html new file mode 100644 index 00000000000000..8998d3ce4581ee --- /dev/null +++ b/playground/hmr/accept-exports/unused-exports/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr/accept-exports/unused-exports/index.ts b/playground/hmr/accept-exports/unused-exports/index.ts new file mode 100644 index 00000000000000..e7dde82c9037f8 --- /dev/null +++ b/playground/hmr/accept-exports/unused-exports/index.ts @@ -0,0 +1,4 @@ +import './unused' +import { foo } from './used' + +console.log('used:' + foo) diff --git a/playground/hmr/accept-exports/unused-exports/unused.ts b/playground/hmr/accept-exports/unused-exports/unused.ts new file mode 100644 index 00000000000000..e7ce31166f4437 --- /dev/null +++ b/playground/hmr/accept-exports/unused-exports/unused.ts @@ -0,0 +1,11 @@ +export const x = 'x' + +export const y = 'y' + +export default 'z' + +console.log('-- unused --') + +if (import.meta.hot) { + import.meta.hot.acceptExports([]) +} diff --git a/playground/hmr/accept-exports/unused-exports/used.ts b/playground/hmr/accept-exports/unused-exports/used.ts new file mode 100644 index 00000000000000..a62fd5afc9cebb --- /dev/null +++ b/playground/hmr/accept-exports/unused-exports/used.ts @@ -0,0 +1,9 @@ +export const foo = 'foo0' + +export const bar = 'bar0' + +console.log('-- used --') + +if (import.meta.hot) { + import.meta.hot.acceptExports([]) +} diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index ef5d3cf36a2fcb..2ee03c28228ade 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vite' export default defineConfig({ + experimental: { + hmrPartialAccept: true + }, plugins: [ { name: 'mock-custom', diff --git a/playground/test-utils.ts b/playground/test-utils.ts index 7c128b49c5f2fd..d1c9da43d7e4c8 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -8,7 +8,7 @@ import fs from 'node:fs' import path from 'node:path' import colors from 'css-color-names' -import type { ElementHandle } from 'playwright-chromium' +import type { ElementHandle, ConsoleMessage } from 'playwright-chromium' import type { Manifest } from 'vite' import { normalizePath } from 'vite' import { fromComment } from 'convert-source-map' @@ -162,6 +162,88 @@ export async function untilUpdated( } } +export async function untilBrowserLogAfter( + operation: () => any, + target: string | RegExp | Array, + callback?: (logs: string[]) => PromiseLike | void +): Promise { + const promise = untilBrowserLog(target, false) + await operation() + const logs = await promise + if (callback) { + await callback(logs) + } + return logs +} + +async function untilBrowserLog( + target?: string | RegExp | Array, + expectOrder = true +): Promise { + let resolve: () => void + let reject: (reason: any) => void + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + + const logs = [] + + try { + const isMatch = (matcher: string | RegExp) => (text: string) => + typeof matcher === 'string' ? text === matcher : matcher.test(text) + + let processMsg: (text: string) => boolean + + if (!target) { + processMsg = () => true + } else if (Array.isArray(target)) { + if (expectOrder) { + const remainingTargets = [...target] + processMsg = (text: string) => { + const nextTarget = remainingTargets.shift() + expect(text).toMatch(nextTarget) + return remainingTargets.length === 0 + } + } else { + const remainingMatchers = target.map(isMatch) + processMsg = (text: string) => { + const nextIndex = remainingMatchers.findIndex((matcher) => + matcher(text) + ) + if (nextIndex >= 0) { + remainingMatchers.splice(nextIndex, 1) + } + return remainingMatchers.length === 0 + } + } + } else { + processMsg = isMatch(target) + } + + const handleMsg = (msg: ConsoleMessage) => { + try { + const text = msg.text() + logs.push(text) + const done = processMsg(text) + if (done) { + resolve() + } + } catch (err) { + reject(err) + } + } + + page.on('console', handleMsg) + } catch (err) { + reject(err) + } + + await promise + + return logs +} + export const extractSourcemap = (content: string): any => { const lines = content.trim().split('\n') return fromComment(lines[lines.length - 1]).toObject() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50bf6b16a92af4..30d3aeb8f19a5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,7 @@ importers: launch-editor-middleware: ^2.4.0 magic-string: ^0.26.2 micromatch: ^4.0.5 + mlly: ^0.5.1 mrmime: ^1.0.1 node-forge: ^1.3.1 okie: ^1.0.1 @@ -311,6 +312,7 @@ importers: launch-editor-middleware: 2.4.0 magic-string: 0.26.2 micromatch: 4.0.5 + mlly: 0.5.2 mrmime: 1.0.1 node-forge: 1.3.1 okie: 1.0.1