From 37da5ad61eda7c4e0c7f2dda48a96b7c20141ca8 Mon Sep 17 00:00:00 2001 From: Jack Misner Date: Fri, 23 Oct 2026 13:46:18 +0100 Subject: [PATCH 1/2] docs: say what a CommonJS consumer pays, and what actually fixes it 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. --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ffdf30..b1dbddb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Three entry points: All three ship dual ESM/CJS builds with TypeScript declarations. The `/react` and `/server` subpaths need TypeScript `moduleResolution` set to `node16` or `nodenext` (TS 4.7+), or `bundler` (TS 5.0+); under the legacy `node`/`node10` setting only the root entry resolves types, though all three resolve at runtime in Node. +The root entry is a barrel, so for a browser bundle it matters how you import from it. esbuild and Rollup — and so Vite — cannot tree-shake CommonJS, so `const { toSnakeCase } = require('@jackmisner/utm-toolkit')` keeps the whole entry: roughly 28KB minified, against roughly 1.4KB for `import { toSnakeCase }` doing the same work. webpack 5 does prune unused CommonJS exports, and there the gap is under 1KB. A static `import` at the call site is the fix; `await import()` is not, because the namespace object is destructured at runtime and the bundler has to keep every export. The subpaths are far less exposed, because most of what they contain gets used either way: `/server` differs by a couple of hundred bytes, and `/react` by about 2.7KB on esbuild and under 500 bytes on webpack, against the ~14KB the hook pulls in regardless. + ## Quick Start ### Basic Usage (Framework-Agnostic) @@ -850,7 +852,7 @@ The browser defaults are lenient because losing a campaign label client-side is `/server` does not import storage, form population, link decoration, debug helpers or React. That restriction is enforced by a test that walks the module's runtime import graph, not just documented — so the guarantee cannot be quietly removed by a convenient re-export. -Note the honest framing: the root entry does **not** crash in Node. The reasons to use `/server` are the documented DOM-free surface, the server-appropriate defaults, the totality contract, and a smaller install/parse surface — the server bundle is roughly 5KB against the root entry's 52KB. +Note the honest framing: the root entry does **not** crash in Node. The reasons to use `/server` are the documented DOM-free surface, the server-appropriate defaults, the totality contract, and a smaller install/parse surface — the published `/server` build is about 6KB against the root entry's 52KB. Those are raw file sizes in the package rather than what a consumer ships: a bundler prunes both, and almost all of `/server` is reachable from its own exports anyway. ## Configuration Options @@ -942,6 +944,7 @@ import type { - The root entry is DOM-free at runtime — every `window` access sits behind a guard — so it imports and runs in Node without a DOM - `@jackmisner/utm-toolkit/server` is the supported surface for server use: it cannot reach storage or the DOM, and that restriction is enforced by a test rather than only documented - Types for the `/react` and `/server` subpaths need `moduleResolution` `node16`, `nodenext` or `bundler`; see [Installation](#installation) +- A Node consumer is usually not bundling at all, so the import-style difference under [Installation](#installation) is parse cost on a long-lived process rather than download size — and `/server` carries no meaningful penalty either way ## Migration from Existing Projects From 7e3df6dec79ff169eb4e36b0b49bd553a070516c Mon Sep 17 00:00:00 2001 From: Jack Misner Date: Fri, 23 Oct 2026 13:54:20 +0100 Subject: [PATCH 2/2] docs: correct the await import() explanation, which was backwards 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. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b1dbddb..4e9d50b 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Three entry points: All three ship dual ESM/CJS builds with TypeScript declarations. The `/react` and `/server` subpaths need TypeScript `moduleResolution` set to `node16` or `nodenext` (TS 4.7+), or `bundler` (TS 5.0+); under the legacy `node`/`node10` setting only the root entry resolves types, though all three resolve at runtime in Node. -The root entry is a barrel, so for a browser bundle it matters how you import from it. esbuild and Rollup — and so Vite — cannot tree-shake CommonJS, so `const { toSnakeCase } = require('@jackmisner/utm-toolkit')` keeps the whole entry: roughly 28KB minified, against roughly 1.4KB for `import { toSnakeCase }` doing the same work. webpack 5 does prune unused CommonJS exports, and there the gap is under 1KB. A static `import` at the call site is the fix; `await import()` is not, because the namespace object is destructured at runtime and the bundler has to keep every export. The subpaths are far less exposed, because most of what they contain gets used either way: `/server` differs by a couple of hundred bytes, and `/react` by about 2.7KB on esbuild and under 500 bytes on webpack, against the ~14KB the hook pulls in regardless. +The root entry is a barrel, so for a browser bundle it matters how you import from it. A static `import { toSnakeCase }` prunes to roughly 1.4KB on esbuild, and to well under 1KB on Rollup and webpack. A `require()` of the same entry keeps all of it on esbuild, Rollup and Rolldown — roughly 28KB minified — because none of them tree-shake CommonJS; webpack 5 does prune CommonJS exports, so there the gap is about 640 bytes. `await import()` is not a dependable substitute: esbuild and Rolldown keep the whole namespace however you access it, and Rollup prunes it only when you destructure the result directly. Use a static `import` at the call site. The subpaths are far less exposed, because most of what they contain is used either way: `/server` differs by a few hundred bytes, and `/react` by about 2.7KB on esbuild and about 500 bytes on webpack, against the ~14KB the hook pulls in regardless. ## Quick Start @@ -852,7 +852,7 @@ The browser defaults are lenient because losing a campaign label client-side is `/server` does not import storage, form population, link decoration, debug helpers or React. That restriction is enforced by a test that walks the module's runtime import graph, not just documented — so the guarantee cannot be quietly removed by a convenient re-export. -Note the honest framing: the root entry does **not** crash in Node. The reasons to use `/server` are the documented DOM-free surface, the server-appropriate defaults, the totality contract, and a smaller install/parse surface — the published `/server` build is about 6KB against the root entry's 52KB. Those are raw file sizes in the package rather than what a consumer ships: a bundler prunes both, and almost all of `/server` is reachable from its own exports anyway. +Note the honest framing: the root entry does **not** crash in Node. The reasons to use `/server` are the documented DOM-free surface, the server-appropriate defaults, the totality contract, and a smaller install/parse surface — the published `/server` ESM build is about 6KB against the root entry's 52KB, the CommonJS files being slightly larger. Those are raw file sizes in the package rather than what a consumer ships: under a static `import` a bundler prunes both, and almost all of `/server` is reachable from its own exports anyway. ## Configuration Options @@ -944,7 +944,7 @@ import type { - The root entry is DOM-free at runtime — every `window` access sits behind a guard — so it imports and runs in Node without a DOM - `@jackmisner/utm-toolkit/server` is the supported surface for server use: it cannot reach storage or the DOM, and that restriction is enforced by a test rather than only documented - Types for the `/react` and `/server` subpaths need `moduleResolution` `node16`, `nodenext` or `bundler`; see [Installation](#installation) -- A Node consumer is usually not bundling at all, so the import-style difference under [Installation](#installation) is parse cost on a long-lived process rather than download size — and `/server` carries no meaningful penalty either way +- Unbundled Node loads the whole entry file whichever way you import it, so the bundle figures under [Installation](#installation) do not apply there: the cost is a one-off parse of about 52KB for the root entry, or about 6KB for `/server` ## Migration from Existing Projects