feat: buffer attribute changes until end of onInit

The assumption of everything starting at `onInit` breaks when browsers
triggers `attributeChanged` first after parsing HTML. This buffers all
property initialization after `onInit` logic for more predictable
behavior
This commit is contained in:
ayo 2026-07-05 11:54:44 +02:00
parent 61d0a5adf9
commit 5503e57897
5 changed files with 101 additions and 1 deletions

View file

@ -5,4 +5,4 @@ slug: library-size
All the functions and the base class in the library are minimalist by design and only contains what is needed for their purpose. All the functions and the base class in the library are minimalist by design and only contains what is needed for their purpose.
The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.34 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit). The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.35 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).

View file

@ -110,3 +110,20 @@ onChanges({ property /* 'myName' */, attribute /* 'my-name' */, previousValue, c
If you previously read `changes.property` for the attribute name, switch to `changes.attribute`. If you previously read `changes.property` for the attribute name, switch to `changes.attribute`.
::: :::
## Upgrade ordering & the buffering guarantee
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), the browser fires `attributeChangedCallback` **before** `connectedCallback`. Taken literally, that means `render()` and `onChanges()` could run before `onInit()` — so any setup you do in `onInit` (event wiring, reading external state) would not have happened yet on that first render. Test environments like happy-dom/jsdom don't reproduce this ordering, so components can pass in tests and then misbehave in a real browser.
`WebComponent` removes this footgun. Attribute changes that arrive **before** the element is connected are buffered:
- the **prop value is applied immediately**, so `this.props` is already correct inside `onInit()`;
- the **`render()` and `onChanges()` side effects are deferred** until after `onInit()` runs.
On connect, the order is always:
1. `onInit()``this.props` already reflects any authored attributes
2. a single `render()` — reflects all buffered props in one pass
3. `afterViewInit()`
**`onChanges()` never fires before `onInit()`.** Pre-connect attribute changes are **not** replayed through `onChanges()` — the first `render()` already reflects them, so `onChanges()` is reserved for genuine post-connect changes. After the element is connected, attribute changes behave normally: each one triggers `render()` and `onChanges()` immediately.

View file

@ -9,3 +9,4 @@ A running record of how each correctness/feature change affects the `WebComponen
| 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. | | 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. | | 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). | | `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`. |

View file

@ -54,6 +54,7 @@ export class WebComponent extends HTMLElement {
#props #props
#typeMap = {} #typeMap = {}
#reflected = false #reflected = false
#connected = false
/** /**
* Blueprint for the Proxy props * Blueprint for the Proxy props
@ -139,6 +140,10 @@ export class WebComponent extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#reflectDefaults() this.#reflectDefaults()
this.onInit() this.onInit()
// Attribute-driven render/onChanges are buffered until here, so onInit
// always runs before the first render — even when the platform fires
// attributeChangedCallback before connect for authored attributes.
this.#connected = true
this.render() this.render()
this.afterViewInit() this.afterViewInit()
} }
@ -170,7 +175,11 @@ export class WebComponent extends HTMLElement {
} }
// write through the proxy; item 25 makes this log-not-throw by default // write through the proxy; item 25 makes this log-not-throw by default
// (prop value is always applied so `props` stays current)
this.props[property] = next this.props[property] = next
// defer the render/onChanges side effects until after onInit
if (!this.#connected) return
this.render() this.render()
this.onChanges({ property, attribute, previousValue, currentValue }) this.onChanges({ property, attribute, previousValue, currentValue })
} }

View file

@ -565,3 +565,76 @@ describe('onChanges property vs attribute', () => {
expect(last.currentValue).toBe('Ayo') expect(last.currentValue).toBe('Ayo')
}) })
}) })
/**
* Upgrade ordering: the platform can fire attributeChangedCallback for
* authored attributes BEFORE connectedCallback. Attribute-driven
* render()/onChanges() are buffered so onInit always runs first, while the
* prop value itself is applied immediately (props stays current in onInit).
*/
describe('attribute callback buffering (upgrade ordering)', () => {
it('runs onInit before the first render/onChanges for a pre-connect attribute', () => {
const order = []
let nameInOnInit
class Upgraded extends WebComponent {
static props = { myName: 'World' }
onInit() {
order.push('onInit')
nameInOnInit = this.props.myName
}
onChanges() {
order.push('onChanges')
}
render() {
order.push('render')
super.render()
}
get template() {
return `<h1>Hello ${this.props.myName}</h1>`
}
}
window.customElements.define('upgrade-order', Upgraded)
// approximate upgrade: set the attribute before the element connects
const el = document.createElement('upgrade-order')
el.setAttribute('my-name', 'Ayo')
// buffered: no render/onChanges have fired yet
expect(order).toEqual([])
document.body.appendChild(el)
// onInit ran before the first render; onChanges was NOT replayed
expect(order[0]).toBe('onInit')
expect(order.indexOf('onInit')).toBeLessThan(order.indexOf('render'))
expect(order).not.toContain('onChanges')
// props already reflects the authored attribute inside onInit
expect(nameInOnInit).toBe('Ayo')
expect(el.querySelector('h1').textContent).toBe('Hello Ayo')
})
it('fires render and onChanges normally for post-connect attribute changes', () => {
const order = []
class PostConnect extends WebComponent {
static props = { myName: 'World' }
onChanges() {
order.push('onChanges')
}
render() {
order.push('render')
super.render()
}
get template() {
return `<h1>Hello ${this.props.myName}</h1>`
}
}
window.customElements.define('post-connect', PostConnect)
const el = document.createElement('post-connect')
document.body.appendChild(el)
order.length = 0
el.setAttribute('my-name', 'Zoe')
expect(order).toContain('render')
expect(order).toContain('onChanges')
expect(el.querySelector('h1').textContent).toBe('Hello Zoe')
})
})