🌳 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
+17 -1
View File
@@ -43,7 +43,7 @@ Measured, minified, across esbuild / rollup / webpack:
| What you import | esbuild | rollup | webpack | | What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: |
| `flattenErrors` | 287 | 292 | 291 | | `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 | | one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,823 | | `validateSync` | 3,722 | 3,554 | 3,823 |
| `toPlainSync` | 7,744 | 7,769 | 7,832 | | `toPlainSync` | 7,744 | 7,769 | 7,832 |
@@ -60,6 +60,22 @@ names the rules that must not appear — and one case asserts that everything IS
everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip
the annotations and it fails with `"must be a latitude" should have been shaken out`. the annotations and it fails with `"must be a latitude" should have been shaken out`.
The same pass shook out something that was supposed to stay. Cereale installs `Symbol.metadata`
when the runtime lacks it, and `sideEffects` named the module holding that install — but not the
barrel that re-exports it. A side-effect-free barrel is droppable as a whole, so all three
bundlers pruned the `export * from './metadata.js'` edge before metadata.js's own marking was
ever consulted: `import { configure } from 'cereale'` came out at 145 bytes through esbuild, 143
through webpack and 144 through rollup, with `Symbol.metadata` in none of them. That matters
because `tsc`'s decorator emit reads the well-known symbol directly — `typeof Symbol ===
"function" && Symbol.metadata ? Object.create(null) : void 0` — so without the install a
decorated class gets `metadata: undefined`, which is to say no rules at all.
`index.js` and `index.ts` are now listed too. It costs about 100 bytes, and only on imports that
reach nothing else; every row of the table above except the first was byte-identical before and
after, across all three bundlers. Two more cases in `treeshake.test.ts` pin it, one on the source
and one on `dist/esm`, because those are separate paths in the manifest and a typo in either is
invisible from the other side.
### Frameworks ### Frameworks
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written, [FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
+8 -1
View File
@@ -506,7 +506,7 @@ source and the published bundle:
| What you import | esbuild | rollup | webpack | | What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: |
| `flattenErrors` | 287 | 292 | 291 | | `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 | | one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,738 | | `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,771 | | `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 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. 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 — 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 `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 the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of
+2
View File
@@ -23,8 +23,10 @@
} }
}, },
"sideEffects": [ "sideEffects": [
"./dist/esm/index.js",
"./dist/esm/metadata.js", "./dist/esm/metadata.js",
"./dist/cereale.min.js", "./dist/cereale.min.js",
"./src/index.ts",
"./src/metadata.ts" "./src/metadata.ts"
], ],
"files": [ "files": [
+16 -2
View File
@@ -15,8 +15,22 @@ import type { ClassConstructor } from './interfaces.js';
*/ */
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata'); const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata` // Also installed globally, because a consumer's own compiler emit reads `Symbol.metadata`
// directly. package.json marks this module as having side effects so it survives bundling. // directly and does not share our fallback. tsc emits
//
// const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
//
// so on a runtime without the well-known symbol the class is decorated with `metadata:
// undefined` and ends up with no metadata at all. (esbuild's `__knownSymbol` has the same
// `Symbol.for` fallback we do and needs nothing from us; tsc does.)
//
// `sideEffects` in package.json is what keeps this statement through bundling — and it has to
// name `index.ts`/`index.js` as well as this module. Marking only this one is not enough: the
// barrel is then itself side-effect-free, so a bundler drops the `export * from './metadata.js'`
// edge before this module's own marking is ever consulted, and the install silently vanishes.
// Measured on `import { configure } from 'cereale'`: absent from all three of esbuild, webpack
// and rollup until the barrel was listed too. It costs ~100 bytes, and only for imports that
// pull in nothing else — every entry point that touches a model was already byte-identical.
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY; ((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
export interface ValidationArguments { export interface ValidationArguments {
+37
View File
@@ -117,6 +117,27 @@ describe('tree-shaking', () => {
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]); expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
}); });
/**
* The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export,
* so it survives only because `sideEffects` names that module — and naming it is one hop short
* of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the
* `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted.
*
* Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145
* bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata`
* absent from all three — and a tsc-compiled consumer on a runtime without the well-known
* symbol is then decorated with `metadata: undefined`, which is to say with no rules at all.
*/
it('installs Symbol.metadata even when nothing model-shaped is imported', async () => {
const code = await bundle(`
import { configure } from CEREALE;
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]);
});
it('costs almost nothing to import only an error helper', async () => { it('costs almost nothing to import only an error helper', async () => {
const code = await bundle(` const code = await bundle(`
import { flattenErrors } from CEREALE; import { flattenErrors } from CEREALE;
@@ -160,6 +181,22 @@ describe('tree-shaking', () => {
expect(code.length).toBeLessThan(3000); expect(code.length).toBeLessThan(3000);
}); });
/**
* The source-level case above proves the `sideEffects` mechanism works; this one proves the
* two entries are spelled the way the published tree is laid out. `./src/index.ts` and
* `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible
* from the other side.
*/
it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => {
const code = await bundle(`
import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))};
export const f = (o) => configure(o);
`.replace('CEREALE', 'unused'));
expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]);
});
it.runIf(built)('keeps its purity annotations through minification', async () => { it.runIf(built)('keeps its purity annotations through minification', async () => {
const flat = readFileSync(path.join(dist, 'cereale.min.js'), 'utf8'); const flat = readFileSync(path.join(dist, 'cereale.min.js'), 'utf8');
const perModule = readFileSync(path.join(dist, 'esm/decorators.js'), 'utf8'); const perModule = readFileSync(path.join(dist, 'esm/decorators.js'), 'utf8');