Repository navigation
docs: what a CommonJS consumer pays for the root entry, and what fixes it - #41
Merged
Merged
Conversation
A consumer who notices the root entry is heavy will reach for `await import()`, and it buys nothing: 28,129 bytes against 27,788 for `require` on esbuild, and worse on webpack, because the namespace object is destructured at runtime so every export has to be kept. They would measure no improvement and conclude the library is just fat. Only a static import at the call site helps, and nothing said so. The figures name their bundlers deliberately. esbuild and Rollup cannot tree-shake CommonJS, so a root `require` for one function costs ~28KB minified against ~1.4KB for the static import. webpack 5 does prune unused CommonJS exports and the gap there is 640 bytes, so quoting 28KB unqualified would send a webpack user chasing a problem they do not have. The subpaths are quoted separately because they behave differently: /server differs by 202-389 bytes and /react by 471-2,695 depending on the bundler, since most of what either contains is used regardless. Also corrects the /server size comparison, which gave 5KB against 52KB without saying those are raw files in the package. They are not what a consumer ships, they sat a few lines from a bundle-size discussion, and the server figure was 6KB.
The claim that `await import()` fails "because the namespace object is
destructured at runtime and the bundler has to keep every export" was wrong
in three ways, and the audit caught all three.
Destructuring is what lets Rollup prune, not what stops it. Measured:
`const { toSnakeCase } = await import(...)` gives 464 bytes against 27,561
for the same import read as `m.toSnakeCase`. The reason was stated backwards
for the one bundler where the advice would have mattered.
"Keep every export" is false for webpack, which prunes most of it either
way; and on webpack `await import()` costs more than `require` anyway,
because it forces a separate chunk, so advice framed around keeping exports
pointed the wrong direction there too.
esbuild and Rolldown do keep everything, but not for that reason: the
namespace survives whether you destructure it or read a property off it,
27,867 against 27,866 bytes on Rolldown.
The paragraph now states which bundlers behave which way instead of
reasoning from a mechanism, names Rolldown rather than inferring Vite's
behaviour through Rollup - vite@latest is 8.3.5 and depends on rolldown, not
rollup - and widens /server to "a few hundred bytes", since esbuild's 422 is
not "a couple".
The Node bullet claimed an import-style difference that does not exist
there: unbundled Node loads the whole entry file either way, so the bundle
figures do not apply at all. It also generalised that Node consumers are not
bundling, which route handlers and bundled Lambdas contradict. It now states
only the parse cost. The /server size sentence says which format its figures
are, and that pruning needs a static import.
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 is, and what it isn't
The investigation this came from recommended against the change it was investigating. There is no code here.
The question was whether to engineer away a ~26 KB penalty that a CommonJS consumer of the root entry pays. Measured, the penalty is real but lands almost entirely where CommonJS consumers aren't, and every in-package fix measured worse than the one-line change the affected consumer has to make anyway. What's left worth doing is telling them which line that is.
The measurements behind it
requireimportawait import()destructuredawait import()namespacePer entry,
requirevs staticimport:./react./serverThree things follow, and they are why this PR has no code:
./server— the one entry where CommonJS consumers are realistic — costs 87–204 gzipped bytes, and can't be improved: a staticimportalready retains ~89% of it, becausenormalizeUtmParamsgenuinely needs the sanitizer, PII filter and defaults.require(), and importing from the root barrel. Vite, Next, Remix, Astro, Rollup, esbuild and webpack 5 all resolve theimportcondition for a browser target by default.Why the note earns its place
A consumer who notices the root entry is heavy reaches for
await import(), and on three of four bundlers it buys nothing — on webpack it is worse thanrequire, because it forces a separate chunk. They would measure no improvement and conclude the library is simply fat. Only a static import reliably helps, and nothing said so.What changed
require()of the root costs, per bundler, thatawait import()is not a dependable substitute, and that a staticimportat the call site is./server./serversize comparison, which gave "roughly 5KB against the root entry's 52KB" without saying those are raw files in the package rather than what a consumer ships — and the server figure was 6KB.Options rejected, with the evidence
splittingfor CJS. Works, andcheck:packagepasses fully green, which is what makes it a trap. Measured: root CJS +22%,/reactCJS +57%,/serverCJS +56%, ESM roughly neutral; only the tarball improves (−24%).splitting: falsetree-shakes each entry independently; splitting hoists the union into chunks and nothing can be dropped across a chunk boundary.requiretoimportand get 3.1 KB instead of 10.3 KB. Six permanent public entry points to offer something 3× worse. Two slices also duplicate their sharedcommon/*, so three slices is worse than the barrel.requirecondition. Fails our own gate: attw reports a livenode16 (from CJS)error, andrequire(esm)only landed in Node 20.19/22.12, soengines.node >= 20.0.0would have to rise.Review
Docs-only needs no review under the repo's tiers, but this prose is almost entirely measured numbers, so it got a claim audit — which was the right call. It found that my
await import()explanation was backwards: destructuring is what lets Rollup prune (464 B) rather than what stops it (27,561 B read asm.x), "keeps every export" was false for webpack, and the Vite inference was stale sincevite@latestdepends on Rolldown, not Rollup. I re-verified every corrected figure myself, Rolldown included, before rewriting. One earlier figure I caught myself:/reactis ~2.7 KB on esbuild, not the "few hundred bytes" I first wrote.Follow-ups, not in this PR
src/config/defaults.tsandsrc/common/keys.ts. Array spreads andObject.keys()at module scope stop esbuild proving them pure, costing ~1 KB on the ESM path — the path nearly everyone is on. Format-independent, no API change, and worth more than this PR. Agreed as the next one.defaultProtocol, sosetDefaultProtocolapplies to half their app. Inherent to dual-format publishing with module-level mutable state../react;splitting: falseduplicates shared modules across entries, which could be ~13 KB. It wouldn't change a docs-only outcome, so it is flagged rather than guessed at.