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.
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`.
:::
## 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. |
| 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`. |

View file

@ -54,6 +54,7 @@ export class WebComponent extends HTMLElement {
#props
#typeMap = {}
#reflected = false
#connected = false
/**
* Blueprint for the Proxy props
@ -139,6 +140,10 @@ export class WebComponent extends HTMLElement {
connectedCallback() {
this.#reflectDefaults()
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.afterViewInit()
}
@ -170,7 +175,11 @@ export class WebComponent extends HTMLElement {
}
// 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
// defer the render/onChanges side effects until after onInit
if (!this.#connected) return
this.render()
this.onChanges({ property, attribute, previousValue, currentValue })
}

View file

@ -565,3 +565,76 @@ describe('onChanges property vs attribute', () => {
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')
})
})