fix: file reloading mechanism and add profile watch ignores (#2112)

Co-authored-by: Aaron <aaronklinker1@gmail.com>
This commit is contained in:
ZerGo0
2026-06-30 22:12:01 +02:00
committed by GitHub
parent a63f2d2a33
commit 82825a5b35
7 changed files with 483 additions and 47 deletions
Generated
-1
View File
@@ -325,7 +325,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.5",
-1
View File
@@ -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.5",
+55 -1
View File
@@ -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';
@@ -76,6 +76,7 @@ export async function createViteBuilder(
ignored: [
`${wxtConfig.outBaseDir}/**`,
`${wxtConfig.wxtDir}/**`,
...getRunnerProfileWatchIgnores(wxtConfig),
...toArray(wxtConfig.watchOptions.ignored ?? []),
],
};
@@ -404,6 +405,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 getRollupAssetNames(assetInfo: RollupAssetNameInfo): string[] {
if (Array.isArray(assetInfo.names)) return assetInfo.names;
return assetInfo.name ? [assetInfo.name] : [];
@@ -0,0 +1,180 @@
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<typeof import('../building')>('../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);
expect(server.reloadContentScript).not.toBeCalled();
});
it('should ignore entrypoint directory changes when no new entrypoints are found', async () => {
const backgroundFile = '/root/src/entrypoints/background.ts';
const ignoredEntrypointDirFile = '/root/src/entrypoints/.DS_Store';
const backgroundEntrypoint = fakeBackgroundEntrypoint({
inputPath: backgroundFile,
skipped: false,
});
const currentOutput = fakeBuildOutput({
steps: [
{
entrypoints: backgroundEntrypoint,
chunks: [fakeOutputChunk({ moduleIds: [backgroundFile] })],
},
],
publicAssets: [],
});
const server = fakeDevServer({
currentOutput,
reloadContentScript: vi.fn(),
reloadExtension: vi.fn(),
});
vi.mocked(findEntrypoints).mockResolvedValue([backgroundEntrypoint]);
const reloadOnChange = createFileReloader(server);
const trigger = reloadOnChange('add', ignoredEntrypointDirFile);
await vi.advanceTimersByTimeAsync(500);
await trigger;
expect(rebuild).not.toBeCalled();
expect(server.reloadContentScript).not.toBeCalled();
expect(server.reloadExtension).not.toBeCalled();
});
});
@@ -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/<name>/index.html files', async () => {
const changedPath = '/root/page1/index.html';
const htmlPage1 = fakePopupEntrypoint({
@@ -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.
@@ -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<void> | 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,58 @@ export function createFileReloader(server: WxtDevServer) {
server.restartBrowser();
return;
}
const hasNewEntrypoints = newEntrypointGroups.length > 0;
if (changes.type === 'no-change' && !hasNewEntrypoints) 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);
if (hasNewEntrypoints || changes.type === 'extension-reload') {
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 if (changes.type === 'content-script-reload') {
reloadContentScripts(changes.changedSteps, server);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => entry.name);
wxt.logger.success(`Reloaded: ${getFilenameList(rebuiltNames)}`);
break;
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,12 +149,98 @@ 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<string, EntrypointGroup>();
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<string, EntrypointGroup>();
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('|');
}
/**
* From the server, tell the client to reload content scripts from the provided
* build step outputs.