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:
parent
61d0a5adf9
commit
5503e57897
5 changed files with 101 additions and 1 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`. |
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue