Skip to content
88 changes: 88 additions & 0 deletions packages/wxt/src/core/__tests__/create-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFileReloader } from '../create-server';
import { findEntrypoints, rebuild } from '../utils/building';
import {
fakeBackgroundEntrypoint,
fakeBuildOutput,
fakeDevServer,
fakeOutputChunk,
setFakeWxt,
} from '../utils/testing/fake-objects';

vi.mock('../utils/building', async () => {
const actual =
await vi.importActual<typeof import('../utils/building')>(
'../utils/building',
);
return {
...actual,
findEntrypoints: vi.fn(),
rebuild: vi.fn(),
};
});

describe('createFileReloader', () => {
beforeEach(() => {
vi.useFakeTimers();
setFakeWxt({
config: {
root: '/root',
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,
});
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: [],
});

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);
expect(rebuild).toBeCalledWith(
[],
[expect.objectContaining({ inputPath: relevantFile })],
expect.anything(),
);
expect(server.reloadExtension).toBeCalledTimes(1);
});
});
61 changes: 59 additions & 2 deletions packages/wxt/src/core/builders/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ViteNodeServer } from 'vite-node/server';
import { ViteNodeRunner } from 'vite-node/client';
import { installSourcemapsSupport } from 'vite-node/source-map';
import { createExtensionEnvironment } from '../../utils/environments';
import { dirname, extname, join, relative } from 'node:path';
import { dirname, extname, join, relative, resolve } from 'node:path';
import fs from 'fs-extra';
import { normalizePath } from '../../utils';

Expand Down Expand Up @@ -66,7 +66,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
Expand Down Expand Up @@ -371,6 +375,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<ReturnType<typeof vite.build>>,
): BuildStepOutput['chunks'] {
Expand Down
53 changes: 40 additions & 13 deletions packages/wxt/src/core/create-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { debounce } from 'perfect-debounce';
import chokidar from 'chokidar';
import {
BuildStepOutput,
Expand All @@ -15,6 +14,7 @@ import {
import {
internalBuild,
detectDevChanges,
getRelevantDevChangedFiles,
rebuild,
findEntrypoints,
} from './utils/building';
Expand Down Expand Up @@ -202,24 +202,31 @@ async function createServerInternal(): Promise<WxtDevServer> {
* Returns a function responsible for reloading different parts of the extension when a file
* changes.
*/
function createFileReloader(server: WxtDevServer) {
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 relevantFileChanges = getRelevantDevChangedFiles(
fileChanges,
server.currentOutput,
);
if (relevantFileChanges.length === 0) return;

await wxt.reloadConfig();

const changes = detectDevChanges(fileChanges, server.currentOutput);
const changes = detectDevChanges(
relevantFileChanges,
server.currentOutput,
);
if (changes.type === 'no-change') return;

if (changes.type === 'full-restart') {
Expand All @@ -236,7 +243,7 @@ function createFileReloader(server: WxtDevServer) {

// Log the entrypoints that were effected
wxt.logger.info(
`Changed: ${Array.from(new Set(fileChanges))
`Changed: ${relevantFileChanges
.map((file) => pc.dim(relative(wxt.config.root, file)))
.join(', ')}`,
);
Expand Down Expand Up @@ -288,10 +295,30 @@ 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;
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
32 changes: 25 additions & 7 deletions packages/wxt/src/core/utils/building/detect-dev-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,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) {
Expand Down Expand Up @@ -93,8 +98,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',
Expand Down Expand Up @@ -125,6 +130,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.
*/
Expand Down
Loading