Skip to content

perf: make the module-scope defaults provably pure (1,347 → 303 bytes for one import) - #42

Merged
jackmisner merged 7 commits into
mainfrom
perf/pure-module-initialisers
Oct 23, 2026
Merged

jackmisner merged 7 commits into
mainfrom
perf/pure-module-initialisers

Conversation

@jackmisner

Copy link
Copy Markdown
Owner

What this does

Makes the module-scope defaults provably pure so a bundler can drop them. A consumer importing one function from the root entry goes from 1,347 bytes to 303.

bundler before after
esbuild, static import 1,347 303
Rolldown (Vite 8), static 994 302
webpack 5, static 469 307
webpack 5, require 1,109 516
Rollup, static 362 362 (already pruned)

esbuild and Rolldown cannot prove an array or object spread pure — an array spread invokes an iterator, an object spread can invoke getters — so they retained DEFAULT_CONFIG and everything it reaches even when nothing referenced it. Rollup could already prove it.

This is the first change in the recent sequence to help a CommonJS consumer at all (webpack require, −53%). It also removes the dead top-level statement from dist/server/index.js, the residue of the same spread.

How it is built

DEFAULT_PII_FILTER_CONFIG and DEFAULT_CONFIG are built by non-exported factory functions whose single call site carries the annotation. The spreads inside are character-identical to those on main — the diff shows each one as a -/+ pair differing only by indentation — so the copy semantics are the original code, not a re-expression of it.

That shape was proposed in review, after a first attempt that annotated each copy individually (Object.assign({}, x), x.slice()). Measured, the factory form dominates it for four bytes:

annotated copies factory
one-symbol import 303 B 303 B
annotations in defaults.ts 6 2
lost annotation caught by the budget 4 of 6 2 of 2
Object.assign Get/Set hazard present none
consumer using the defaults 988 B 992 B

It closes a guard coverage gap rather than documenting one, and removes a hazard — Object.assign reads sources with Get and writes the target with Set, so a getter, a __proto__ key or a Proxy on any of those public constants would have made an annotation false, invisibly to both the tests and the byte budget. Spread syntax cannot be subverted that way.

The invariant that had no coverage

The spreads exist to copy: DEFAULT_CONFIG.sanitize must not alias DEFAULT_SANITIZE_CONFIG, because both are public API and a consumer mutating one default must not change another. Nothing tested that — grep 'DEFAULT_CONFIG\.' __tests__/ returned nothing.

Tests were written first, confirmed passing on unmodified main, then checked against the mistake they exist to catch: replacing the five copies with references. 5 of 8 failed — and all 21 other test files still passed. That mistake was undetectable by 635 tests.

Four of those tests were later removed on review: with no getter or Proxy involved they are strictly implied by the identity assertions, and they were the only tests in the repo writing to exported module state, which CLAUDE.md warns against. One replaced them, pinning what identity does not cover — the patterns array is copied while its member objects stay shared.

The guard, and what it does not cover

scripts/assert-bundle-budget.js runs in check:package. Annotations are comments, so losing one costs every consumer a kilobyte and changes nothing a test can see. It weighs the bundle and executes it, because an annotation on a call that is not pure would license a bundler to drop something needed and nothing else here prunes an entry and runs the result.

Verified: 303 bytes clean; 671 and 1,228 when either annotation is lost; a dist tampered to answer wrongly is caught; a missing build gives a clear message. Stable at 303 bytes on every run across Node 20/22/24/26, aarch64 and x86_64, Debian 12 and 13, and macOS. Costs 0.065s.

It does not cover the two annotations in src/common/keys.ts, worth 153 and 21 bytes: catching 21 would need a ceiling 17 bytes above baseline. A breach is therefore most likely one of the defaults.ts pair, and the prose says so rather than implying full coverage.

What review found in my work

Five reviewers and two claim audits. The substantive catches were all mine to own:

  • I verified "the guard catches a lost annotation" against one annotation — a defaults.ts one worth ~920 bytes — and wrote three claims on that sample. The two in keys.ts measure 456 and 324 bytes and pass the 600 budget. The cheapest is worth 21 bytes, not the ~170 I stated.
  • I stranded two public doc comments. The factory refactor left "Default configuration with all values set" above a non-exported function, so dist/defaults-*.d.ts shipped a bare declare const DEFAULT_CONFIG while every sibling kept its description — two commits after I had moved an internal note out of those same declarations.
  • "A bundler cannot prove a spread pure" was wrong twice. First with the wrong reason (an iterator, which is true of array spreads only), then with the right reason but an overgeneral subject — Rollup can.
  • A CLAUDE.md bullet inserted between two others left "The flag saves a consumer bytes..." sitting under a bullet about annotations, referent gone.
  • A test comment described a shape that no longer existed once the factory restored the spreads.
  • My own archeology grep — the one CLAUDE.md says to run before every commit — tripped on "no longer works" in a script header.

