Skip to content

perf: lint what webpack rebuilt, while it is still building - #319

Merged
alexander-akait merged 3 commits into
mainfrom
perf/incremental-linting
Sep 8, 2026
Merged

perf: lint what webpack rebuilt, while it is still building#319
alexander-akait merged 3 commits into
mainfrom
perf/incremental-linting

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

A rebuild re-linted every file in the graph. Measured by counting what reaches lintFiles over three hundred modules:

initial build   : linted 301 files
after one change: linted 301 files      ← before
after one change: linted   1 file       ← after

That is what #137 has been reporting since 2022, and what lintDirtyModulesOnly trades the first lint away to avoid. On the same fixture a rebuild goes from 1078 ms to 89 ms, best of three.

The plugin now keeps what each check found, per file, for the life of the compiler. A module webpack rebuilt is linted; one it reports as still valid is reported from the store; one that leaves the graph is dropped, as is one webpack lists in removedFiles for a check that walks the file system rather than the module graph — the modifiedFiles / removedFiles pair the issue asked for.

Three things the store has to get right, each of which cost me a failing test before I understood it:

  • A file a check finds nothing in is remembered as nothing found. ESLint reports no result for a clean file, so without this a rebuild cannot tell a clean file from one it has never seen, and lints everything again — which is exactly what my first attempt did.
  • What was linted is forgotten before the new results go back in, or a file that has just been fixed keeps reporting the problem it no longer has. The watch suite caught this one.
  • A file the store does not hold is linted rather than skipped. webpack restores a module from its own filesystem cache without building it, so a compiler that starts that way sees stillValidModule for everything and has nothing to report it from. cached.test.js caught this.

Linting also starts during the module phase rather than after it, once enough files have arrived to be worth sending. I am not claiming a win for that half: measured on a single-threaded lint it is neutral, because overlapping CPU-bound linting with CPU-bound bundling on one thread interleaves rather than overlaps. It is in because it costs nothing, it is what lets a check that parallelises its own work start earlier, and a batch floor keeps small builds on the single call they had (below it, ESLint's concurrency would have nothing to spread across workers).

Also here: a check that fails to lint now reports once rather than once per batch, and a result a check cannot attribute to a file is still reported rather than dropped.

What I looked at and did not ship. Reading fork-ts-checker-webpack-plugin alongside this, two more ideas came up. Aborting a superseded compilation's work (it holds an AbortController per iteration) is real but I could not measure a benefit here. And Stylelint's worker pool looks like it is torn down after the first compilation, leaving watch rebuilds single-threaded — I wrote the fix, then found the test I wrote for it passes on main as well, so it demonstrates nothing and is not in this PR. Both are worth their own look.

What kind of change does this PR introduce?

perf.

Did you add tests for your changes?

Yes, test/incremental.test.js: that a rebuild lints only what webpack rebuilt, and that a file leaving the graph stops being reported. The three defects above are each covered by an existing suite that failed while I had them wrong — cached, watch and stylelint/watch. 120 passing.

Does this PR introduce a breaking change?

No. What is reported is unchanged; only how much is re-linted to report it. lintDirtyModulesOnly still does what it did.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

Documented here: that a rebuild lints only what changed, so lintDirtyModulesOnly is now only worth setting to skip the first lint; and ESLint's concurrency, with the caveat that ESLint itself warns on the runs where threading costs more than it saves. The changeset is a patch.

Use of AI

Written with Claude Code, driven interactively. I asked it to read #137, look at fork-ts-checker-webpack-plugin for ideas, and then to fix what it found. It measured before and after rather than asserting: it counted the files reaching lintFiles, timed the phases against webpack's own, and caught that its own first benchmark was measuring ESLint ignoring every file because the fixture's cwd was wrong. It also corrected two of its own earlier claims — that streaming in small batches was faster (cold-start bias) and that concurrency was unsupported here (it is supported and tested) — and dropped a fix it could not demonstrate. I reviewed the result.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy


Generated by Claude Code

A rebuild re-linted every file in the graph. Touching one module of three
hundred sent all three hundred back to ESLint, which is what #137 has been
reporting since 2022 and what `lintDirtyModulesOnly` traded the first lint
away to avoid.

The plugin now keeps what each check found, per file, for the life of the
compiler. A module webpack rebuilt is linted; one it reports as still valid is
reported from there; one that leaves the graph is dropped, as is one webpack
lists in `removedFiles` for a check that walks the file system rather than the
module graph. Over the same three hundred modules a rebuild goes from 1078 ms
to 89 ms.

Three things the store has to get right, each of which cost a test:

  - A file a check finds nothing in is remembered as nothing found. ESLint
    reports no result for a clean file, so without this a rebuild cannot tell
    a clean file from one it has never seen, and lints everything again.
  - What was linted is forgotten before the new results are put back, or a
    file that has just been fixed keeps reporting the problem it no longer has.
  - A file the store does not hold is linted rather than skipped: webpack
    restores a module from its own filesystem cache without building it, and
    a compiler that starts that way has nothing to report it from.

Linting also starts during the module phase rather than after it, once enough
files have arrived to be worth sending — a check that parallelises its own
work, as ESLint does under `concurrency`, has nothing to spread across workers
before then. This is measured neutral on a single-threaded lint, where it
interleaves with webpack rather than overlapping it; the rebuild is where the
time is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy
@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 8, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via:

Co-authored-by: name <email>

Supported Co-authored-by: formats include:

  1. Anything <id+login@users.noreply.github.com> - it will locate your GitHub user by id part.
  2. Anything <login@users.noreply.github.com> - it will locate your GitHub user by login part.
  3. Anything <public-email> - it will locate your GitHub user by public-email part. Note that this email must be made public on Github.
  4. Anything <other-email> - it will locate your GitHub user by other-email part but only if that email was used before for any other CLA as a main commit author.
  5. login <any-valid-email> - it will locate your GitHub user by login part, note that login part must be at least 3 characters long.

Alternatively, if the co-author should not be included, remove the Co-authored-by: line from the commit message.

Please update your commit message(s) by doing git commit --amend and then git push [--force] and then request re-running CLA check via commenting on this pull request:

/easycla

@nkzawa

nkzawa commented Sep 8, 2026

Copy link
Copy Markdown

Two commits in this PR, 571d064 and 06d6f6c, are attributed to my GitHub account, but I am not involved in this work. Could you fix the commit author?

Thanks!

webpack reports a module's resource with the separators the platform uses
and a check answers with whatever its own tool wrote, so on Windows the
files a compilation covered never matched the results stored for them and
the store evicted every one of them.
The two shapes the store is fed meet on any platform: a glob check walks
the file system for forward slashes while the watcher answers in the
separators the platform uses, so both cases fail on the unfixed store
without needing Windows to run them.
@alexander-akait
alexander-akait force-pushed the perf/incremental-linting branch from 06d6f6c to 80b1291 Compare September 8, 2026 13:42
@alexander-akait
alexander-akait merged commit a809291 into main Sep 8, 2026
13 checks passed
@alexander-akait
alexander-akait deleted the perf/incremental-linting branch September 8, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants