diff --git a/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts b/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts index 4e2f051dc1bb91..91b3c969f3d8e6 100644 --- a/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts +++ b/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts @@ -1,6 +1,5 @@ import fs from 'node:fs' -import { fileURLToPath } from 'node:url' -import { dirname, posix, resolve } from 'node:path' +import { posix, resolve } from 'node:path' import EventEmitter from 'node:events' import { afterAll, @@ -11,16 +10,19 @@ import { test, vi, } from 'vitest' -import type { InlineConfig, Logger, ViteDevServer } from 'vite' +import type { InlineConfig, ViteDevServer } from 'vite' import { createServer, createServerModuleRunner } from 'vite' import type { ModuleRunner } from 'vite/module-runner' -import type { RollupError } from 'rollup' import { addFile, - page, + createInMemoryLogger, + editFile, + isBuild, promiseWithResolvers, readFile, + removeFile, slash, + testDir, untilUpdated, } from '~utils' @@ -31,24 +33,8 @@ let runner: ModuleRunner const logsEmitter = new EventEmitter() -const originalFiles = new Map() -const createdFiles = new Set() -const deletedFiles = new Map() afterAll(async () => { - await server.close() - - originalFiles.forEach((content, file) => { - fs.writeFileSync(file, content, 'utf-8') - }) - createdFiles.forEach((file) => { - if (fs.existsSync(file)) fs.unlinkSync(file) - }) - deletedFiles.forEach((file) => { - fs.writeFileSync(file, deletedFiles.get(file)!, 'utf-8') - }) - originalFiles.clear() - createdFiles.clear() - deletedFiles.clear() + await server?.close() }) const hmr = (key: string) => (globalThis.__HMR__[key] as string) || '' @@ -60,297 +46,30 @@ const updated = (file: string, via?: string) => { return `[vite] hot updated: ${file}` } -describe('hmr works correctly', () => { - beforeAll(async () => { - await setupModuleRunner('/hmr.ts') - }) - - test('should connect', async () => { - expect(clientLogs).toContain('[vite] connected.') - }) - - test('self accept', async () => { - const el = () => hmr('.app') - await untilConsoleLogAfter( - () => - editFile('hmr.ts', (code) => - code.replace('const foo = 1', 'const foo = 2'), - ), - [ - '>>> vite:beforeUpdate -- update', - 'foo was: 1', - '(self-accepting 1) foo is now: 2', - '(self-accepting 2) foo is now: 2', - updated('/hmr.ts'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), '2') - - await untilConsoleLogAfter( - () => - editFile('hmr.ts', (code) => - code.replace('const foo = 2', 'const foo = 3'), - ), - [ - '>>> vite:beforeUpdate -- update', - 'foo was: 2', - '(self-accepting 1) foo is now: 3', - '(self-accepting 2) foo is now: 3', - updated('/hmr.ts'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), '3') - }) - - test('accept dep', async () => { - const el = () => hmr('.dep') - await untilConsoleLogAfter( - () => - editFile('hmrDep.js', (code) => - code.replace('const foo = 1', 'const foo = 2'), - ), - [ - '>>> vite:beforeUpdate -- update', - '(dep) foo was: 1', - '(dep) foo from dispose: 1', - '(single dep) foo is now: 2', - '(single dep) nested foo is now: 1', - '(multi deps) foo is now: 2', - '(multi deps) nested foo is now: 1', - updated('/hmrDep.js', '/hmr.ts'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), '2') - - await untilConsoleLogAfter( - () => - editFile('hmrDep.js', (code) => - code.replace('const foo = 2', 'const foo = 3'), - ), - [ - '>>> vite:beforeUpdate -- update', - '(dep) foo was: 2', - '(dep) foo from dispose: 2', - '(single dep) foo is now: 3', - '(single dep) nested foo is now: 1', - '(multi deps) foo is now: 3', - '(multi deps) nested foo is now: 1', - updated('/hmrDep.js', '/hmr.ts'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), '3') - }) - - test('nested dep propagation', async () => { - const el = () => hmr('.nested') - await untilConsoleLogAfter( - () => - editFile('hmrNestedDep.js', (code) => - code.replace('const foo = 1', 'const foo = 2'), - ), - [ - '>>> vite:beforeUpdate -- update', - '(dep) foo was: 3', - '(dep) foo from dispose: 3', - '(single dep) foo is now: 3', - '(single dep) nested foo is now: 2', - '(multi deps) foo is now: 3', - '(multi deps) nested foo is now: 2', - updated('/hmrDep.js', '/hmr.ts'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), '2') - - await untilConsoleLogAfter( - () => - editFile('hmrNestedDep.js', (code) => - code.replace('const foo = 2', 'const foo = 3'), - ), - [ - '>>> vite:beforeUpdate -- update', - '(dep) foo was: 3', - '(dep) foo from dispose: 3', - '(single dep) foo is now: 3', - '(single dep) nested foo is now: 3', - '(multi deps) foo is now: 3', - '(multi deps) nested foo is now: 3', - updated('/hmrDep.js', '/hmr.ts'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), '3') - }) - - test('invalidate', async () => { - const el = () => hmr('.invalidation') - await untilConsoleLogAfter( - () => - editFile('invalidation/child.js', (code) => - code.replace('child', 'child updated'), - ), - [ - '>>> vite:beforeUpdate -- update', - `>>> vite:invalidate -- /invalidation/child.js`, - '[vite] invalidate /invalidation/child.js', - updated('/invalidation/child.js'), - '>>> vite:afterUpdate -- update', - '>>> vite:beforeUpdate -- update', - '(invalidation) parent is executing', - updated('/invalidation/parent.js'), - '>>> vite:afterUpdate -- update', - ], - true, - ) - await untilUpdated(() => el(), 'child updated') - }) - - test('soft invalidate', async () => { - const el = () => hmr('.soft-invalidation') - expect(el()).toBe( - 'soft-invalidation/index.js is transformed 1 times. child is bar', - ) - editFile('soft-invalidation/child.js', (code) => - code.replace('bar', 'updated'), - ) - await untilUpdated( - () => el(), - 'soft-invalidation/index.js is transformed 1 times. child is updated', - ) - }) - - test('plugin hmr handler + custom event', async () => { - const el = () => hmr('.custom') - editFile('customFile.js', (code) => code.replace('custom', 'edited')) - await untilUpdated(() => el(), 'edited') - }) - - test('plugin hmr remove custom events', async () => { - const el = () => hmr('.toRemove') - editFile('customFile.js', (code) => code.replace('custom', 'edited')) - await untilUpdated(() => el(), 'edited') - editFile('customFile.js', (code) => code.replace('edited', 'custom')) - await untilUpdated(() => el(), 'edited') - }) - - test('plugin client-server communication', async () => { - const el = () => hmr('.custom-communication') - await untilUpdated(() => el(), '3') - }) - - test('queries are correctly resolved', async () => { - const query1 = () => hmr('query1') - const query2 = () => hmr('query2') - - expect(query1()).toBe('query1') - expect(query2()).toBe('query2') - - editFile('queries/multi-query.js', (code) => code + '//comment') - await untilUpdated(() => query1(), '//commentquery1') - await untilUpdated(() => query2(), '//commentquery2') - }) - - // TODO - // test('full-reload encodeURI path', async () => { - // await page.goto( - // viteTestUrl + '/unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', - // ) - // const el = () => hmr('#app') - // expect(await el()).toBe('title') - // editFile('unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', (code) => - // code.replace('title', 'title2'), - // ) - // await page.waitForEvent('load') - // await untilUpdated(async () => el(), 'title2') - // }) - - // TODO: css is not supported in SSR (yet?) - // test('CSS update preserves query params', async () => { - // await page.goto(viteTestUrl) - - // editFile('global.css', (code) => code.replace('white', 'tomato')) - - // const elprev = () => hmr('.css-prev') - // const elpost = () => hmr('.css-post') - // await untilUpdated(() => elprev(), 'param=required') - // await untilUpdated(() => elpost(), 'param=required') - // const textprev = elprev() - // const textpost = elpost() - // expect(textprev).not.toBe(textpost) - // expect(textprev).not.toMatch('direct') - // expect(textpost).not.toMatch('direct') - // }) - - // test('it swaps out link tags', async () => { - // await page.goto(viteTestUrl) - - // editFile('global.css', (code) => code.replace('white', 'tomato')) - - // let el = () => hmr('.link-tag-added') - // await untilUpdated(() => el(), 'yes') - - // el = () => hmr('.link-tag-removed') - // await untilUpdated(() => el(), 'yes') - - // expect((await page.$$('link')).length).toBe(1) - // }) - - // #2255 - not applicable to SSR because invalidateModule expects the module - // to always be reloaded again - // test('importing reloaded', async () => { - // const outputEle = () => hmr('.importing-reloaded') - - // await untilUpdated(outputEle, ['a.js: a0', 'b.js: b0,a0'].join('
')) - - // editFile('importing-updated/a.js', (code) => code.replace("'a0'", "'a1'")) - // await untilUpdated( - // outputEle, - // ['a.js: a0', 'b.js: b0,a0', 'a.js: a1'].join('
'), - // ) - - // editFile('importing-updated/b.js', (code) => - // code.replace('`b0,${a}`', '`b1,${a}`'), - // ) - // // note that "a.js: a1" should not happen twice after "b.js: b0,a0'" - // await untilUpdated( - // outputEle, - // ['a.js: a0', 'b.js: b0,a0', 'a.js: a1', 'b.js: b1,a1'].join('
'), - // ) - // }) -}) - -describe('self accept with different entry point formats', () => { - test.each(['./unresolved.ts', './unresolved', '/unresolved'])( - 'accepts if entry point is relative to root', - async (entrypoint) => { - await setupModuleRunner(entrypoint, {}, '/unresolved.ts') +if (!isBuild) { + describe('hmr works correctly', () => { + beforeAll(async () => { + await setupModuleRunner('/hmr.ts') + }) - onTestFinished(() => { - const filepath = resolvePath('..', 'unresolved.ts') - fs.writeFileSync(filepath, originalFiles.get(filepath)!, 'utf-8') - }) + test('should connect', async () => { + expect(clientLogs).toContain('[vite] connected.') + }) + test('self accept', async () => { const el = () => hmr('.app') await untilConsoleLogAfter( () => - editFile('unresolved.ts', (code) => + editFile('hmr.ts', (code) => code.replace('const foo = 1', 'const foo = 2'), ), [ + '>>> vite:beforeUpdate -- update', 'foo was: 1', '(self-accepting 1) foo is now: 2', '(self-accepting 2) foo is now: 2', - updated('/unresolved.ts'), + updated('/hmr.ts'), + '>>> vite:afterUpdate -- update', ], true, ) @@ -358,528 +77,654 @@ describe('self accept with different entry point formats', () => { await untilConsoleLogAfter( () => - editFile('unresolved.ts', (code) => + editFile('hmr.ts', (code) => code.replace('const foo = 2', 'const foo = 3'), ), [ + '>>> vite:beforeUpdate -- update', 'foo was: 2', '(self-accepting 1) foo is now: 3', '(self-accepting 2) foo is now: 3', - updated('/unresolved.ts'), + updated('/hmr.ts'), + '>>> vite:afterUpdate -- update', ], true, ) await untilUpdated(() => el(), '3') - }, - ) -}) - -describe('acceptExports', () => { - const HOT_UPDATED = /hot updated/ - const CONNECTED = /connected/ - const PROGRAM_RELOAD = /program reload/ - - 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' + test('accept dep', async () => { + const el = () => hmr('.dep') + await untilConsoleLogAfter( + () => + editFile('hmrDep.js', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 1', + '(dep) foo from dispose: 1', + '(single dep) foo is now: 2', + '(single dep) nested foo is now: 1', + '(multi deps) foo is now: 2', + '(multi deps) nested foo is now: 1', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '2') - beforeAll(async () => { await untilConsoleLogAfter( - () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, />>>>>>/], - (logs) => { - expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) - expect(logs).toContain('>>>>>> A0 D0') - }, + () => + editFile('hmrDep.js', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 2', + '(dep) foo from dispose: 2', + '(single dep) foo is now: 3', + '(single dep) nested foo is now: 1', + '(multi deps) foo is now: 3', + '(multi deps) nested foo is now: 1', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, ) + await untilUpdated(() => el(), '3') }) - test('the callback is called with the new version the module', async () => { - const callbackFile = `${testDir}/callback.ts` - const callbackUrl = `/${callbackFile}` - + test('nested dep propagation', async () => { + const el = () => hmr('.nested') await untilConsoleLogAfter( - () => { - 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}`, - ]) - }, + () => + editFile('hmrNestedDep.js', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 3', + '(dep) foo from dispose: 3', + '(single dep) foo is now: 3', + '(single dep) nested foo is now: 2', + '(multi deps) foo is now: 3', + '(multi deps) nested foo is now: 2', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, ) + await untilUpdated(() => el(), '2') await untilConsoleLogAfter( - () => { - editFile(callbackFile, (code) => code.replace("x = 'Y'", "x = 'Z'")) - }, - HOT_UPDATED, - (logs) => { - expect(logs).toEqual([ - 'reloaded (2) >>> Z', - `[vite] hot updated: ${callbackUrl}`, - ]) - }, + () => + editFile('hmrNestedDep.js', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 3', + '(dep) foo from dispose: 3', + '(single dep) foo is now: 3', + '(single dep) nested foo is now: 3', + '(multi deps) foo is now: 3', + '(multi deps) nested foo is now: 3', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, ) + await untilUpdated(() => el(), '3') }) - test('stops HMR bubble on dependency change', async () => { - const depFileName = 'dep.ts' - const depFile = `${testDir}/${depFileName}` - + test('invalidate', async () => { + const el = () => hmr('.invalidation') await untilConsoleLogAfter( - () => { - editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) - }, - HOT_UPDATED, - (logs) => { - expect(logs).toEqual([ - `<<<<<< A0 B0 D0 ; ${dep}`, - `[vite] hot updated: ${url}`, - ]) - }, + () => + editFile('invalidation/child.js', (code) => + code.replace('child', 'child updated'), + ), + [ + '>>> vite:beforeUpdate -- update', + `>>> vite:invalidate -- /invalidation/child.js`, + '[vite] invalidate /invalidation/child.js', + updated('/invalidation/child.js'), + '>>> vite:afterUpdate -- update', + '>>> vite:beforeUpdate -- update', + '(invalidation) parent is executing', + updated('/invalidation/parent.js'), + '>>> vite:afterUpdate -- update', + ], + true, ) + await untilUpdated(() => el(), 'child updated') }) - test('accepts itself and refreshes on change', async () => { - await untilConsoleLogAfter( - () => { - 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}`, - ]) - }, + test('soft invalidate', async () => { + const el = () => hmr('.soft-invalidation') + expect(el()).toBe( + 'soft-invalidation/index.js is transformed 1 times. child is bar', + ) + editFile('soft-invalidation/child.js', (code) => + code.replace('bar', 'updated'), + ) + await untilUpdated( + () => el(), + 'soft-invalidation/index.js is transformed 1 times. child is updated', ) }) - test('accepts itself and refreshes on 2nd change', async () => { - await untilConsoleLogAfter( - () => { - 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}`, - ]) - }, - ) + test('plugin hmr handler + custom event', async () => { + const el = () => hmr('.custom') + editFile('customFile.js', (code) => code.replace('custom', 'edited')) + await untilUpdated(() => el(), 'edited') }) - test('does not accept itself anymore after acceptedExports change', async () => { - await untilConsoleLogAfter( - async () => { - editFile(file, (code) => code.replace(/(\b[A-Z])2/g, '$13')) - }, - [PROGRAM_RELOAD, />>>>>>/], - (logs) => { - expect(logs).toContain(`<<<<<< A3 B3 D3 ; ${dep}`) - expect(logs).toContain('>>>>>> A3 D3') - }, - ) + test('plugin hmr remove custom events', async () => { + const el = () => hmr('.toRemove') + editFile('customFile.js', (code) => code.replace('custom', 'edited')) + await untilUpdated(() => el(), 'edited') + editFile('customFile.js', (code) => code.replace('edited', 'custom')) + await untilUpdated(() => el(), 'edited') }) - }) - describe('when some used exports are not accepted', () => { - const testDir = baseDir + '/main-non-accepted' + test('plugin client-server communication', async () => { + const el = () => hmr('.custom-communication') + await untilUpdated(() => el(), '3') + }) - const namedFileName = 'named.ts' - const namedFile = `${testDir}/${namedFileName}` - const defaultFileName = 'default.ts' - const defaultFile = `${testDir}/${defaultFileName}` - const depFileName = 'dep.ts' - const depFile = `${testDir}/${depFileName}` + test('queries are correctly resolved', async () => { + const query1 = () => hmr('query1') + const query2 = () => hmr('query2') - const a = 'A0' - let dep = 'dep0' + expect(query1()).toBe('query1') + expect(query2()).toBe('query2') - beforeAll(async () => { - await untilConsoleLogAfter( - () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, />>>>>>/], - (logs) => { - expect(logs).toContain(`<<< named: ${a} ; ${dep}`) - expect(logs).toContain(`<<< default: def0`) - expect(logs).toContain(`>>>>>> ${a} def0`) - }, - ) + editFile('queries/multi-query.js', (code) => code + '//comment') + await untilUpdated(() => query1(), '//commentquery1') + await untilUpdated(() => query2(), '//commentquery2') }) + }) - test('does not stop the HMR bubble on change to dep', async () => { - await untilConsoleLogAfter( - async () => { - editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) - }, - [PROGRAM_RELOAD, />>>>>>/], - (logs) => { - expect(logs).toContain(`<<< named: ${a} ; ${dep}`) - }, - ) - }) + describe('self accept with different entry point formats', () => { + test.each(['./unresolved.ts', './unresolved', '/unresolved'])( + 'accepts if entry point is relative to root', + async (entrypoint) => { + await setupModuleRunner(entrypoint, {}, '/unresolved.ts') + + const originalUnresolvedFile = readFile('unresolved.ts') + onTestFinished(() => { + const filepath = resolve(testDir, 'unresolved.ts') + fs.writeFileSync(filepath, originalUnresolvedFile, 'utf-8') + }) - describe('does not stop the HMR bubble on change to self', () => { - test('with named exports', async () => { + const el = () => hmr('.app') await untilConsoleLogAfter( - async () => { - editFile(namedFile, (code) => code.replace(a, 'A1')) - }, - [PROGRAM_RELOAD, />>>>>>/], + () => + editFile('unresolved.ts', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + 'foo was: 1', + '(self-accepting 1) foo is now: 2', + '(self-accepting 2) foo is now: 2', + updated('/unresolved.ts'), + ], + true, + ) + await untilUpdated(() => el(), '2') + + await untilConsoleLogAfter( + () => + editFile('unresolved.ts', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + 'foo was: 2', + '(self-accepting 1) foo is now: 3', + '(self-accepting 2) foo is now: 3', + updated('/unresolved.ts'), + ], + true, + ) + await untilUpdated(() => el(), '3') + }, + ) + }) + + describe('acceptExports', () => { + const HOT_UPDATED = /hot updated/ + const CONNECTED = /connected/ + const PROGRAM_RELOAD = /program reload/ + + 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 untilConsoleLogAfter( + () => setupModuleRunner(`/${testDir}/index`), + [CONNECTED, />>>>>>/], (logs) => { - expect(logs).toContain(`<<< named: A1 ; ${dep}`) + expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) + expect(logs).toContain('>>>>>> A0 D0') }, ) }) - test('with default export', async () => { + test('the callback is called with the new version the module', async () => { + const callbackFile = `${testDir}/callback.ts` + const callbackUrl = `/${callbackFile}` + await untilConsoleLogAfter( - async () => { - editFile(defaultFile, (code) => code.replace('def0', 'def1')) + () => { + editFile(callbackFile, (code) => + code + .replace("x = 'X'", "x = 'Y'") + .replace('reloaded >>>', 'reloaded (2) >>>'), + ) }, - [PROGRAM_RELOAD, />>>>>>/], + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'reloaded >>> Y', + `[vite] hot updated: ${callbackUrl}`, + ]) + }, + ) + + await untilConsoleLogAfter( + () => { + editFile(callbackFile, (code) => code.replace("x = 'Y'", "x = 'Z'")) + }, + HOT_UPDATED, (logs) => { - expect(logs).toContain(`<<< default: def1`) + expect(logs).toEqual([ + 'reloaded (2) >>> Z', + `[vite] hot updated: ${callbackUrl}`, + ]) }, ) }) - }) - describe("doesn't reload if files not in the the entrypoint importers chain is changed", async () => { - const testFile = 'non-tested/index.js' + test('stops HMR bubble on dependency change', async () => { + const depFileName = 'dep.ts' + const depFile = `${testDir}/${depFileName}` - beforeAll(async () => { - clientLogs.length = 0 - // so it's in the module graph - const ssrEnvironment = server.environments.ssr - await ssrEnvironment.transformRequest(testFile) - await ssrEnvironment.transformRequest('non-tested/dep.js') + await untilConsoleLogAfter( + () => { + editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A0 B0 D0 ; ${dep}`, + `[vite] hot updated: ${url}`, + ]) + }, + ) }) - test('does not full reload', async () => { - editFile( - testFile, - (code) => code + '\n\nexport const query5 = "query5"', + test('accepts itself and refreshes on change', async () => { + await untilConsoleLogAfter( + () => { + 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}`, + ]) + }, ) - const start = Date.now() - // for 2 seconds check that there is no log about the file being reloaded - while (Date.now() - start < 2000) { - if ( - clientLogs.some( - (log) => - log.match(PROGRAM_RELOAD) || - log.includes('non-tested/index.js'), - ) - ) { - throw new Error('File was reloaded') - } - await new Promise((r) => setTimeout(r, 100)) - } - }, 5_000) - - test('does not update', async () => { - editFile('non-tested/dep.js', (code) => code + '//comment') - const start = Date.now() - // for 2 seconds check that there is no log about the file being reloaded - while (Date.now() - start < 2000) { - if ( - clientLogs.some( - (log) => - log.match(PROGRAM_RELOAD) || log.includes('non-tested/dep.js'), + }) + + test('accepts itself and refreshes on 2nd change', async () => { + await untilConsoleLogAfter( + () => { + editFile(file, (code) => + code + .replace(/(\b[A-Z])1/g, '$12') + .replace( + "acceptExports(['a', 'default']", + "acceptExports(['b', 'default']", + ), ) - ) { - throw new Error('File was updated') - } - await new Promise((r) => setTimeout(r, 100)) - } - }, 5_000) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A2 B2 D2 ; ${dep}`, + `[vite] hot updated: ${url}`, + ]) + }, + ) + }) + + test('does not accept itself anymore after acceptedExports change', async () => { + await untilConsoleLogAfter( + async () => { + editFile(file, (code) => code.replace(/(\b[A-Z])2/g, '$13')) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<<<<< A3 B3 D3 ; ${dep}`) + expect(logs).toContain('>>>>>> A3 D3') + }, + ) + }) }) - }) - test('accepts itself when imported for side effects only (no bindings imported)', async () => { - const testDir = baseDir + '/side-effects' - const file = 'side-effects.ts' + describe('when some used exports are not accepted', () => { + const testDir = baseDir + '/main-non-accepted' - await untilConsoleLogAfter( - () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, />>>/], - (logs) => { - expect(logs).toContain('>>> side FX') - }, - ) + const namedFileName = 'named.ts' + const namedFile = `${testDir}/${namedFileName}` + const defaultFileName = 'default.ts' + const defaultFile = `${testDir}/${defaultFileName}` + const depFileName = 'dep.ts' + const depFile = `${testDir}/${depFileName}` - await untilConsoleLogAfter( - () => { - editFile(`${testDir}/${file}`, (code) => - code.replace('>>> side FX', '>>> side FX !!'), - ) - }, - HOT_UPDATED, - (logs) => { - expect(logs).toEqual(['>>> side FX !!', updated(`/${testDir}/${file}`)]) - }, - ) - }) + const a = 'A0' + let dep = 'dep0' - describe('acceptExports([])', () => { - const testDir = baseDir + '/unused-exports' + beforeAll(async () => { + await untilConsoleLogAfter( + () => setupModuleRunner(`/${testDir}/index`), + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: ${a} ; ${dep}`) + expect(logs).toContain(`<<< default: def0`) + expect(logs).toContain(`>>>>>> ${a} def0`) + }, + ) + }) - test('accepts itself if no exports are imported', async () => { - const fileName = 'unused.ts' - const file = `${testDir}/${fileName}` - const url = '/' + file + test('does not stop the HMR bubble on change to dep', async () => { + await untilConsoleLogAfter( + async () => { + editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: ${a} ; ${dep}`) + }, + ) + }) - await untilConsoleLogAfter( - () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, '-- unused --'], - (logs) => { - expect(logs).toContain('-- unused --') - }, - ) + describe('does not stop the HMR bubble on change to self', () => { + test('with named exports', async () => { + await untilConsoleLogAfter( + async () => { + editFile(namedFile, (code) => code.replace(a, 'A1')) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: A1 ; ${dep}`) + }, + ) + }) + + test('with default export', async () => { + await untilConsoleLogAfter( + async () => { + editFile(defaultFile, (code) => code.replace('def0', 'def1')) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< default: def1`) + }, + ) + }) + }) - await untilConsoleLogAfter( - () => { - editFile(file, (code) => code.replace('-- unused --', '-> unused <-')) - }, - HOT_UPDATED, - (logs) => { - expect(logs).toEqual(['-> unused <-', updated(url)]) - }, - ) + describe("doesn't reload if files not in the the entrypoint importers chain is changed", async () => { + const testFile = 'non-tested/index.js' + + beforeAll(async () => { + clientLogs.length = 0 + // so it's in the module graph + const ssrEnvironment = server.environments.ssr + await ssrEnvironment.transformRequest(testFile) + await ssrEnvironment.transformRequest('non-tested/dep.js') + }) + + test('does not full reload', async () => { + editFile( + testFile, + (code) => code + '\n\nexport const query5 = "query5"', + ) + const start = Date.now() + // for 2 seconds check that there is no log about the file being reloaded + while (Date.now() - start < 2000) { + if ( + clientLogs.some( + (log) => + log.match(PROGRAM_RELOAD) || + log.includes('non-tested/index.js'), + ) + ) { + throw new Error('File was reloaded') + } + await new Promise((r) => setTimeout(r, 100)) + } + }, 5_000) + + test('does not update', async () => { + editFile('non-tested/dep.js', (code) => code + '//comment') + const start = Date.now() + // for 2 seconds check that there is no log about the file being reloaded + while (Date.now() - start < 2000) { + if ( + clientLogs.some( + (log) => + log.match(PROGRAM_RELOAD) || + log.includes('non-tested/dep.js'), + ) + ) { + throw new Error('File was updated') + } + await new Promise((r) => setTimeout(r, 100)) + } + }, 5_000) + }) }) - test("doesn't accept itself if any of its exports is imported", async () => { - const fileName = 'used.ts' - const file = `${testDir}/${fileName}` + test('accepts itself when imported for side effects only (no bindings imported)', async () => { + const testDir = baseDir + '/side-effects' + const file = 'side-effects.ts' await untilConsoleLogAfter( () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, '-- used --', 'used:foo0'], + [CONNECTED, />>>/], (logs) => { - expect(logs).toContain('-- used --') - expect(logs).toContain('used:foo0') + expect(logs).toContain('>>> side FX') }, ) await untilConsoleLogAfter( - async () => { - editFile(file, (code) => - code.replace('foo0', 'foo1').replace('-- used --', '-> used <-'), + () => { + editFile(`${testDir}/${file}`, (code) => + code.replace('>>> side FX', '>>> side FX !!'), ) }, - [PROGRAM_RELOAD, /used:foo/], + HOT_UPDATED, (logs) => { - expect(logs).toContain('-> used <-') - expect(logs).toContain('used:foo1') + expect(logs).toEqual([ + '>>> side FX !!', + updated(`/${testDir}/${file}`), + ]) }, ) }) - }) - describe('indiscriminate imports: import *', () => { - const testStarExports = (testDirName: string) => { - const testDir = `${baseDir}/${testDirName}` + describe('acceptExports([])', () => { + const testDir = baseDir + '/unused-exports' - test('accepts itself if all its exports are accepted', async () => { - const fileName = 'deps-all-accepted.ts' + test('accepts itself if no exports are imported', async () => { + const fileName = 'unused.ts' const file = `${testDir}/${fileName}` const url = '/' + file await untilConsoleLogAfter( () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, '>>> ready <<<'], + [CONNECTED, '-- unused --'], (logs) => { - expect(logs).toContain('loaded:all:a0b0c0default0') - expect(logs).toContain('all >>>>>> a0, b0, c0') + expect(logs).toContain('-- unused --') }, ) await untilConsoleLogAfter( () => { - editFile(file, (code) => code.replace(/([abc])0/g, '$11')) - }, - HOT_UPDATED, - (logs) => { - expect(logs).toEqual(['all >>>>>> a1, b1, c1', updated(url)]) - }, - ) - - await untilConsoleLogAfter( - () => { - editFile(file, (code) => code.replace(/([abc])1/g, '$12')) + editFile(file, (code) => + code.replace('-- unused --', '-> unused <-'), + ) }, HOT_UPDATED, (logs) => { - expect(logs).toEqual(['all >>>>>> a2, b2, c2', updated(url)]) + expect(logs).toEqual(['-> unused <-', updated(url)]) }, ) }) - test("doesn't accept itself if one export is not accepted", async () => { - const fileName = 'deps-some-accepted.ts' + test("doesn't accept itself if any of its exports is imported", async () => { + const fileName = 'used.ts' const file = `${testDir}/${fileName}` await untilConsoleLogAfter( () => setupModuleRunner(`/${testDir}/index`), - [CONNECTED, '>>> ready <<<'], + [CONNECTED, '-- used --', 'used:foo0'], (logs) => { - expect(logs).toContain('loaded:some:a0b0c0default0') - expect(logs).toContain('some >>>>>> a0, b0, c0') + expect(logs).toContain('-- used --') + expect(logs).toContain('used:foo0') }, ) await untilConsoleLogAfter( async () => { - editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + editFile(file, (code) => + code.replace('foo0', 'foo1').replace('-- used --', '-> used <-'), + ) }, - [PROGRAM_RELOAD, '>>> ready <<<'], + [PROGRAM_RELOAD, /used:foo/], (logs) => { - expect(logs).toContain('loaded:some:a1b1c1default0') - expect(logs).toContain('some >>>>>> a1, b1, c1') + expect(logs).toContain('-> used <-') + expect(logs).toContain('used:foo1') }, ) }) - } + }) - describe('import * from ...', () => testStarExports('star-imports')) + describe('indiscriminate imports: import *', () => { + const testStarExports = (testDirName: string) => { + const testDir = `${baseDir}/${testDirName}` + + test('accepts itself if all its exports are accepted', async () => { + const fileName = 'deps-all-accepted.ts' + const file = `${testDir}/${fileName}` + const url = '/' + file + + await untilConsoleLogAfter( + () => setupModuleRunner(`/${testDir}/index`), + [CONNECTED, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:all:a0b0c0default0') + expect(logs).toContain('all >>>>>> a0, b0, c0') + }, + ) - describe('dynamic import(...)', () => testStarExports('dynamic-imports')) - }) -}) + await untilConsoleLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['all >>>>>> a1, b1, c1', updated(url)]) + }, + ) -test('handle virtual module updates', async () => { - await setupModuleRunner('/hmr.ts') - const el = () => hmr('.virtual') - expect(el()).toBe('[success]0') - editFile('importedVirtual.js', (code) => code.replace('[success]', '[wow]')) - await untilUpdated(el, '[wow]') -}) + await untilConsoleLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])1/g, '$12')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['all >>>>>> a2, b2, c2', updated(url)]) + }, + ) + }) + + test("doesn't accept itself if one export is not accepted", async () => { + const fileName = 'deps-some-accepted.ts' + const file = `${testDir}/${fileName}` + + await untilConsoleLogAfter( + () => setupModuleRunner(`/${testDir}/index`), + [CONNECTED, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:some:a0b0c0default0') + expect(logs).toContain('some >>>>>> a0, b0, c0') + }, + ) -test('invalidate virtual module', async () => { - await setupModuleRunner('/hmr.ts') - const el = () => hmr('.virtual') - expect(el()).toBe('[wow]0') - globalThis.__HMR__['virtual:increment']() - await untilUpdated(el, '[wow]1') -}) + await untilConsoleLogAfter( + async () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + }, + [PROGRAM_RELOAD, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:some:a1b1c1default0') + expect(logs).toContain('some >>>>>> a1, b1, c1') + }, + ) + }) + } -test.todo('should hmr when file is deleted and restored', async () => { - await setupModuleRunner('/hmr.ts') - - const parentFile = 'file-delete-restore/parent.js' - const childFile = 'file-delete-restore/child.js' - - await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') - - editFile(childFile, (code) => - code.replace("value = 'child'", "value = 'child1'"), - ) - await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child1') - - // delete the file - editFile(parentFile, (code) => - code.replace( - "export { value as childValue } from './child'", - "export const childValue = 'not-child'", - ), - ) - const originalChildFileCode = readFile(childFile) - removeFile(childFile) - await untilUpdated(() => hmr('.file-delete-restore'), 'parent:not-child') - - // restore the file - createFile(childFile, originalChildFileCode) - editFile(parentFile, (code) => - code.replace( - "export const childValue = 'not-child'", - "export { value as childValue } from './child'", - ), - ) - await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') -}) + describe('import * from ...', () => testStarExports('star-imports')) -test.todo('delete file should not break hmr', async () => { - // await page.goto(viteTestUrl) - - await untilUpdated( - () => page.textContent('.intermediate-file-delete-display'), - 'count is 1', - ) - - // add state - await page.click('.intermediate-file-delete-increment') - await untilUpdated( - () => page.textContent('.intermediate-file-delete-display'), - 'count is 2', - ) - - // update import, hmr works - editFile('intermediate-file-delete/index.js', (code) => - code.replace("from './re-export.js'", "from './display.js'"), - ) - editFile('intermediate-file-delete/display.js', (code) => - code.replace('count is ${count}', 'count is ${count}!'), - ) - await untilUpdated( - () => page.textContent('.intermediate-file-delete-display'), - 'count is 2!', - ) - - // remove unused file, page reload because it's considered entry point now - removeFile('intermediate-file-delete/re-export.js') - await untilUpdated( - () => page.textContent('.intermediate-file-delete-display'), - 'count is 1!', - ) - - // re-add state - await page.click('.intermediate-file-delete-increment') - await untilUpdated( - () => page.textContent('.intermediate-file-delete-display'), - 'count is 2!', - ) - - // hmr works after file deletion - editFile('intermediate-file-delete/display.js', (code) => - code.replace('count is ${count}!', 'count is ${count}'), - ) - await untilUpdated( - () => page.textContent('.intermediate-file-delete-display'), - 'count is 2', - ) -}) + describe('dynamic import(...)', () => testStarExports('dynamic-imports')) + }) + }) + + test('handle virtual module updates', async () => { + await setupModuleRunner('/hmr.ts') + const el = () => hmr('.virtual') + expect(el()).toBe('[success]0') + editFile('importedVirtual.js', (code) => code.replace('[success]', '[wow]')) + await untilUpdated(el, '[wow]') + }) + + test('invalidate virtual module', async () => { + await setupModuleRunner('/hmr.ts') + const el = () => hmr('.virtual') + expect(el()).toBe('[wow]0') + globalThis.__HMR__['virtual:increment']() + await untilUpdated(el, '[wow]1') + }) -test.todo( - 'deleted file should trigger dispose and prune callbacks', - async () => { + test('should hmr when file is deleted and restored', async () => { await setupModuleRunner('/hmr.ts') const parentFile = 'file-delete-restore/parent.js' const childFile = 'file-delete-restore/child.js' + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') + + editFile(childFile, (code) => + code.replace("value = 'child'", "value = 'child1'"), + ) + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child1') + // delete the file editFile(parentFile, (code) => code.replace( @@ -889,12 +734,7 @@ test.todo( ) const originalChildFileCode = readFile(childFile) removeFile(childFile) - await untilUpdated( - () => page.textContent('.file-delete-restore'), - 'parent:not-child', - ) - expect(clientLogs).to.include('file-delete-restore/child.js is disposed') - expect(clientLogs).to.include('file-delete-restore/child.js is pruned') + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:not-child') // restore the file addFile(childFile, originalChildFileCode) @@ -904,96 +744,154 @@ test.todo( "export { value as childValue } from './child'", ), ) + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') + }) + + test('delete file should not break hmr', async () => { + await setupModuleRunner('/hmr.ts', undefined, undefined, { + '.intermediate-file-delete-increment': '1', + }) + await untilUpdated( - () => page.textContent('.file-delete-restore'), - 'parent:child', + () => hmr('.intermediate-file-delete-display'), + 'count is 1', ) - }, -) - -test('import.meta.hot?.accept', async () => { - await setupModuleRunner('/hmr.ts') - await untilConsoleLogAfter( - () => - editFile('optional-chaining/child.js', (code) => - code.replace('const foo = 1', 'const foo = 2'), - ), - '(optional-chaining) child update', - ) - await untilUpdated(() => hmr('.optional-chaining')?.toString(), '2') -}) -test('hmr works for self-accepted module within circular imported files', async () => { - await setupModuleRunner('/self-accept-within-circular/index') - const el = () => hmr('.self-accept-within-circular') - expect(el()).toBe('c') - editFile('self-accept-within-circular/c.js', (code) => - code.replace(`export const c = 'c'`, `export const c = 'cc'`), - ) - await untilUpdated(() => el(), 'cc') - await vi.waitFor(() => { - expect(serverLogs.length).greaterThanOrEqual(1) - // Should still keep hmr update, but it'll error on the browser-side and will refresh itself. - // Match on full log not possible because of color markers - expect(serverLogs.at(-1)!).toContain('hmr update') + // add state + globalThis.__HMR__['.delete-intermediate-file']() + await untilUpdated( + () => hmr('.intermediate-file-delete-display'), + 'count is 2', + ) + + // update import, hmr works + editFile('intermediate-file-delete/index.js', (code) => + code.replace("from './re-export.js'", "from './display.js'"), + ) + editFile('intermediate-file-delete/display.js', (code) => + code.replace('count is ${count}', 'count is ${count}!'), + ) + await untilUpdated( + () => hmr('.intermediate-file-delete-display'), + 'count is 2!', + ) + + // remove unused file + removeFile('intermediate-file-delete/re-export.js') + __HMR__['.intermediate-file-delete-increment'] = '1' // reset state + await untilUpdated( + () => hmr('.intermediate-file-delete-display'), + 'count is 1!', + ) + + // re-add state + globalThis.__HMR__['.delete-intermediate-file']() + await untilUpdated( + () => hmr('.intermediate-file-delete-display'), + 'count is 2!', + ) + + // hmr works after file deletion + editFile('intermediate-file-delete/display.js', (code) => + code.replace('count is ${count}!', 'count is ${count}'), + ) + await untilUpdated( + () => hmr('.intermediate-file-delete-display'), + 'count is 2', + ) }) -}) -test('hmr should not reload if no accepted within circular imported files', async () => { - await setupModuleRunner('/circular/index') - const el = () => hmr('.circular') - expect(el()).toBe( - // tests in the browser check that there is an error, but vite runtime just returns undefined in those cases - 'mod-a -> mod-b -> mod-c -> undefined (expected no error)', - ) - editFile('circular/mod-b.js', (code) => - code.replace(`mod-b ->`, `mod-b (edited) ->`), - ) - await untilUpdated( - () => el(), - 'mod-a -> mod-b (edited) -> mod-c -> undefined (expected no error)', - ) -}) + test('deleted file should trigger dispose and prune callbacks', async () => { + await setupModuleRunner('/hmr.ts') + + const parentFile = 'file-delete-restore/parent.js' + const childFile = 'file-delete-restore/child.js' + + // delete the file + editFile(parentFile, (code) => + code.replace( + "export { value as childValue } from './child'", + "export const childValue = 'not-child'", + ), + ) + const originalChildFileCode = readFile(childFile) + removeFile(childFile) + + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:not-child') + expect(clientLogs).to.include('file-delete-restore/child.js is disposed') + expect(clientLogs).to.include('file-delete-restore/child.js is pruned') -test('assets HMR', async () => { - await setupModuleRunner('/hmr.ts') - const el = () => hmr('#logo') - await untilConsoleLogAfter( - () => - editFile('logo.svg', (code) => - code.replace('height="30px"', 'height="40px"'), + // restore the file + addFile(childFile, originalChildFileCode) + editFile(parentFile, (code) => + code.replace( + "export const childValue = 'not-child'", + "export { value as childValue } from './child'", ), - /Logo updated/, - ) - await vi.waitUntil(() => el().includes('logo.svg?t=')) -}) + ) + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') + }) -export function createFile(file: string, content: string): void { - const filepath = resolvePath(import.meta.url, '..', file) - createdFiles.add(filepath) - fs.mkdirSync(dirname(filepath), { recursive: true }) - fs.writeFileSync(filepath, content, 'utf-8') -} + test('import.meta.hot?.accept', async () => { + await setupModuleRunner('/hmr.ts') + await untilConsoleLogAfter( + () => + editFile('optional-chaining/child.js', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + '(optional-chaining) child update', + ) + await untilUpdated(() => hmr('.optional-chaining')?.toString(), '2') + }) -export function removeFile(file: string): void { - const filepath = resolvePath('..', file) - deletedFiles.set(filepath, fs.readFileSync(filepath, 'utf-8')) - fs.unlinkSync(filepath) -} + test('hmr works for self-accepted module within circular imported files', async () => { + await setupModuleRunner('/self-accept-within-circular/index') + const el = () => hmr('.self-accept-within-circular') + expect(el()).toBe('c') + editFile('self-accept-within-circular/c.js', (code) => + code.replace(`export const c = 'c'`, `export const c = 'cc'`), + ) + await untilUpdated(() => el(), 'cc') + await vi.waitFor(() => { + expect(serverLogs.length).greaterThanOrEqual(1) + // Should still keep hmr update, but it'll error on the browser-side and will refresh itself. + // Match on full log not possible because of color markers + expect(serverLogs.at(-1)!).toContain('hmr update') + }) + }) -export function editFile( - file: string, - callback: (content: string) => string, -): void { - const filepath = resolvePath('..', file) - const content = fs.readFileSync(filepath, 'utf-8') - if (!originalFiles.has(filepath)) originalFiles.set(filepath, content) - fs.writeFileSync(filepath, callback(content), 'utf-8') -} + test('hmr should not reload if no accepted within circular imported files', async () => { + await setupModuleRunner('/circular/index') + const el = () => hmr('.circular') + expect(el()).toBe( + // tests in the browser check that there is an error, but vite runtime just returns undefined in those cases + 'mod-a -> mod-b -> mod-c -> undefined (expected no error)', + ) + editFile('circular/mod-b.js', (code) => + code.replace(`mod-b ->`, `mod-b (edited) ->`), + ) + await untilUpdated( + () => el(), + 'mod-a -> mod-b (edited) -> mod-c -> undefined (expected no error)', + ) + }) -export function resolvePath(...segments: string[]): string { - const filename = fileURLToPath(import.meta.url) - return resolve(dirname(filename), ...segments).replace(/\\/g, '/') + test('assets HMR', async () => { + await setupModuleRunner('/hmr.ts') + const el = () => hmr('#logo') + await untilConsoleLogAfter( + () => + editFile('logo.svg', (code) => + code.replace('height="30px"', 'height="40px"'), + ), + /Logo updated/, + ) + await vi.waitUntil(() => el().includes('logo.svg?t=')) + }) +} else { + test('this file only includes test for serve', () => { + expect(true).toBe(true) + }) } type UntilBrowserLogAfterCallback = (logs: string[]) => PromiseLike | void @@ -1119,42 +1017,11 @@ function waitForWatcher(server: ViteDevServer, watched: string) { }) } -function createInMemoryLogger(logs: string[]) { - const loggedErrors = new WeakSet() - const warnedMessages = new Set() - - const logger: Logger = { - hasWarned: false, - hasErrorLogged: (err) => loggedErrors.has(err), - clearScreen: () => {}, - info(msg) { - logs.push(msg) - }, - warn(msg) { - logs.push(msg) - logger.hasWarned = true - }, - warnOnce(msg) { - if (warnedMessages.has(msg)) return - logs.push(msg) - logger.hasWarned = true - warnedMessages.add(msg) - }, - error(msg, opts) { - logs.push(msg) - if (opts?.error) { - loggedErrors.add(opts.error) - } - }, - } - - return logger -} - async function setupModuleRunner( entrypoint: string, serverOptions: InlineConfig = {}, waitForFile: string = entrypoint, + initHmrState: Record = {}, ) { if (server) { await server.close() @@ -1163,12 +1030,11 @@ async function setupModuleRunner( runner.clearCache() } - globalThis.__HMR__ = {} as any + globalThis.__HMR__ = initHmrState as any - const root = resolvePath('..') server = await createServer({ - configFile: resolvePath('../vite.config.ts'), - root, + configFile: resolve(testDir, 'vite.config.ts'), + root: testDir, customLogger: createInMemoryLogger(serverLogs), server: { middlewareMode: true, diff --git a/playground/hmr-ssr/file-delete-restore/child.js b/playground/hmr-ssr/file-delete-restore/child.js index 704c7d8c7e98cc..ddf10cd5a7a170 100644 --- a/playground/hmr-ssr/file-delete-restore/child.js +++ b/playground/hmr-ssr/file-delete-restore/child.js @@ -8,4 +8,12 @@ if (import.meta.hot) { rerender({ child: newMod.value }) }) + + import.meta.hot.dispose(() => { + log('file-delete-restore/child.js is disposed') + }) + + import.meta.hot.prune(() => { + log('file-delete-restore/child.js is pruned') + }) } diff --git a/playground/test-utils.ts b/playground/test-utils.ts index 4666820f850c28..544bc31f8c0fa1 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -142,7 +142,9 @@ export function editFile( } export function addFile(filename: string, content: string): void { - fs.writeFileSync(path.resolve(testDir, filename), content) + const resolvedFilename = path.resolve(testDir, filename) + fs.mkdirSync(path.dirname(resolvedFilename), { recursive: true }) + fs.writeFileSync(resolvedFilename, content) } export function removeFile(filename: string): void { diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index b316bb6924bb18..4b38fbbc9cac34 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -82,6 +82,14 @@ export function setViteUrl(url: string): void { beforeAll(async (s) => { const suite = s as File + + testPath = suite.filepath! + testName = slash(testPath).match(/playground\/([\w-]+)\//)?.[1] + testDir = path.dirname(testPath) + if (testName) { + testDir = path.resolve(workspaceRoot, 'playground-temp', testName) + } + // skip browser setup for non-playground tests // TODO: ssr playground? if ( @@ -124,15 +132,9 @@ beforeAll(async (s) => { browserErrors.push(error) }) - testPath = suite.filepath! - testName = slash(testPath).match(/playground\/([\w-]+)\//)?.[1] - testDir = path.dirname(testPath) - // if this is a test placed under playground/xxx/__tests__ // start a vite server in that directory. if (testName) { - testDir = path.resolve(workspaceRoot, 'playground-temp', testName) - // when `root` dir is present, use it as vite's root const testCustomRoot = path.resolve(testDir, 'root') rootDir = fs.existsSync(testCustomRoot) ? testCustomRoot : testDir