🌳 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:
+17
-1
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user