diff --git a/bun.lock b/bun.lock index db7d3d711..7c29682cc 100644 --- a/bun.lock +++ b/bun.lock @@ -324,7 +324,6 @@ "nypm": "^0.6.5", "ohash": "^2.0.11", "open": "^11.0.0", - "perfect-debounce": "^2.1.0", "picomatch": "^4.0.3", "prompts": "^2.4.2", "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.4", diff --git a/packages/wxt/package.json b/packages/wxt/package.json index 6d364bbef..9c9f00c6a 100644 --- a/packages/wxt/package.json +++ b/packages/wxt/package.json @@ -48,7 +48,6 @@ "nypm": "^0.6.5", "ohash": "^2.0.11", "open": "^11.0.0", - "perfect-debounce": "^2.1.0", "picomatch": "^4.0.3", "prompts": "^2.4.2", "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.4", diff --git a/packages/wxt/src/core/builders/vite/index.ts b/packages/wxt/src/core/builders/vite/index.ts index 43da7ebec..11b0529bb 100644 --- a/packages/wxt/src/core/builders/vite/index.ts +++ b/packages/wxt/src/core/builders/vite/index.ts @@ -1,6 +1,6 @@ import { Hookable } from 'hookable'; import { mkdir, readdir, rename, rmdir, stat } from 'node:fs/promises'; -import { dirname, extname, join, relative } from 'node:path'; +import { dirname, extname, join, relative, resolve } from 'node:path'; import type * as vite from 'vite'; import { ViteNodeRunner } from 'vite-node/client'; import { ViteNodeServer } from 'vite-node/server'; @@ -67,7 +67,11 @@ export async function createViteBuilder( config.server ??= {}; config.server.watch = { - ignored: [`${wxtConfig.outBaseDir}/**`, `${wxtConfig.wxtDir}/**`], + ignored: [ + `${wxtConfig.outBaseDir}/**`, + `${wxtConfig.wxtDir}/**`, + ...getRunnerProfileWatchIgnores(wxtConfig), + ], }; // TODO: Remove once https://github.com/wxt-dev/wxt/pull/1411 is merged @@ -392,6 +396,59 @@ export async function createViteBuilder( }; } +export function getRunnerProfileWatchIgnores( + wxtConfig: ResolvedConfig, +): string[] { + const root = normalizePath(wxtConfig.root); + const chromiumArgProfiles = extractPathArgs( + wxtConfig.runnerConfig.config?.chromiumArgs, + '--user-data-dir', + ); + const firefoxArgProfiles = extractPathArgs( + wxtConfig.runnerConfig.config?.firefoxArgs, + '-profile', + ); + const profiles = [ + wxtConfig.runnerConfig.config?.chromiumProfile, + wxtConfig.runnerConfig.config?.firefoxProfile, + ...chromiumArgProfiles, + ...firefoxArgProfiles, + ].filter((profile): profile is string => typeof profile === 'string'); + + return Array.from( + new Set( + profiles + .map((profile) => normalizePath(resolve(wxtConfig.root, profile))) + // Avoid accidentally disabling all file watching. + .filter((profilePath) => profilePath !== root) + .map((profilePath) => `${profilePath}/**`), + ), + ); +} + +function extractPathArgs(args: string[] | undefined, flag: string): string[] { + if (!args?.length) return []; + + const paths: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg.startsWith(`${flag}=`)) { + const value = arg.slice(flag.length + 1).trim(); + if (value) paths.push(value); + continue; + } + + if (arg === flag) { + const nextValue = args[i + 1]?.trim(); + if (nextValue) paths.push(nextValue); + i += 1; + } + } + + return paths; +} + function getBuildOutputChunks( result: Awaited>, ): BuildStepOutput['chunks'] { diff --git a/packages/wxt/src/core/utils/__tests__/create-file-reloader.test.ts b/packages/wxt/src/core/utils/__tests__/create-file-reloader.test.ts new file mode 100644 index 000000000..7d772e0b6 --- /dev/null +++ b/packages/wxt/src/core/utils/__tests__/create-file-reloader.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFileReloader } from '../create-file-reloader'; +import { findEntrypoints, rebuild } from '../building'; +import { + fakeBackgroundEntrypoint, + fakeBuildOutput, + fakeDevServer, + fakeOutputChunk, + fakePopupEntrypoint, + setFakeWxt, +} from '../testing/fake-objects'; + +vi.mock('../building', async () => { + const actual = + await vi.importActual('../building'); + return { + ...actual, + findEntrypoints: vi.fn(), + rebuild: vi.fn(), + }; +}); + +describe('createFileReloader', () => { + beforeEach(() => { + vi.useFakeTimers(); + setFakeWxt({ + config: { + root: '/root', + entrypointsDir: '/root/src/entrypoints', + dev: { + server: { + watchDebounce: 100, + }, + }, + }, + }); + vi.mocked(findEntrypoints).mockResolvedValue([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should detect relevant file changes even when noisy file events happen first', async () => { + const relevantFile = '/root/src/entrypoints/background.ts'; + const noisyProfileFile = + '/root/private/.dev-profile/Default/Cache/Cache_Data/d573fa6484e43cf9_0'; + const backgroundEntrypoint = fakeBackgroundEntrypoint({ + inputPath: relevantFile, + skipped: false, + }); + const currentOutput = fakeBuildOutput({ + steps: [ + { + entrypoints: backgroundEntrypoint, + chunks: [fakeOutputChunk({ moduleIds: [relevantFile] })], + }, + ], + publicAssets: [], + }); + const server = fakeDevServer({ + currentOutput, + reloadExtension: vi.fn(), + }); + + vi.mocked(rebuild).mockResolvedValue({ + output: currentOutput, + manifest: currentOutput.manifest, + warnings: [], + }); + vi.mocked(findEntrypoints).mockResolvedValue([backgroundEntrypoint]); + + const reloadOnChange = createFileReloader(server); + + const fixedFirst = reloadOnChange('change', noisyProfileFile); + await vi.advanceTimersByTimeAsync(50); + const fixedSecond = reloadOnChange('change', relevantFile); + await vi.advanceTimersByTimeAsync(500); + await Promise.all([fixedFirst, fixedSecond]); + + expect(rebuild).toBeCalledTimes(1); + const [allEntrypoints, rebuiltGroups] = vi.mocked(rebuild).mock.calls[0]; + expect( + allEntrypoints.some((entry) => entry.inputPath === relevantFile), + ).toBe(true); + expect( + rebuiltGroups.flat().some((entry) => entry.inputPath === relevantFile), + ).toBe(true); + expect(server.reloadExtension).toBeCalledTimes(1); + }); + + it('should rebuild and reload extension when a new entrypoint is added', async () => { + const backgroundFile = '/root/src/entrypoints/background.ts'; + const newEntrypointFile = '/root/src/entrypoints/popup.html'; + const backgroundEntrypoint = fakeBackgroundEntrypoint({ + inputPath: backgroundFile, + skipped: false, + }); + const popupEntrypoint = fakePopupEntrypoint({ + inputPath: newEntrypointFile, + skipped: false, + }); + const currentOutput = fakeBuildOutput({ + steps: [ + { + entrypoints: backgroundEntrypoint, + chunks: [fakeOutputChunk({ moduleIds: [backgroundFile] })], + }, + ], + publicAssets: [], + }); + const server = fakeDevServer({ + currentOutput, + reloadExtension: vi.fn(), + }); + + vi.mocked(findEntrypoints).mockResolvedValue([ + backgroundEntrypoint, + popupEntrypoint, + ]); + vi.mocked(rebuild).mockResolvedValue({ + output: currentOutput, + manifest: currentOutput.manifest, + warnings: [], + }); + + const reloadOnChange = createFileReloader(server); + const trigger = reloadOnChange('add', newEntrypointFile); + await vi.advanceTimersByTimeAsync(500); + await trigger; + + expect(rebuild).toBeCalledTimes(1); + const [allEntrypoints, rebuiltGroups, cachedOutput] = + vi.mocked(rebuild).mock.calls[0]; + expect(allEntrypoints).toEqual([backgroundEntrypoint, popupEntrypoint]); + expect( + rebuiltGroups + .flat() + .some((entry) => entry.inputPath === newEntrypointFile), + ).toBe(true); + expect(cachedOutput).toEqual(currentOutput); + expect(server.reloadExtension).toBeCalledTimes(1); + }); +}); diff --git a/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts b/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts index efae832fb..ff7facff9 100644 --- a/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts +++ b/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts @@ -252,6 +252,60 @@ describe('Detect Dev Changes', () => { expect(actual).toEqual(expected); }); + it('should ignore unrelated changed files when checking html-only reloads', async () => { + const changedPath = '/root/page1.html'; + const unrelatedPath = + '/root/private/.dev-profile/Default/Cache/Cache_Data/1004_0'; + const htmlPage1 = fakePopupEntrypoint({ + inputPath: changedPath, + }); + const htmlPage2 = fakeOptionsEntrypoint({ + inputPath: '/root/page2.html', + }); + const htmlPage3 = fakeGenericEntrypoint({ + type: 'sandbox', + inputPath: '/root/page3.html', + }); + + const step1: BuildStepOutput = { + entrypoints: [htmlPage1, htmlPage2], + chunks: [ + fakeOutputAsset({ + fileName: 'page1.html', + }), + ], + }; + const step2: BuildStepOutput = { + entrypoints: [htmlPage3], + chunks: [ + fakeOutputAsset({ + fileName: 'page2.html', + }), + ], + }; + + const currentOutput: BuildOutput = { + manifest: fakeManifest(), + publicAssets: [], + steps: [step1, step2], + }; + const expected: DevModeChange = { + type: 'html-reload', + cachedOutput: { + ...currentOutput, + steps: [step2], + }, + rebuildGroups: [[htmlPage1, htmlPage2]], + }; + + const actual = detectDevChanges( + [unrelatedPath, changedPath], + currentOutput, + ); + + expect(actual).toEqual(expected); + }); + it('should detect changes to entrypoints//index.html files', async () => { const changedPath = '/root/page1/index.html'; const htmlPage1 = fakePopupEntrypoint({ diff --git a/packages/wxt/src/core/utils/building/detect-dev-changes.ts b/packages/wxt/src/core/utils/building/detect-dev-changes.ts index 194fffa4e..137057615 100644 --- a/packages/wxt/src/core/utils/building/detect-dev-changes.ts +++ b/packages/wxt/src/core/utils/building/detect-dev-changes.ts @@ -45,30 +45,35 @@ export function detectDevChanges( changedFiles: string[], currentOutput: BuildOutput, ): DevModeChange { - const isConfigChange = some( + const relevantChangedFiles = getRelevantDevChangedFiles( changedFiles, + currentOutput, + ); + + const isConfigChange = some( + relevantChangedFiles, (file) => file === wxt.config.userConfigMetadata.configFile, ); if (isConfigChange) return { type: 'full-restart' }; - const isWxtModuleChange = some(changedFiles, (file) => + const isWxtModuleChange = some(relevantChangedFiles, (file) => file.startsWith(wxt.config.modulesDir), ); if (isWxtModuleChange) return { type: 'full-restart' }; const isRunnerChange = some( - changedFiles, + relevantChangedFiles, (file) => file === wxt.config.runnerConfig.configFile, ); if (isRunnerChange) return { type: 'browser-restart' }; const changedSteps = new Set( - changedFiles.flatMap((changedFile) => + relevantChangedFiles.flatMap((changedFile) => findEffectedSteps(changedFile, currentOutput), ), ); if (changedSteps.size === 0) { - const hasPublicChange = some(changedFiles, (file) => + const hasPublicChange = some(relevantChangedFiles, (file) => file.startsWith(wxt.config.publicDir), ); if (hasPublicChange) { @@ -102,8 +107,8 @@ export function detectDevChanges( } const isOnlyHtmlChanges = - changedFiles.length > 0 && - every(changedFiles, (file) => file.endsWith('.html')); + relevantChangedFiles.length > 0 && + every(relevantChangedFiles, (file) => file.endsWith('.html')); if (isOnlyHtmlChanges) { return { type: 'html-reload', @@ -134,6 +139,19 @@ export function detectDevChanges( }; } +export function getRelevantDevChangedFiles( + changedFiles: string[], + currentOutput: BuildOutput, +): string[] { + return Array.from(new Set(changedFiles)).filter((changedFile) => { + if (changedFile === wxt.config.userConfigMetadata.configFile) return true; + if (changedFile.startsWith(wxt.config.modulesDir)) return true; + if (changedFile === wxt.config.runnerConfig.configFile) return true; + if (changedFile.startsWith(wxt.config.publicDir)) return true; + return findEffectedSteps(changedFile, currentOutput).length > 0; + }); +} + /** * For a single change, return all the step of the build output that were * effected by it. diff --git a/packages/wxt/src/core/utils/create-file-reloader.ts b/packages/wxt/src/core/utils/create-file-reloader.ts index e12ae09e3..ed79bd577 100644 --- a/packages/wxt/src/core/utils/create-file-reloader.ts +++ b/packages/wxt/src/core/utils/create-file-reloader.ts @@ -1,9 +1,19 @@ -import { debounce } from 'perfect-debounce'; import { Mutex } from 'async-mutex'; import { relative } from 'node:path'; -import { BuildStepOutput, EntrypointGroup, WxtDevServer } from '../../types'; +import { + BuildOutput, + BuildStepOutput, + EntrypointGroup, + WxtDevServer, +} from '../../types'; import { wxt } from '../wxt'; -import { detectDevChanges, findEntrypoints, rebuild } from './building'; +import { + detectDevChanges, + findEntrypoints, + getRelevantDevChangedFiles, + groupEntrypoints, + rebuild, +} from './building'; import { getEntrypointBundlePath, isHtmlEntrypoint } from './entrypoints'; import { getContentScriptCssFiles, getContentScriptsCssMap } from './manifest'; import { @@ -12,6 +22,8 @@ import { } from './content-scripts'; import { isBabelSyntaxError, logBabelSyntaxError } from './syntax-errors'; import { styleText } from 'node:util'; +import { filterTruthy, toArray } from './arrays'; +import { normalizePath } from './paths'; /** * Returns a function responsible for reloading different parts of the extension @@ -20,22 +32,44 @@ import { styleText } from 'node:util'; export function createFileReloader(server: WxtDevServer) { const fileChangedMutex = new Mutex(); const changeQueue: Array<[string, string]> = []; + let processLoop: Promise | undefined; - const cb = async (event: string, path: string) => { - changeQueue.push([event, path]); - + const processQueue = async () => { const reloading = fileChangedMutex.runExclusive(async () => { - if (server.currentOutput == null) return; - const fileChanges = changeQueue .splice(0, changeQueue.length) .map(([_, file]) => file); if (fileChanges.length === 0) return; + if (server.currentOutput == null) return; + + const normalizedFileChanges = fileChanges.map(normalizePath); + const relevantFileChanges = getRelevantDevChangedFiles( + fileChanges, + server.currentOutput, + ); + const normalizedEntrypointsDir = normalizePath(wxt.config.entrypointsDir); + const hasEntrypointDirChange = normalizedFileChanges.some( + (file) => + file === normalizedEntrypointsDir || + file.startsWith(`${normalizedEntrypointsDir}/`), + ); + if (relevantFileChanges.length === 0 && !hasEntrypointDirChange) return; await wxt.reloadConfig(); + const allEntrypoints = await findEntrypoints(); + const allEntrypointGroups = groupEntrypoints(allEntrypoints); + const newEntrypointGroups = getNewEntrypointGroups( + normalizedFileChanges, + allEntrypointGroups, + server.currentOutput, + ); + if (relevantFileChanges.length === 0 && newEntrypointGroups.length === 0) + return; - const changes = detectDevChanges(fileChanges, server.currentOutput); - if (changes.type === 'no-change') return; + const changes = detectDevChanges( + relevantFileChanges, + server.currentOutput, + ); if (changes.type === 'full-restart') { wxt.logger.info('Config changed, restarting server...'); @@ -48,46 +82,60 @@ export function createFileReloader(server: WxtDevServer) { server.restartBrowser(); return; } + if (changes.type === 'no-change' && newEntrypointGroups.length === 0) + return; + + const changedFilesToLog = Array.from( + new Set([ + ...relevantFileChanges, + ...newEntrypointGroups + .flatMap((group) => toArray(group)) + .map((entry) => entry.inputPath), + ]), + ); // Log the entrypoints that were effected wxt.logger.info( - `Changed: ${Array.from(new Set(fileChanges)) + `Changed: ${changedFilesToLog .map((file) => styleText('dim', relative(wxt.config.root, file))) .join(', ')}`, ); // Rebuild entrypoints on change - const allEntrypoints = await findEntrypoints(); + const rebuildGroups = + changes.type === 'no-change' + ? newEntrypointGroups + : mergeEntrypointGroups( + getLatestRebuildGroups( + changes.rebuildGroups, + allEntrypointGroups, + ), + newEntrypointGroups, + ); try { const { output: newOutput } = await rebuild( allEntrypoints, - // TODO: this excludes new entrypoints, so they're not built until the dev command is restarted - changes.rebuildGroups, - changes.cachedOutput, + rebuildGroups, + changes.type === 'no-change' + ? server.currentOutput + : changes.cachedOutput, ); server.currentOutput = newOutput; // Perform reloads - switch (changes.type) { - case 'extension-reload': - server.reloadExtension(); - wxt.logger.success(`Reloaded extension`); - break; - case 'html-reload': - const { reloadedNames } = reloadHtmlPages( - changes.rebuildGroups, - server, - ); - wxt.logger.success(`Reloaded: ${getFilenameList(reloadedNames)}`); - break; - case 'content-script-reload': - reloadContentScripts(changes.changedSteps, server); - - const rebuiltNames = changes.rebuildGroups - .flat() - .map((entry) => entry.name); - wxt.logger.success(`Reloaded: ${getFilenameList(rebuiltNames)}`); - break; + const needsFullExtensionReload = + newEntrypointGroups.length || changes.type === 'extension-reload'; + if (needsFullExtensionReload) { + server.reloadExtension(); + wxt.logger.success(`Reloaded extension`); + } else if (changes.type === 'html-reload') { + const { reloadedNames } = reloadHtmlPages(rebuildGroups, server); + wxt.logger.success(`Reloaded: ${getFilenameList(reloadedNames)}`); + } else { + reloadContentScripts(changes.changedSteps, server); + + const rebuiltNames = rebuildGroups.flat().map((entry) => entry.name); + wxt.logger.success(`Reloaded: ${getFilenameList(rebuiltNames)}`); } } catch { // Catch build errors instead of crashing. Don't log error either, builder should have already logged it @@ -103,10 +151,96 @@ export function createFileReloader(server: WxtDevServer) { }); }; - return debounce(cb, wxt.config.dev.server!.watchDebounce, { - leading: true, - trailing: false, + const waitForDebounceWindow = async () => { + await new Promise((resolve) => { + setTimeout(resolve, wxt.config.dev.server!.watchDebounce); + }); + }; + + const queueWorker = async () => { + while (true) { + await processQueue(); + + await waitForDebounceWindow(); + if (changeQueue.length === 0) break; + } + }; + + return async (event: string, path: string) => { + // Queue every event before debouncing so we never drop changes. + changeQueue.push([event, path]); + + processLoop ??= queueWorker().finally(() => { + processLoop = undefined; + }); + await processLoop; + }; +} + +function getNewEntrypointGroups( + normalizedFileChanges: string[], + allEntrypointGroups: EntrypointGroup[], + currentOutput: BuildOutput, +): EntrypointGroup[] { + const changedFiles = new Set(normalizedFileChanges); + const builtEntrypointPaths = new Set( + currentOutput.steps.flatMap((step) => + toArray(step.entrypoints).map((entry) => normalizePath(entry.inputPath)), + ), + ); + + return allEntrypointGroups.filter((group) => { + const groupEntrypoints = toArray(group); + const hasNewEntrypoint = groupEntrypoints.some( + (entry) => !builtEntrypointPaths.has(normalizePath(entry.inputPath)), + ); + const changedEntrypoint = groupEntrypoints.some((entry) => + changedFiles.has(normalizePath(entry.inputPath)), + ); + return hasNewEntrypoint && changedEntrypoint; + }); +} + +function getLatestRebuildGroups( + rebuildGroups: EntrypointGroup[], + allEntrypointGroups: EntrypointGroup[], +): EntrypointGroup[] { + const groupByEntrypointPath = new Map(); + + allEntrypointGroups.forEach((group) => { + toArray(group).forEach((entry) => { + groupByEntrypointPath.set(normalizePath(entry.inputPath), group); + }); + }); + + return mergeEntrypointGroups( + rebuildGroups.flatMap((group) => { + return filterTruthy( + toArray(group).map((entry) => + groupByEntrypointPath.get(normalizePath(entry.inputPath)), + ), + ); + }), + ); +} + +function mergeEntrypointGroups( + ...groups: EntrypointGroup[][] +): EntrypointGroup[] { + const deduped = new Map(); + + groups.flat().forEach((group) => { + deduped.set(getEntrypointGroupKey(group), group); }); + + return [...deduped.values()]; +} + +function getEntrypointGroupKey(group: EntrypointGroup): string { + return toArray(group) + .map((entry) => normalizePath(entry.inputPath)) + .sort() + .join('|'); } /**