Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions src/rule-packs/nuxt/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type NuxtDoctorEvidence = {
importDirs?: unknown[];
autoImportContext?: NuxtAutoImportContext;
autoImportEntries?: unknown[];
composableScan?: { dirs: string; entries: Promise<unknown[]> };
moduleSources?: NuxtModuleSource[];
extensions?: string[];
};
Expand All @@ -78,6 +79,7 @@ async function setupNuxtDoctor(options: NuxtDoctorModuleOptions, nuxt: any) {
importDirs: [] as unknown[],
autoImportContext: undefined as NuxtAutoImportContext | undefined,
autoImportEntries: undefined as unknown[] | undefined,
composableScan: undefined as NuxtDoctorEvidence["composableScan"],
moduleSources: undefined as NuxtModuleSource[] | undefined,
extensions: undefined as string[] | undefined,
};
Expand Down Expand Up @@ -176,6 +178,8 @@ async function setupNuxtDoctor(options: NuxtDoctorModuleOptions, nuxt: any) {

nuxt.hook?.("imports:extend", (imports: unknown) => {
evidence.autoImportEntries = Array.isArray(imports) ? imports : [];
// Nuxt calls this hook right after it rescans its import directories, so rescan with it.
evidence.composableScan = undefined;
});

nuxt.hook?.("pages:resolved", (pages: any[]) => {
Expand Down Expand Up @@ -291,17 +295,17 @@ export async function writeManifest(
if (evidence?.autoImportContext) {
scannedAutoImportEntries = [];
if (composableScanRoots.size && evidence.importDirs?.length) {
// Use Nuxt's scanner without the public dynamic-import list, which modules can mutate.
const { scanDirExports } = await importModule<{
scanDirExports: (
dirs: string[],
options: { fileFilter: (file: string) => boolean },
) => Promise<unknown[]>;
}>("unimport", { url: new URL(import.meta.resolve("nuxt")) });
const isIgnored = createIsIgnored(nuxt);
scannedAutoImportEntries = await scanDirExports(evidence.importDirs.map(String), {
fileFilter: (file) => !isIgnored(file),
});
const dirs = evidence.importDirs.map(String);
const key = JSON.stringify(dirs);
if (evidence.composableScan?.dirs !== key)
evidence.composableScan = { dirs: key, entries: scanImportDirs(nuxt, dirs) };
const scan = evidence.composableScan;
try {
scannedAutoImportEntries = await scan.entries;
} catch (error) {
if (evidence.composableScan === scan) evidence.composableScan = undefined;
throw error;
}
}
}
const manifest = {
Expand Down Expand Up @@ -423,6 +427,18 @@ const lastManifestWrite: { path: string; signature: string; generatedAt?: string
signature: "",
};

// Use Nuxt's scanner without the public dynamic-import list, which modules can mutate.
async function scanImportDirs(nuxt: any, dirs: string[]): Promise<unknown[]> {
const { scanDirExports } = await importModule<{
scanDirExports: (
dirs: string[],
options: { fileFilter: (file: string) => boolean },
) => Promise<unknown[]>;
}>("unimport", { url: new URL(import.meta.resolve("nuxt")) });
const isIgnored = createIsIgnored(nuxt);
return scanDirExports(dirs, { fileFilter: (file) => !isIgnored(file) });
}

function nuxtConfigModifiedAt(root: string): number | undefined {
for (const extension of ["ts", "mts", "js", "mjs", "cjs", "cts"]) {
const file = join(root, `nuxt.config.${extension}`);
Expand Down
110 changes: 110 additions & 0 deletions tests/rule-packs/nuxt/composable-scan-reuse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { createRequire, syncBuiltinESMExports } from "node:module";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { dirname, join } from "pathe";
import { loadNuxt } from "nuxt/kit";
import { expect, test } from "vite-plus/test";
import nuxtDoctorModule, { writeManifest } from "../../../src/rule-packs/nuxt/module.ts";

type Fixture = {
root: string;
nuxt: Awaited<ReturnType<typeof loadNuxt>>;
reads: () => number;
failNextRead: () => void;
};

async function withNuxtFixture(run: (fixture: Fixture) => Promise<void>) {
const root = mkdtempSync(join(tmpdir(), "doctor-composable-reuse-"));
const composable = join(root, "layer/app/composables/useLayer.ts");
const fsPromises = createRequire(import.meta.url)("node:fs/promises");
const readFile = fsPromises.readFile;
let reads = 0;
let failNext = false;
fsPromises.readFile = function (this: unknown, path: unknown, ...rest: unknown[]) {
if (String(path) === composable) {
reads++;
if (failNext) {
failNext = false;
return Promise.reject(Object.assign(new Error("EIO: i/o error"), { code: "EIO" }));
}
}
return Reflect.apply(readFile, this, [path, ...rest]);
};
syncBuiltinESMExports();
let nuxt: Fixture["nuxt"] | undefined;
const cwd = process.cwd();
try {
symlinkSync(
fileURLToPath(new URL("../../../node_modules", import.meta.url)),
join(root, "node_modules"),
"junction",
);
for (const [file, source] of Object.entries({
"package.json": JSON.stringify({ type: "module" }),
"nuxt.config.ts": "export default { extends: ['./layer'] }",
"app/composables/useApp.ts": "export function useApp() {}",
"layer/nuxt.config.ts": "export default {}",
"layer/app/composables/useLayer.ts": "export function useLayer() {}",
})) {
mkdirSync(dirname(join(root, file)), { recursive: true });
writeFileSync(join(root, file), source);
}
process.chdir(root);
nuxt = await loadNuxt({
cwd: root,
overrides: { modules: [nuxtDoctorModule], telemetry: false },
});
await run({
root,
nuxt,
reads: () => reads,
failNextRead: () => {
failNext = true;
},
});
await nuxt.close();
nuxt = undefined;
} finally {
fsPromises.readFile = readFile;
syncBuiltinESMExports();
await nuxt?.close();
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
}

test("scans composables once per Nuxt import regeneration across manifest writes", async () => {
await withNuxtFixture(async ({ root, nuxt, reads }) => {
const readsAfterLoad = reads();
expect(readsAfterLoad).toBeGreaterThan(0);
await nuxt.callHook("builder:generateApp");
await nuxt.callHook("builder:generateApp");
expect(reads()).toBe(readsAfterLoad);

writeFileSync(join(root, "app/composables/useAdded.ts"), "export function useAdded() {}");
await nuxt.callHook("builder:watch", "add", join(root, "app/composables/useAdded.ts"));
const manifest = JSON.parse(readFileSync(join(root, ".nuxt/doctor.manifest.json"), "utf8"));
expect(manifest.scannedComposableFiles).toEqual([
"app/composables/useAdded.ts",
"app/composables/useApp.ts",
"layer/app/composables/useLayer.ts",
]);
});
}, 30000);

test("rescans composables on the next manifest write after a failed scan", async () => {
await withNuxtFixture(async ({ root, nuxt, failNextRead }) => {
const evidence = {
autoImportContext: { getImports: () => [] },
importDirs: [join(root, "app/composables"), join(root, "layer/app/composables")],
};
failNextRead();
await expect(writeManifest(nuxt, evidence)).rejects.toThrow("EIO");
const manifest = await writeManifest(nuxt, evidence);
expect(manifest.scannedComposableFiles).toEqual([
"app/composables/useApp.ts",
"layer/app/composables/useLayer.ts",
]);
});
}, 30000);
Loading