Release implication

Compared against the tarball published on npm as 0.3.2: 73 exports, none added, none removed, no type changes; DEFAULT_CONFIG JSON and key order identical; toSnakeCase parity. Published files +268 B (root), +236 B (react), −13 B (server) to save each consumer up to a kilobyte. That is a patch.

Follow-ups, not in this PR

  • A source-level annotation check would cover the two keys.ts annotations no byte budget can reach. Raised in review; it is a different guard from the one agreed, so it is an offer.
  • npm run type-check never checks test files. tsconfig.json excludes __tests__, hiding 19 type errors across eight existing test files. This branch's new tests are clean and do not widen it.
  • Node 20 cannot run the test suite (vitest workers fail on undici), while engines.node still says >=20.0.0. Pre-existing, documented, and the declared floor remains apart from the testable one.

A consumer importing one function from the root entry shipped 1,347 bytes,
of which about 1,040 was DEFAULT_CONFIG and everything it reaches. esbuild
cannot prove an array or object spread pure - spreading invokes an iterator,
which could in principle be patched - so it retained the defaults even when
nothing referenced them. Annotating each module-scope initialiser takes that
import to 303 bytes.

The win is not confined to esbuild. Measured per bundler, root entry, one
static import: esbuild 1,347 to 306, Rolldown 994 to 305, webpack 469 to
307. Rollup already pruned these. webpack's CommonJS consumer drops from
1,109 to 516, which is the first thing in this sequence to help a require()
consumer at all.

The exported constants stay the single source of truth rather than being
inlined as literals: DEFAULT_SANITIZE_CONFIG and its siblings are all public
API, so duplicating their data into DEFAULT_CONFIG would create two exported
things that can drift. The spreads are replaced by annotated calls, not by
references, because they exist to copy - DEFAULT_CONFIG.sanitize must not
alias DEFAULT_SANITIZE_CONFIG.

Nothing covered that. Replacing those five copies with references left all
635 existing tests passing, so the new tests pin it: identity, and that a
write through one exported default cannot reach another. Five of the eight
fail on the reference form.

scripts/assert-bundle-budget.js guards the saving, because annotations are
comments. Removing one, or adding an un-annotated spread to DEFAULT_CONFIG,
costs every consumer a kilobyte and changes nothing a test would see;
dropping a single annotation takes the measured bundle to 1,211 bytes,
over the 600 budget. esbuild joins the package-check Dependabot group,
since it is pre-1.0 and its output size is what the budget measures.

The published files grow 183 bytes to save each consumer ten times that.
dist/server/index.js shrinks, and the dead top-level statement esbuild used
to leave there - the residue of the same spread - is gone.
The figures merged an hour ago describe the build before the purity fix. A
static import of one function is now around 300 bytes on esbuild, Rollup,
Rolldown and webpack alike, rather than 1.4KB on esbuild and under 1KB
elsewhere, and webpack's require-vs-import gap is about 200 bytes rather
than 640.

The /react delta moves from about 2.7KB to about 3KB, since its static
import shrank further than its require did. The await import() sentence is
unchanged: re-measured, Rollup still prunes only a directly destructured
result, 464 bytes against 27,611 read off a namespace, and esbuild and
Rolldown still keep everything at about 28KB.
A spread reads as the cleaner form, so the next person to tidy these will
reach for it and hand every consumer back the kilobyte this branch removed.
The budget script catches it, but only after the fact; the reason belongs
where the odd-looking code is.
The note was a second JSDoc block stacked above DEFAULT_CONFIG, so
TypeScript took it as the declaration's doc comment and the real
description - "Default configuration with all values set" - was orphaned
above it. It shipped that way: dist/defaults-*.d.ts carried build-tooling
trivia as the documentation for a public constant, pointing at a script that
is not in the package.

Line comments inside the object cannot be read as a doc comment, and they
sit where someone would reach for the cleaner spread, which is where the
warning is wanted.

Also corrects the byte figures in the budget script, which quoted 1,347 and
306 from a fixture whose entry writes globalThis.__input where the script
writes globalThis.__in - three characters, three bytes. The script now
quotes what it measures: 1,344 and 303.
…bundle

The guard cannot see either annotation in common/keys.ts. Stripping them from
the built dist and re-measuring gives 456 and 324 bytes against a 600 budget,
so both pass. My "fails when a single annotation is removed" check happened
to hit one of the six in defaults.ts, which are worth ~920 bytes each, and I
generalised from that one sample. Three claims were false as a result: the
script's "cheapest annotation is worth ~170 bytes" (it is 21), its failure
message naming keys.ts, and the CLAUDE.md line saying the script notices for
both files.

No budget fixes this: catching a 21-byte loss needs a ceiling 17 bytes above
the baseline. So the prose now says what is true - a breach always points at
defaults.ts - and records that the keys.ts pair is unguarded and worth 174
bytes between them.

The guard also only weighed the bundle, never ran it. An annotation on a call
that is not pure would license a bundler to drop something needed, and
nothing in the repo would notice: vitest runs unminified src with no
tree-shaking. It now executes the pruned bundle and asserts one conversion,
which costs milliseconds since esbuild already returns the code in memory. A
dist tampered to answer wrongly is caught.

And the annotations rest on a precondition nothing stated: the sources must
stay plain data literals. Object.assign reads them with Get and writes the
target with Set, so a getter, a __proto__ key or a Proxy on any of these
public constants would make an annotation false, invisibly to both the tests
and the byte budget. Spread syntax could not be subverted that way; the
replacement can, so the condition is now written where the code is.
…ted copies

Six annotations on individual copies become two on factory calls, and the
spreads come back. Measured, this is the better shape on every axis for four
bytes.

The budget could only see four of the six. Each annotation inside
DEFAULT_CONFIG was worth ~920 bytes and was caught, but the two in
common/keys.ts cost 153 and 21, and no ceiling catches 21 against a baseline
of 303. With one annotation per object, losing either costs the whole object:
671 and 1,228 bytes, both over budget. The keys.ts pair stays uncovered,
which is now the only gap rather than one of three.

It also removes a hazard rather than documenting it. Object.assign reads its
sources with Get and writes the target with Set, so a getter, a __proto__ key
or a Proxy on any of those public constants would have made an annotation
false - invisible to the tests and to the byte budget alike. Spread syntax
cannot be subverted that way, so the precondition that needed writing down no
longer exists.

And the comment no longer has to explain why a copy is written as
Object.assign({}, x), which read as a lint target. What remains is the one
thing a reader would otherwise undo: the factory exists so the annotation has
a call to attach to.

Cost: 992 bytes against 988 for a consumer who does use the defaults.

The four mutation tests are gone. They were strictly implied by the identity
assertions above them - with no getter or Proxy involved, a write through one
object cannot reach a different one - and they were the only tests in the repo
writing to exported module state, which CLAUDE.md warns against. One test
replaces them, pinning the nuance identity does not cover: the patterns array
is copied while its member objects stay shared. The key-list tests move to
common/keys.test.ts, which covers that module and had no coverage of them.

Also: the CLAUDE.md bullet was inserted between the two sideEffects bullets,
so "The flag" came to sit under a bullet about annotations and lost its
referent. It now goes last and points at the code rather than restating it.
The dependabot group comment names esbuild, which the pattern list gained.
…ard claims

The factory refactor stranded two public doc comments. "Default configuration
with all values set" and "Default PII filtering configuration" ended up above
the factory functions, which are not exported, so dist/defaults-*.d.ts carried
bare `declare const DEFAULT_CONFIG` and `declare const
DEFAULT_PII_FILTER_CONFIG` where every sibling keeps its description. Two
commits earlier I moved an internal note out of those declarations; this put
the real docs out with it. They are back on the export lines, with the factory
rationale left on the function where it cannot reach a consumer.

"A bundler cannot prove an object or array spread pure" is false for Rollup,
which drops both factories unannotated - 362 bytes either way. Having fixed
the iterator half of that sentence I left the subject overgeneral. It now
names esbuild and Rolldown and says Rollup can.

The guard said it protects "each exported default" in defaults.ts and told a
maintainer to check "each one". Six constants are exported there and two are
factory calls; the other four are plain literals needing no annotation, so
that wording sends someone annotating literals. It now names the two, and the
error message names them too.

"~1,200 with any one of them lost" was measured from the DEFAULT_CONFIG
annotation only. Losing the DEFAULT_PII_FILTER_CONFIG one gives 671. Both
breach the budget, so the mechanism holds, but the figure covered half the
cases. Now ~670 or ~1,200.

A breach "always points at defaults.ts" overclaimed: growth in toSnakeCase or
in keys.ts could also cross a 297-byte headroom. Softened to most likely.
"Nothing else here bundles anything" was literally false, since tsup bundles
on every build; what is true is that no other check prunes the entry and runs
the result. The header no longer restates the reasoning the src comment holds.

The test comment "the spread these replaced" described a shape that no longer
exists - the factory restored the spreads, so the spread is the live
implementation and nothing replaced it. Deleted rather than reworded.

README: /react on webpack is fixture-sensitive. My fixture measures 471 bytes
and an independent one 224, while both agree on root (209) and /server (202),
so a precise figure does not belong there. "Well under 1KB" is true of both.
@jackmisner jackmisner added documentation Improvements or additions to documentation enhancement New feature or request labels Oct 23, 2026
@jackmisner
jackmisner merged commit 415290b into main Oct 23, 2026
5 checks passed
@jackmisner jackmisner mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant