Repository navigation
perf: make the module-scope defaults provably pure (1,347 → 303 bytes for one import) - #42
Merged
Merged
Conversation
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
importrequireesbuild 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_CONFIGand 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 fromdist/server/index.js, the residue of the same spread.How it is built
DEFAULT_PII_FILTER_CONFIGandDEFAULT_CONFIGare built by non-exported factory functions whose single call site carries the annotation. The spreads inside are character-identical to those onmain— 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:defaults.tsObject.assignGet/Set hazardIt closes a guard coverage gap rather than documenting one, and removes a hazard —
Object.assignreads sources withGetand writes the target withSet, 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.sanitizemust not aliasDEFAULT_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.jsruns incheck: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
disttampered 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 thedefaults.tspair, 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:
defaults.tsone worth ~920 bytes — and wrote three claims on that sample. The two inkeys.tsmeasure 456 and 324 bytes and pass the 600 budget. The cheapest is worth 21 bytes, not the ~170 I stated.dist/defaults-*.d.tsshipped a baredeclare const DEFAULT_CONFIGwhile every sibling kept its description — two commits after I had moved an internal note out of those same declarations.Release implication
Compared against the tarball published on npm as 0.3.2: 73 exports, none added, none removed, no type changes;
DEFAULT_CONFIGJSON and key order identical;toSnakeCaseparity. 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
keys.tsannotations 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-checknever checks test files.tsconfig.jsonexcludes__tests__, hiding 19 type errors across eight existing test files. This branch's new tests are clean and do not widen it.engines.nodestill says>=20.0.0. Pre-existing, documented, and the declared floor remains apart from the testable one.