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/first-edit-face.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"diagnostics-webpack-plugin": patch
---

Leave the importers of a file be on its first edit in a watch too when what it shows them does not change, rather than only from its second edit on: what each file held is kept once the first build of the watch is over. In a project whose `tsconfig.json` sets `allowJs`, or a `rootDir` a file is outside of, what a TypeScript file shows its importers could not be read at all, so every edit linted every file importing it again; it is now read without the project's own compiler options. Over three hundred modules under typed linting, the first edit of a function's body took a rebuild from 2.0–2.2 s to 0.27–0.35 s.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,13 @@ A file changed while webpack watches reaches its importers only when what it
shows them changes: the declarations it emits and what it imports. Edit the
body of a function or a comment and only that file is linted again — told
apart by `ts.transpileDeclaration` (TypeScript 5.5 and later), which needs no
type checker and so answers only for a file whose exports say their own types,
and only from the second edit of a file on, the first being what it is told
from. Over three hundred modules in a chain, editing one halfway along took a
typed-linting rebuild from 1.1–1.4 s to 0.16–0.2 s.
type checker and so answers only for a TypeScript file whose exports say their
own types. What each file held as the watch started is kept once the first
build is over, so the first edit of a file is told apart too; a file written
after webpack started reading the file system for that build is not kept, and
its first edit reaches every file importing it. Over three hundred modules in a
chain, editing one halfway along took a typed-linting rebuild from 1.1–1.4 s to
0.16–0.2 s, and the first edit of it from 2.0–2.2 s to 0.27–0.35 s.

Left out, each check reads it from its tool's configuration:

Expand Down
95 changes: 87 additions & 8 deletions src/import-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { createHash } from "node:crypto";
import { statSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, extname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
Expand Down Expand Up @@ -42,7 +43,10 @@ import { toPosixPath } from "./utils.js";
* @property {Map<string, string>} configHashes each config file and what it extends, hashed, as the signatures were last read under
* @property {Map<EXPECTED_ANY, EXPECTED_ANY>} resolutions what each set of compiler options resolved this pass, so that every file in a folder does not look its imports up again
* @property {Map<string, string>} faces what each file that changed showed the files importing it, as the last change left it, hashed
* @property {Map<string, string>} texts what each file held when the watch started, kept until it first changes so that its first change is told from it
* @property {Promise<void> | undefined} keeping the reading of what the files held when the watch started, which a rebuild waits for
* @property {Set<string> | undefined} scripts what a file TypeScript reads ends in, read from it once
* @property {string[] | undefined} typed what a TypeScript file, as against a JavaScript one, ends in, read from it once
* @property {EXPECTED_ANY} fallback the options of a directory no config file covers
*/

Expand Down Expand Up @@ -102,7 +106,10 @@ function createGraph(ts) {
configHashes: new Map(),
resolutions: new Map(),
faces: new Map(),
texts: new Map(),
keeping: undefined,
scripts: undefined,
typed: undefined,
fallback: undefined,
};
}
Expand Down Expand Up @@ -272,11 +279,24 @@ function faceOf(graph, file, text, imports) {
return undefined;
}

if (!graph.typed) graph.typed = ts.getSupportedExtensions({}).flat();

// Only TypeScript says when it cannot write a type without a checker: the
// exports of a JavaScript file are written `any` without a word, and no
// change to them would show.
if (
!(/** @type {string[]} */ (graph.typed).some((one) => file.endsWith(one)))
) {
return undefined;
}

const emitted = ts.transpileDeclaration(text, {
fileName: file,
reportDiagnostics: true,
// Not the project's own options: the old text of a file and the new are
// read alike either way, and some of them — `allowJs`, a `rootDir` the
// file is outside of — make TypeScript refuse to read one at all.
compilerOptions: {
...optionsFor(graph, file),
declaration: true,
// What an importer's rules read and a project's own emit may drop: a
// `@deprecated` in a comment, an `@internal` export.
Expand Down Expand Up @@ -320,6 +340,8 @@ function affectedFiles(graph, files, changed) {
graph.configs.clear();
graph.options.clear();
graph.faces.clear();
// What the files that changed held is not what was linted last any more.
for (const file of touched) graph.texts.delete(file);

return undefined;
}
Expand All @@ -331,26 +353,49 @@ function affectedFiles(graph, files, changed) {
const quiet = new Set();

for (const file of touched) {
const before = graph.files.get(file);
// What a file held before its first change, kept from when the watch
// started, stands in for the face no change has left yet.
const kept = graph.texts.get(file);
const before =
graph.files.get(file) ||
(kept === undefined ? undefined : importsOf(graph, file, kept));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// What a file declared for every other one is gone once it changes, as
// much as what it declares now is new.
if (before && (before.global || before.unresolved)) return undefined;
if (before && (before.global || before.unresolved)) {
// Every file is linted again, against what each one that changed holds
// now: what any of them held or showed before says nothing any more.
for (const other of touched) {
graph.files.delete(other);
graph.faces.delete(other);
graph.texts.delete(other);
}

return undefined;
}

graph.files.delete(file);

const text = graph.ts.sys.readFile(file);

if (text === undefined) {
graph.faces.delete(file);
graph.texts.delete(file);
continue;
}

const imports = importsOf(graph, file, text);
const was = graph.faces.get(file);
// Only a file that changed is read for it: the first edit of a file is
// taken to reach everything importing it, and the next ones are told from
// what it showed after that one.
let was = graph.faces.get(file);

if (was === undefined && kept !== undefined && before) {
was = faceOf(graph, file, kept, before);
}

graph.texts.delete(file);

// Only a file that changed is read for it: one whose text was not kept
// when the watch started is taken to reach everything importing it on its
// first edit, and the next ones are told from what it showed after that.
const face = faceOf(graph, file, text, imports);

graph.files.set(file, imports);
Expand Down Expand Up @@ -420,6 +465,40 @@ function affectedFiles(graph, files, changed) {
return new Set([...reached].filter((file) => covered.has(file)));
}

/**
* Keeps what each file holds as a watch starts, so that its first change can
* be told from what it showed before it as well as every later one is. A file
* changed since webpack started reading the file system for the build is left
* out: what it holds now may be the change rather than what came before it,
* and its first change reaches everything importing it, as without this.
* @param {Graph} graph the graph
* @param {string[]} files every file the check covers
* @param {number} since when webpack started reading the file system for the build
* @returns {Promise<void>} once every file is kept or left out
*/
async function keepTexts(graph, files, since) {
for (const file of files) {
const known = toPosixPath(resolve(file));

if (graph.texts.has(known) || graph.faces.has(known)) continue;

try {
const { mtimeMs } = await stat(known);

if (mtimeMs >= since) continue;

const text = await readFile(known, "utf8");

// Written while it was read.
if ((await stat(known)).mtimeMs !== mtimeMs) continue;

graph.texts.set(known, text);
} catch {
// Gone, which its next change says.
}
}
}

/**
* The graph a compiler keeps for one check, read with the TypeScript the
* check's project installs — the one its own tooling resolves imports with.
Expand Down Expand Up @@ -709,4 +788,4 @@ function signatures(graph, files) {
return found;
}

export { affectedFiles, createGraph, getGraph, hashOf, signatures };
export { affectedFiles, createGraph, getGraph, hashOf, keepTexts, signatures };
54 changes: 53 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import createCheckRunner from "./check.js";
import {
affectedFiles as affectedFromGraph,
getGraph,
keepTexts,
} from "./import-graph.js";
import { getOptions, reportedAs, validateOptions } from "./options.js";
import {
Expand Down Expand Up @@ -136,13 +137,49 @@ async function affectedFiles(compiler, check, files) {
check.id,
);

return graph && affectedFromGraph(graph, files, changed);
if (!graph) return undefined;

// What the files held as the watch started, which a first change is told
// from, is read once the first build is over.
await graph.keeping;

return affectedFromGraph(graph, files, changed);
} catch {
// Not knowing is answered by linting every file, as before.
return undefined;
}
}

/**
* Keeps what the files a check reading across files covers hold as a watch
* starts, in the graph it is answered from: the first change of one is then
* told from what it held before, as every later one is, rather than taken to
* reach every file importing it.
* @param {Compiler} compiler compiler
* @param {ResolvedCheck} check the check to keep them for
* @param {string[]} files every file the check covers
* @param {number} since when webpack started reading the file system for the build
* @returns {Promise<void>} once they are kept
*/
async function keepWatchStart(compiler, check, files, since) {
if (check.adapter.affectedFiles || !readsAcrossFiles(compiler, check)) {
return;
}

// Neither throws: a graph that cannot be had, or a file that cannot be read,
// leaves the first change of a file to reach every file importing it.
const graph = await getGraph(
compiler,
String(check.options.context),
check.id,
);

if (!graph) return;

graph.keeping = keepTexts(graph, files, since);
await graph.keeping;
}

/**
* Walks the file system for the files a check wants, and says which of them
* webpack has just seen change.
Expand Down Expand Up @@ -461,6 +498,14 @@ class DiagnosticsWebpackPlugin {
generation += 1;

const mine = generation;
// The first build of a watch, which webpack started reading the file
// system for at this time: what the files hold by its end is kept.
const watchStart =
compiler.watchMode &&
!compiler.modifiedFiles &&
!compilation.compiler.isChild()
? compiler.fsStartTime
: undefined;
// Globbing the file system does not depend on the module graph, so a
// child compilation would only lint what its parent already did.
const enabled = compilation.compiler.isChild()
Expand Down Expand Up @@ -606,6 +651,7 @@ class DiagnosticsWebpackPlugin {

check.collecting = collectFromFileSystem(compiler, check).then(
(collected) => {
check.files.push(...collected.lint, ...collected.keep);
check.pending.push(...collected.lint);
check.kept.push(...collected.keep);
check.flush(true);
Expand Down Expand Up @@ -737,6 +783,12 @@ class DiagnosticsWebpackPlugin {
readLast.set(id, { read, missing, directories, writes });
await watch(check, report);

if (watchStart !== undefined) {
afterBuild.push(() => {
keepWatchStart(compiler, check, check.files, watchStart);
});
}

// `reportAs` has already dropped whatever it reports as `false`,
// so what is left only needs putting where it belongs.
for (const [results, reported] of /** @type {const} */ ([
Expand Down
Loading
Loading