wcb/size-change-log.md
ayo 20e31e4796
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run
fix: nested components no longer wipe each other on re-render (#59)
2026-07-20 23:46:14 +02:00

9.6 KiB

Size change log

A running record of how each correctness/feature change affects the WebComponent base class bundle, measured as min + brotli via size-limit. Baseline before the Phase 0 correctness work: 1.19 kB (limit 1.2 kB).

Change Size (min+brotli) Limit Reason & benefit
Move default-attribute reflection out of the constructor ~1.19 kB (≈0) 1.2 kB Custom Elements Spec compliance. Constructors may not mutate attributes, so document.createElement on a class with static props threw NotSupportedError in real browsers. Defaults now reflect on first connect, and markup/SSR-provided attributes win. Pure code movement — no net size cost.
Safe prop cloning (functions/instances) + define-time validation 1.31 kB (+0.12 kB) 1.2 → 1.35 kB No more DataCloneError. structuredClone of a function/class-instance default crashed construction. Defaults are now cloned per key (plain data deep-copied so instances don't share object/array state; non-cloneable values kept by reference), and a one-time warning names any default whose type can't reflect to an attribute. Limit raised to fit.
Derive prop types from defaults only; log type violations instead of throwing (+ static strictProps opt-in) 1.33 kB (+0.02 kB) 1.35 kB Loud, not fatal. The proxy setter locked a prop's type on first write and threw on mismatch; inside attributeChangedCallback that TypeError vanished into window.onerror and silently skipped rendering. Types are now taken from static props defaults only (undeclared props stay untyped), and a declared-type violation is logged via console.error and skipped so render()/onChanges() still run. Teams wanting hard enforcement can set static strictProps = true to restore throwing.
Correct empty-string / removed attribute handling; drop native-setter writes 1.33 kB (≈0) 1.35 kB State no longer corrupts on common attribute changes. attr="" coerced to boolean true (echoed as attr="true"); removeAttribute wrote null into the proxy and threw before render()/onChanges(); deserialize('', 'number') threw. attributeChangedCallback now keeps strings as-is ('' stays ''), resets removed attributes to the declared default, wraps deserialize so a malformed value falls back to the raw string instead of skipping render, and no longer writes vestigial this[prop] fields that clobbered native setters like title/id/lang. Logic swap — no net size cost.
onChanges gains a clear attribute-vs-property distinction (breaking) 1.34 kB (+0.01 kB) 1.35 kB Self-documenting change payload. onChanges previously passed only property, holding the kebab-case attribute name — confusing in a library where props are camelCase. property now holds the camelCase prop key (matching this.props access) and the kebab attribute name moves to a new attribute field. Handlers can read this.props[property] directly without re-deriving the key. Breaking: code reading changes.property for the attribute name must switch to changes.attribute (see the migration note on the life-cycle-hooks docs page).
Buffer attribute-driven render/onChanges until after onInit 1.35 kB (+0.01 kB) 1.35 kB onInit is guaranteed to run first. Per spec, authored attributes fire attributeChangedCallback (→ render/onChanges) before connectedCallback, so components that assume onInit already ran break in real browsers (happy-dom hides this). A #connected flag now buffers the render/onChanges side effects until after onInit, while the prop value is still applied immediately so props is correct inside onInit. Pre-connect changes are reflected by the first render and not replayed through onChanges.
Reconcile render() in place instead of replaceChildren (WCB-REQ-06) 1.81 kB (+0.43 kB) 1.4 → 1.85 kB Re-renders no longer destroy DOM state. The vnode path rebuilt the whole subtree and swapped it on every prop change, so focus, caret/selection, an uncommitted <input> value, :hover and running CSS transitions were wiped on the component's own rendered nodes — text fields lost focus mid-typing and a segmented control lost keyboard focus on select. Re-renders now reconcile the existing DOM against the new vnode tree in place (src/utils/patch.mjs: index-based, non-keyed — reuse same-tag elements, patch prop/attr adds, changes and removals by the same rule createElement applies, recurse children, trim extras). The first render still builds via createElement, the JSON.stringify no-op skip is unchanged, and html's vnode shape and string templates are untouched. Reusing an element also means a style rule that is dropped or goes falsy ({ fontStyle: condition && 'italic' }) must now be cleared explicitly — a rebuild used to do that implicitly. The reconciler lives in its own zero-dep module, but size-limit measures each entry with its dependencies, so the core budget had to move regardless; no runtime dependency was added.
Reflect boolean props as bare attributes (WCB-REQ-07, breaking) 1.93 kB (+0.12 kB) 1.85 → 1.95 kB toggleAttribute() and [attr] CSS selectors work again. Booleans reflected as the strings flag="true"/flag="false", so the attribute always existed: el.toggleAttribute('flag', true) was a silent no-op (it only adds/removes, never rewrites a value — no attributeChangedCallback, no re-render), and presence selectors like :host([flag]) matched the stamped flag="false" too, painting "on" styles onto elements whose prop was false. Boolean props now follow HTML in both directions: reflecting true sets the bare attribute, false removes it, and attribute removal means false rather than the declared default. Reads are strict — any present value is true, including the literal flag="false", exactly like native disabled="false". Breaking: setAttribute(name, String(bool)) now always means true; migrate those call sites to toggleAttribute(name, bool). Two dev warnings carry most of the cost and make the migration safe: one when a boolean attribute is written as a literal "true"/"false" string (the silent inversion), and one once-per-class when a boolean prop declares a true default (HTML has no true-default boolean attribute — absence must mean both "false" and "default"). applyProp also stops stamping "false" for boolean vnode props with no matching DOM property, which a nested WebComponent would otherwise read back as true. Limit raised to fit the warnings.
Overrideable toAttribute / fromAttribute converters (#56) 1.96 kB (+0.03 kB) 1.95 → 2 kB An escape hatch for the edge cases strict boolean semantics create. The reflection and parsing rules were hardcoded, so a prop needing custom serialization (a Date, a delimited list, an enumerated "true"/"false" attribute) had no seam to hook into. Two overrideable methods now own both directions — toAttribute(name, value) returns the attribute value, where null removes the attribute, and fromAttribute(name, value) parses a present attribute back into a prop value. Both take the camelCase prop key, matching static props and onChanges's property. The existing behavior became the default implementations (pure code movement — boolean presence/absence is now simply toAttribute returning '' or null), so subclasses customize one prop and delegate the rest to super. Cheaper than a per-prop converter map, which would cost a lookup on every prop even for the vast majority of components that never need one. Limit raised for the two non-manglable public method names.
Don't reconcile a nested component's own light DOM 2.01 kB (+0.05 kB) 2 → 2.05 kB Nested components stop erasing each other. The in-place reconciler recursed into every same-tag element it reused, including nested custom elements. A parent's vnode never describes what a nested light-DOM component rendered for itself, so the trailing-node trim in patchChildren deleted that content on every ancestor re-render — and since the child only re-renders when its own props change, it stayed blank. Any WebComponent that renders another WebComponent (without shadow DOM) lost the inner subtree the first time the outer one re-rendered, even for an unrelated prop. patchNode now treats a custom element that has no open shadow root as opaque: it still patches the element's props (that is how data flows down) but leaves the children to the child. Shadow-DOM components are exempt — their own content lives in the shadow root, invisible to the parent, so any light children are genuine parent-authored slot content that must still be reconciled. Limit raised 50 B to fit the one-line guard.