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
5 changes: 5 additions & 0 deletions .changeset/ts-skip-unrelated-rebuild.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/checks/typescript-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ async function lintFiles() {
directories: found.directories,
writes: found.writes,
missing: [...held.missing],
seen: [...held.seen],
};
}

Expand Down
124 changes: 109 additions & 15 deletions src/checks/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string>} 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<EXPECTED_ANY, Map<string, Run>>} */
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<EXPECTED_ANY, Map<string, Solo>>} */
const workersByCompiler = new WeakMap();

Expand Down Expand Up @@ -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<string, Run>} */
const runs = runsByCompiler.get(compiler) || new Map();

runsByCompiler.set(compiler, runs);

/**
* @param {Omit<Run, "reads" | "folders">} 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;
Expand Down Expand Up @@ -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);

Expand Down
68 changes: 68 additions & 0 deletions test/typescript/watch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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");
Expand Down
37 changes: 37 additions & 0 deletions types/checks/typescript.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/**
* 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<CheckInstance>} typescript check
Expand Down
Loading