Skip to content
82 changes: 82 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,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFileReloader } from '../create-server';
import { detectDevChanges, findEntrypoints, rebuild } from '../utils/building';
import {
fakeBuildOutput,
fakeDevServer,
setFakeWxt,
} from '../utils/testing/fake-objects';

vi.mock('../utils/building', () => ({
detectDevChanges: vi.fn(),
findEntrypoints: vi.fn(),
internalBuild: 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 currentOutput = fakeBuildOutput({
steps: [],
publicAssets: [],
});
const server = fakeDevServer({
currentOutput,
reloadExtension: vi.fn(),
});

vi.mocked(detectDevChanges).mockImplementation((fileChanges, output) => {
if (fileChanges.includes(relevantFile)) {
return {
type: 'extension-reload',
rebuildGroups: [],
cachedOutput: output,
};
}
return { type: 'no-change' };
});
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]);

const seenFiles = vi
.mocked(detectDevChanges)
.mock.calls.flatMap(([fileChanges]) => fileChanges);

expect(seenFiles).toContain(relevantFile);
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
39 changes: 28 additions & 11 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 Down Expand Up @@ -202,20 +201,18 @@ 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;

await wxt.reloadConfig();

Expand Down Expand Up @@ -288,10 +285,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
Loading