diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a1848..9639d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ Measured, minified, across 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 | | `validateSync` | 3,722 | 3,554 | 3,823 | | `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 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.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written, diff --git a/README.md b/README.md index 15abc69..1e3d79e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 2392cb4..11edee9 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,10 @@ } }, "sideEffects": [ + "./dist/esm/index.js", "./dist/esm/metadata.js", "./dist/cereale.min.js", + "./src/index.ts", "./src/metadata.ts" ], "files": [ diff --git a/src/metadata.ts b/src/metadata.ts index 50c261a..04138f8 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -15,8 +15,22 @@ import type { ClassConstructor } from './interfaces.js'; */ 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` -// directly. package.json marks this module as having side effects so it survives bundling. +// Also installed globally, because a consumer's own compiler emit reads `Symbol.metadata` +// 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; export interface ValidationArguments { diff --git a/src/treeshake.test.ts b/src/treeshake.test.ts index a53026c..f3ef26f 100644 --- a/src/treeshake.test.ts +++ b/src/treeshake.test.ts @@ -117,6 +117,27 @@ describe('tree-shaking', () => { 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 () => { const code = await bundle(` import { flattenErrors } from CEREALE; @@ -160,6 +181,22 @@ describe('tree-shaking', () => { 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 () => { const flat = readFileSync(path.join(dist, 'cereale.min.js'), 'utf8'); const perModule = readFileSync(path.join(dist, 'esm/decorators.js'), 'utf8');