diff --git a/.changeset/ts-skip-unrelated-rebuild.md b/.changeset/ts-skip-unrelated-rebuild.md new file mode 100644 index 0000000..a0fcd65 --- /dev/null +++ b/.changeset/ts-skip-unrelated-rebuild.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": patch +--- + +Report what the TypeScript check found last time on a watch rebuild that changed nothing the program reads or would pick up, such as a stylesheet, rather than parsing the config file and building the program again to learn that. diff --git a/README.md b/README.md index 73b8283..ce79784 100644 --- a/README.md +++ b/README.md @@ -873,7 +873,9 @@ While webpack watches, the program is kept and handed to the build after it, so a rebuild type checks what the change reaches rather than the project over again — about 30 ms rather than 300 over three hundred files, for the memory the program holds on to (some 50 MB there). A change to the config file, or to the -compiler options given here, starts a new one. +compiler options given here, starts a new one. A rebuild for a file the program +neither reads nor would pick up — a stylesheet, an image — reports what the last +run found without asking the program at all. Alongside the shared options you can pass any [compiler option](https://www.typescriptlang.org/tsconfig/) — they override what diff --git a/src/checks/typescript-worker.js b/src/checks/typescript-worker.js index 7ffc5bf..257a14e 100644 --- a/src/checks/typescript-worker.js +++ b/src/checks/typescript-worker.js @@ -69,6 +69,7 @@ async function lintFiles() { directories: found.directories, writes: found.writes, missing: [...held.missing], + seen: [...held.seen], }; } diff --git a/src/checks/typescript.js b/src/checks/typescript.js index b819cb5..7c65095 100644 --- a/src/checks/typescript.js +++ b/src/checks/typescript.js @@ -4,7 +4,7 @@ import { createRequire } from "node:module"; import { countThreads, createSolo } from "../threads.js"; -import { importFrom } from "../utils.js"; +import { importFrom, toPosixPath } from "../utils.js"; import { check, describe, getHeld, getSchemas } from "./typescript-program.js"; @@ -24,8 +24,48 @@ export { getTypeScriptOptions } from "./typescript-program.js"; * @typedef {{ file?: string, code: string, severity: string, text: string, formatted: string }} Described */ +/** + * What a run of the check found, and what it read to find it. + * @typedef {object} Run + * @property {EXPECTED_ANY[]} diagnostics what it found + * @property {EXPECTED_ANY} host the host that formats what it found, on this thread + * @property {string[]} read the files a watcher follows for it + * @property {string[]} directories the directories a watcher follows for it + * @property {string[]} writes what it writes + * @property {string[]} missing the paths an import resolved to nothing through + * @property {Set} reads every path it read or looked for, as posix paths + * @property {string[]} folders where a file the program does not hold yet would be found, as posix paths ending in a slash + */ + const nodeRequire = createRequire(import.meta.url); +// What a file the config file's `include` would pick up ends in. +const SCRIPT = /\.(?:[cm]?[jt]sx?|json)$/u; + +/** @type {WeakMap>} */ +const runsByCompiler = new WeakMap(); + +/** + * Whether a rebuild can have changed what the program says: a file it read or + * looked for changed or went, or a script appeared where its config file would + * pick it up. What webpack rebuilds for a stylesheet or an image is none of + * them. + * @param {Run} run what the last run read + * @param {string[]} changed what webpack saw change or go since + * @returns {boolean} whether the program has to be asked again + */ +function reaches(run, changed) { + return changed.some((path) => { + const file = toPosixPath(path); + + return ( + run.reads.has(file) || + (SCRIPT.test(file) && + run.folders.some((folder) => file.startsWith(folder))) + ); + }); +} + /** @type {WeakMap>} */ const workersByCompiler = new WeakMap(); @@ -142,32 +182,83 @@ async function create({ key, id: entry, options, compilation }) { const saying = (one) => one.formatted === undefined ? describe(typescript, one) : one; + const { compiler } = compilation; + /** @type {Map} */ + const runs = runsByCompiler.get(compiler) || new Map(); + + runsByCompiler.set(compiler, runs); + + /** + * @param {Omit} found what this run found and read + * @param {string[]} seen every path the program asked for + * @returns {EXPECTED_ANY[]} what it found + */ + const remember = (found, seen) => { + ({ host, read, directories, writes, missing } = found); + runs.set(id, { + ...found, + reads: new Set( + [...read, ...seen, ...missing].map((file) => toPosixPath(file)), + ), + folders: directories.map( + (directory) => `${toPosixPath(directory).replace(/\/$/u, "")}/`, + ), + }); + + return found.diagnostics; + }; + return { async lintFiles() { if (checked) return []; checked = true; + // A rebuild nothing the program reads took part in is answered with what + // the last run found, without parsing the config file, reading the + // state of every file and building the program again to learn that. + const last = runs.get(id); + const { modifiedFiles, removedFiles } = compiler; + + if ( + last && + modifiedFiles && + !reaches(last, [...modifiedFiles, ...(removedFiles || [])]) + ) { + ({ host, read, directories, writes, missing } = last); + + return last.diagnostics; + } + if (worker) { const found = await worker.lintFiles(); - read = found.files; - directories = found.directories; - writes = found.writes; - missing = found.missing; - - return found.diagnostics; + return remember( + { + diagnostics: found.diagnostics, + host: undefined, + read: found.files, + directories: found.directories, + writes: found.writes, + missing: found.missing, + }, + found.seen, + ); } const found = check(typescript, options, held); - host = found.host; - read = found.files; - directories = found.directories; - writes = found.writes; - missing = [...held.missing]; - - return found.diagnostics; + return remember( + { + diagnostics: found.diagnostics, + host: found.host, + read: found.files, + directories: found.directories, + writes: found.writes, + missing: [...held.missing], + }, + [...held.seen], + ); }, readFiles() { return read; @@ -226,7 +317,10 @@ async function create({ key, id: entry, options, compilation }) { // A build that has no next one keeps no program, and a worker left running // would hold the process open after webpack is done with it. async cleanup() { - if (!worker || compilation.compiler.watchMode) return; + // What a one-shot build found is of no use to a build after it. + if (!compiler.watchMode) runs.delete(id); + + if (!worker || compiler.watchMode) return; dropWorker(compilation.compiler, id); diff --git a/test/typescript/watch.test.js b/test/typescript/watch.test.js index e285aac..f616e49 100644 --- a/test/typescript/watch.test.js +++ b/test/typescript/watch.test.js @@ -17,6 +17,8 @@ const configFile = join(fixture, "tsconfig.json"); // Something webpack does build, so that a rebuild can be asked for without // touching the files under test. const trigger = join(fixture, "trigger.js"); +// What webpack builds and the program never reads, as a stylesheet is. +const note = join(fixture, "note.txt"); /** * @param {boolean} strict whether the project is checked strictly @@ -57,6 +59,7 @@ describe("watch", () => { rmSync(dependent, { force: true }); rmSync(configFile, { force: true }); rmSync(trigger, { force: true }); + rmSync(note, { force: true }); }); it("should rebuild when a file only the program holds changes", (t, done) => { @@ -133,6 +136,71 @@ describe("watch", () => { }); }); + it("should answer a rebuild nothing the program reads took part in from the last run", (t, done) => { + writeConfig(true); + writeFileSync(note, "one\n"); + writeFileSync(trigger, 'import "./note.txt";\n'); + writeFileSync(orphan, "export const wrong: string = 42;\n"); + + const compiler = pack( + "watch", + { threads: false, typescriptPath }, + { module: { rules: [{ test: /\.txt$/u, type: "asset/source" }] } }, + ); + let step = 0; + let built = 0; + + require(typescriptPath)._reset(); + + watch = compiler.watch({}, (err, stats) => { + assert.strictEqual(err, null); + + const programs = require(typescriptPath)._programs.length; + + const changed = [...(stats.compilation.compiler.modifiedFiles || [])]; + + if (step === 0) { + // A rebuild webpack starts for what was written before it watched. + if (changed.length > 0) return; + + step = 1; + built = programs; + writeFileSync(note, "two\n"); + + return; + } + + if (step === 1) { + // Webpack names the folder that holds it too, which the program + // watches for a file appearing in it. + const others = changed.filter((file) => file !== fixture); + + if (others.length !== 1 || others[0] !== note) { + // A rebuild for what was written before the watch, late: the edit + // is made again from where that one left the program. + built = programs; + writeFileSync(note, `${Date.now()}\n`); + + return; + } + + assert.strictEqual(programs, built, "no program was built for it"); + // What the last run found is reported again. + assert.match(stats.compilation.errors[0].message, /orphan\.ts/u); + + step = 2; + writeFileSync(orphan, "export const wrong: number = 42;\n"); + + return; + } + + if (stats.hasErrors()) return; + + assert.ok(programs > built, "an edit the program reads builds one"); + done(); + }); + }); + it("should keep what a declaration says while another file changes, and hear it from one that starts to", (t, done) => { writeConfig(true, { declaration: true }); writeFileSync(trigger, "export const trigger = 1;\n"); diff --git a/types/checks/typescript.d.ts b/types/checks/typescript.d.ts index df7dd7f..535e211 100644 --- a/types/checks/typescript.d.ts +++ b/types/checks/typescript.d.ts @@ -30,6 +30,43 @@ export type Described = { text: string; formatted: string; }; +/** + * What a run of the check found, and what it read to find it. + */ +export type Run = { + /** + * what it found + */ + diagnostics: EXPECTED_ANY[]; + /** + * the host that formats what it found, on this thread + */ + host: EXPECTED_ANY; + /** + * the files a watcher follows for it + */ + read: string[]; + /** + * the directories a watcher follows for it + */ + directories: string[]; + /** + * what it writes + */ + writes: string[]; + /** + * the paths an import resolved to nothing through + */ + missing: string[]; + /** + * every path it read or looked for, as posix paths + */ + reads: Set; + /** + * where a file the program does not hold yet would be found, as posix paths ending in a slash + */ + folders: string[]; +}; /** * @param {CheckContext} context check context * @returns {Promise} typescript check