From ca503e274fb76dd87bd672200944d44fe29be093 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:57:05 +0000 Subject: [PATCH] perf: answer a TypeScript rebuild nothing the program reads took part in The check has no `resultPath`, so every compilation handed it the whole project, and it parsed the config file, read the state of every file and built the program again only to find the same answer after a stylesheet or an image changed. What the last run found is now kept, with every path it read or looked for (the worker hands back the paths its program asked for) and the folders its config file's `include` covers. A watch rebuild whose changed and removed files touch none of them, and add no script in those folders, reports it again. 1,500 files, TypeScript check, a text asset edited 5 times: rebuilds cost what webpack alone does (main: +50-100 ms, +100-250 ms CPU each). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- .changeset/ts-skip-unrelated-rebuild.md | 5 + README.md | 4 +- src/checks/typescript-worker.js | 1 + src/checks/typescript.js | 124 +++++++++++++++++++++--- test/typescript/watch.test.js | 68 +++++++++++++ types/checks/typescript.d.ts | 37 +++++++ 6 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 .changeset/ts-skip-unrelated-rebuild.md diff --git a/.changeset/ts-skip-unrelated-rebuild.md b/.changeset/ts-skip-unrelated-rebuild.md new file mode 100644 index 00000000..a0fcd65c --- /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 a59d4550..d5aba924 100644 --- a/README.md +++ b/README.md @@ -863,7 +863,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 7ffc5bfa..257a14eb 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 b819cb5a..7c65095c 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 88b002bc..28d6a08f 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 @@ -55,6 +57,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) => { @@ -131,6 +134,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 find a file the config file covers that appears", (t, done) => { writeConfig(true); writeFileSync(trigger, "export const trigger = 1;\n"); diff --git a/types/checks/typescript.d.ts b/types/checks/typescript.d.ts index df7dd7f6..535e211d 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