feat!: better distinction between props & attr

`onChanges` now introduces an `attribute` in its arguments and
repurposes the `property` to be used for this.props lookup
- `property` is now the camelCase key for `this.props`
- `attribute` is the kebab-case attribute name (i.e., what used to be
  `property` before v5)
This commit is contained in:
ayo 2026-07-05 11:43:56 +02:00
parent 0e367fa2ec
commit 61d0a5adf9
5 changed files with 66 additions and 19 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.33 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.34 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).

View file

@ -74,6 +74,12 @@ class ClickableText extends WebComponent {
### onChanges() ### onChanges()
- Triggered when an attribute value changed - Triggered when an attribute value changed
- The `changes` object cleanly separates the **property** from the **attribute**:
- `property` — the **camelCase** prop key, matching how you access `props` (e.g. `myName`)
- `attribute` — the **kebab-case** attribute name that changed (e.g. `my-name`)
- `previousValue` / `currentValue` — the values before and after the change
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
```js ```js
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js' import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
@ -81,8 +87,8 @@ import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.
class ClickableText extends WebComponent { class ClickableText extends WebComponent {
// gets called when an attribute value changes // gets called when an attribute value changes
onChanges(changes) { onChanges(changes) {
const { property, previousValue, currentValue } = changes const { property, attribute, previousValue, currentValue } = changes
console.log('>>> ', { property, previousValue, currentValue }) console.log('>>> ', { property, attribute, previousValue, currentValue })
} }
get template() { get template() {
@ -90,3 +96,17 @@ class ClickableText extends WebComponent {
} }
} }
``` ```
:::caution[Breaking change]
The `onChanges` payload now draws a clear **attribute vs. property** distinction. Previously `property` held the kebab-case _attribute_ name. It now holds the camelCase _prop_ key (matching `props` access), and the kebab-case attribute name moved to the new `attribute` field.
```js
// before
onChanges({ property /* 'my-name' */, previousValue, currentValue }) {}
// after
onChanges({ property /* 'myName' */, attribute /* 'my-name' */, previousValue, currentValue }) {}
```
If you previously read `changes.property` for the attribute name, switch to `changes.attribute`.
:::

View file

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

View file

@ -113,12 +113,11 @@ export class WebComponent extends HTMLElement {
/** /**
* Triggered when an attribute value changes * Triggered when an attribute value changes
* @typedef {{ * @typedef {object} Changes
* property: string, * @property {string} property camelCase prop key, matching `props` access
* name: string, * @property {string} attribute kebab-case attribute name that changed
* previousValue: any, * @property {any} previousValue value before the change
* currentValue: any * @property {any} currentValue value after the change
* }} Changes
* @param {Changes} changes * @param {Changes} changes
*/ */
onChanges(changes) {} onChanges(changes) {}
@ -148,15 +147,15 @@ export class WebComponent extends HTMLElement {
this.onDestroy() this.onDestroy()
} }
attributeChangedCallback(property, previousValue, currentValue) { attributeChangedCallback(attribute, previousValue, currentValue) {
if (previousValue === currentValue) return if (previousValue === currentValue) return
const key = getCamelCase(property) const property = getCamelCase(attribute)
const type = this.#typeMap[key] const type = this.#typeMap[property]
let next let next
if (currentValue === null) { if (currentValue === null) {
// removal resets to the declared default (or undefined if none) // removal resets to the declared default (or undefined if none)
next = this.constructor.props?.[key] next = this.constructor.props?.[property]
} else if (type && type !== 'string') { } else if (type && type !== 'string') {
// typed props deserialize; a malformed value falls back to the raw // typed props deserialize; a malformed value falls back to the raw
// string so render()/onChanges() are never skipped // string so render()/onChanges() are never skipped
@ -171,9 +170,9 @@ 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
this.props[key] = next this.props[property] = next
this.render() this.render()
this.onChanges({ property, name: key, previousValue, currentValue }) this.onChanges({ property, attribute, previousValue, currentValue })
} }
#handler(setter, meta) { #handler(setter, meta) {

View file

@ -265,7 +265,7 @@ describe('reactive props (documented contract)', () => {
expect(el.props.count).toBe(5) expect(el.props.count).toBe(5)
}) })
it('fires onChanges with property/previousValue/currentValue', () => { it('fires onChanges with property/attribute/previousValue/currentValue', () => {
const changes = [] const changes = []
class WithChanges extends WebComponent { class WithChanges extends WebComponent {
static props = { myName: 'World' } static props = { myName: 'World' }
@ -276,7 +276,8 @@ describe('reactive props (documented contract)', () => {
const el = mount(WithChanges) const el = mount(WithChanges)
el.setAttribute('my-name', 'Ayo') el.setAttribute('my-name', 'Ayo')
expect(changes.at(-1)).toMatchObject({ expect(changes.at(-1)).toMatchObject({
property: 'my-name', property: 'myName',
attribute: 'my-name',
currentValue: 'Ayo', currentValue: 'Ayo',
}) })
}) })
@ -474,7 +475,7 @@ describe('attribute value & removal handling', () => {
expect(el.props.label).toBe('') expect(el.props.label).toBe('')
// regression: must NOT echo back as "true" // regression: must NOT echo back as "true"
expect(el.getAttribute('label')).toBe('') expect(el.getAttribute('label')).toBe('')
expect(changes.at(-1)).toMatchObject({ name: 'label', currentValue: '' }) expect(changes.at(-1)).toMatchObject({ property: 'label', currentValue: '' })
}) })
it('resets a prop to its static default when the attribute is removed', () => { it('resets a prop to its static default when the attribute is removed', () => {
@ -517,7 +518,7 @@ describe('attribute value & removal handling', () => {
// malformed value must not throw and must still fire onChanges/render // malformed value must not throw and must still fire onChanges/render
expect(() => el.setAttribute('count', 'abc')).not.toThrow() expect(() => el.setAttribute('count', 'abc')).not.toThrow()
expect(changes.at(-1)).toMatchObject({ name: 'count', currentValue: 'abc' }) expect(changes.at(-1)).toMatchObject({ property: 'count', currentValue: 'abc' })
error.mockRestore() error.mockRestore()
}) })
@ -538,3 +539,29 @@ describe('attribute value & removal handling', () => {
expect(el.getAttribute('title')).toBe('changed') expect(el.getAttribute('title')).toBe('changed')
}) })
}) })
/**
* onChanges distinguishes `property` (camelCase prop key, matching `props`
* access) from `attribute` (kebab-case attribute name), so handlers can index
* `props` without re-deriving the key.
*/
describe('onChanges property vs attribute', () => {
it('exposes camelCase property and kebab attribute for a multi-word prop', () => {
const changes = []
class MultiWord extends WebComponent {
static props = { myName: 'World' }
onChanges(c) {
changes.push(c)
}
}
window.customElements.define('changes-multiword', MultiWord)
const el = document.createElement('changes-multiword')
document.body.appendChild(el)
el.setAttribute('my-name', 'Ayo')
const last = changes.at(-1)
expect(last.property).toBe('myName') // camelCase prop key
expect(last.attribute).toBe('my-name') // kebab attribute name
expect(last.currentValue).toBe('Ayo')
})
})