🌳 fix: keep the Symbol.metadata install through tree-shaking

`sideEffects` named metadata.js, the module holding the global install, but not the
barrel that re-exports it. A side-effect-free barrel is droppable whole, so every
bundler pruned the `export * from './metadata.js'` edge before metadata.js's own
marking was ever consulted, and the install vanished.

Measured on `import { configure } from 'cereale'`: 145 bytes through esbuild, 143
through webpack, 144 through rollup, with `Symbol.metadata` absent from all three.
It matters because tsc's decorator emit reads the well-known symbol directly —

    const _metadata = typeof Symbol === "function" && Symbol.metadata
      ? Object.create(null) : void 0;

so without it a decorated class is handed `metadata: undefined` and ends up with no
rules at all. Two places in the source promised this survived bundling; it did not.

index.js and index.ts are listed now. The cost is ~100 bytes and lands only on
imports that reach nothing else: the single-decorator, validateSync, toPlainSync,
toInstanceSync and whole-library cases are byte-identical before and after, across
all three bundlers. dist/cjs needs nothing — tsc emits `__exportStar(require(...))`,
an unconditional statement no bundler can drop.

Two new cases in treeshake.test.ts pin it, one on src and one on dist/esm, since
those are separate manifest paths and a typo in either is invisible from the other.
Both were checked by removing the entry and watching them fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 17:22:16 +00:00
parent c48a106a05
commit 3bd190bde6
5 changed files with 80 additions and 4 deletions
+8 -1
View File
@@ -506,7 +506,7 @@ source and the published bundle:
| What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: |
| `flattenErrors` | 287 | 292 | 291 |
| `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
@@ -518,6 +518,13 @@ The serializer and the deserializer drop independently: read JSON and you do not
writing it. The validator is kept by both, because `validate` defaults to `true` and the entry
points reference it whatever a given call site passes.
The floor is about a hundred bytes: cereale installs `Symbol.metadata` if the runtime lacks it,
and that install has to survive tree-shaking or a `tsc`-compiled consumer decorates its classes
with no metadata at all. It is why `sideEffects` names `index.js` as well as `metadata.js` —
marking only the latter leaves the barrel itself droppable, so the edge to it is pruned before
its own marking is ever read. That cost lands only on the first row; every import that touches a
model was already carrying it.
This did not come for free. Thirty of the rules are declared as top-level calls —
`export const IsString = rule(…)` — and rollup can prove such a call side-effect-free by reading
the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of