diff --git a/docs/src/content/docs/guides/library-size.md b/docs/src/content/docs/guides/library-size.md index 9c2736e..67a8562 100644 --- a/docs/src/content/docs/guides/library-size.md +++ b/docs/src/content/docs/guides/library-size.md @@ -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). diff --git a/docs/src/content/docs/guides/life-cycle-hooks.md b/docs/src/content/docs/guides/life-cycle-hooks.md index 1c03247..36f03d8 100644 --- a/docs/src/content/docs/guides/life-cycle-hooks.md +++ b/docs/src/content/docs/guides/life-cycle-hooks.md @@ -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. ``), 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. diff --git a/size-change-log.md b/size-change-log.md index d1e5bed..a8d4564 100644 --- a/size-change-log.md +++ b/size-change-log.md @@ -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`. | diff --git a/src/WebComponent.js b/src/WebComponent.js index 3088cdc..6d968d4 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -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 }) } diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index 7b665cb..378886e 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -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 `

Hello ${this.props.myName}

` + } + } + 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 `

Hello ${this.props.myName}

` + } + } + 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') + }) +})