Bundling Mistakes
Generated from
.claude/rules/anti-patterns.md(the same source as MCPget_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter /pyreon doctor/ MCPvalidatecatches it automatically.
A convenience barrel is a REACHABILITY EDGE, and sideEffects — not /* @__PURE__ */ — is the lever.
A generated or hand-written index.ts that export *s every layer makes importing ONE symbol reach every module it names, and a bundler RETAINS a module-level CALL unless it can prove the call pure. api.endpoint('GET /x', …), s.object({ … }), mock(routes) are all module-level calls, so the barrel pins them — and anything they reference. Measured (Vite 8, 30-tag/120-op generated client, importing one hook): 30,710 B / 2,420 gz with 120 endpoints AND 120 mock fixtures, against 5,748 B / 642 gz with 4 endpoints once a package.json declared sideEffects. The reflex fix does not work: annotating each declaration /* @__PURE__ */ measured 2,041 → 2,000 B (2%), because the ARGUMENTS are themselves calls (s.string().uuid()) esbuild must still evaluate — purity is asked per-EXPRESSION, sideEffects per-MODULE, and the question is about the module. Two traps around it. (a) The declaration must be TRUE: false is a lie if any emitted module has a real module-scope effect (an installMocks() call in a workbench wrapper), so use the ARRAY form naming those files — a bundler acts on the claim. (b) The behaviour is CONFIGURATION-DEPENDENT until you emit the marker yourself: an app whose own package.json already declares sideEffects: false covers files generated into its tree and was never affected, so the same code measures differently in two repos. Emitting a package.json beside the output removes that dependency (include "type": "module" — the file becomes the NEAREST package.json for everything under it, and under Node16 resolution one without type means CommonJS, silently reclassifying the output). DATA is the sharp edge: an unused FUNCTION shakes out everywhere, but a fixture table, a route array or a lookup map survives minification wherever it is reachable — which is how a dev-only fixture set ships to production through a barrel that merely NAMES it. The durable fix is structural, not a hint: keep dev surfaces out of the production entry entirely (./dev, the @pyreon/server/client shape) so there is no edge for any bundler to follow, hint honoured or not. Reference: packages/tools/lathe/src/emit/{entries,package-marker}.ts + tests/entry-points.test.ts (24-case matrix, run WITH and WITHOUT the marker — the no-marker half is load-bearing: re-adding a dev export to the barrel fails ONLY those, since with the marker the bundler shakes the regression out invisibly).
A prop-gated optional feature cannot tree-shake — a branch on a runtime prop is reachable code.
@pyreon/charts/plot documents itself as tree-shakeable, and for the geometry it is: bundling PlotChart + bars + line and grepping the output finds zero bytes of treemap, sunburst, sankey, polar, candlestick, heat, geo, gantt or funnel, because every family and every mark is an imported binding a bundler can drop. The interaction layer is the exception, and it is invisible from the entry file: navigator, dataZoom and brush are implemented as props.navigator === true ? … : … INSIDE the core component, so their hit-testing and drag maths are reachable from PlotChart itself no matter how the consumer imports. Measured: the minimal cartesian import grew 12288 → 13573 B gz as those three landed, and it will grow once per interaction feature — a budget relock per feature, forever, which is the tell that the shape is wrong rather than the number. The distinction worth internalising: an optional FEATURE tree-shakes only when the option is an IMPORT, never when it is a PROP. A boolean prop is data, and no bundler drops a branch on data. The fix is the seam this repo already uses for the same problem elsewhere (setThemeEngine, setStyleExtraction, _setDefaultChromeLayout): the optional half registers itself at module load and the core holds a slot, so a consumer who never imports it never pays. Detection is not a test — it is bundling the minimal import and grepping for a symbol only the optional half defines; a size budget merely tells you something grew, never which thing.
A bundle assertion keyed on a GENERATED IDENTIFIER is vacuous — minification renames it.
expect(bundle).not.toContain('seedFaker') passes with the whole module bundled, because seedFaker minified to one letter. Assert on things minification cannot touch: EXTERNAL import specifiers (@faker-js/faker) and string DATA (a fixture uuid). And pair every "must be ABSENT" suite with a CONTROL that bundles the module and requires each marker PRESENT — without it the suite passes just as well when the emitter stops producing anything. The control must reach every surface at once (export *), since importing one symbol correctly shakes the rest away. Reference: lathe/src/tests/entry-points.test.ts:DEV_MARKERS.
[FIXED, 2026-09] A LITERAL expression child ({"t"}, {54}, {null}) emitted a placeholder + _setChildAt while plain JSX text baked — and that one shape was EVERY root swap in the compiled-path fuzz.
The server renders both as plain text with no range markers, so the adopt verifier found a <!> with no $ range and bailed on the whole element: 78 of 300 seeds, retention 74.6%. Baking literals like text (escaped UNCONDITIONALLY — a JS string is data, so "&" is six characters; and > too, because the template emitter never puts a raw > in text and the verifier refuses a template that does), MERGING adjacent texts into one entry (the parser makes one node and every later placeholder ref walks by child index), and baking nothing for null/boolean/undefined took retention to 98.8% with zero root swaps and deleted a runtime call per literal per mount. A numeric literal bakes only when its source IS its String() form (1.50/1e3/-1 keep the runtime path) so both backends agree by construction. Rule: anything the compiler can render at compile time exactly as the server renders it must be BAKED, not deferred to a runtime set — a deferred literal is a placeholder the verifier has to explain, and the server never marks it. Same sweep, second finding: <textarea value="0"> baked a DEAD attribute (a textarea's value is its text content), so a static textarea mounted EMPTY on the client — the PZ-09 select class, one tag over; routed to the one-time _setValue the reactive form already uses, which also makes <textarea>0</textarea> adopt. Locked by compiler/src/tests/template-literal-children-bake.test.ts + runtime-dom/src/tests/compiled-literal-children-adoption.test.tsx, both bisect-verified.