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.
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()
- 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
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 {
// gets called when an attribute value changes
onChanges(changes) {
const { property, previousValue, currentValue } = changes
console.log('>>> ', { property, previousValue, currentValue })
const { property, attribute, previousValue, currentValue } = changes
console.log('>>> ', { property, attribute, previousValue, currentValue })
}
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. |
| 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). |

View file

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

View file

@ -265,7 +265,7 @@ describe('reactive props (documented contract)', () => {
expect(el.props.count).toBe(5)
})
it('fires onChanges with property/previousValue/currentValue', () => {
it('fires onChanges with property/attribute/previousValue/currentValue', () => {
const changes = []
class WithChanges extends WebComponent {
static props = { myName: 'World' }
@ -276,7 +276,8 @@ describe('reactive props (documented contract)', () => {
const el = mount(WithChanges)
el.setAttribute('my-name', 'Ayo')
expect(changes.at(-1)).toMatchObject({
property: 'my-name',
property: 'myName',
attribute: 'my-name',
currentValue: 'Ayo',
})
})
@ -474,7 +475,7 @@ describe('attribute value & removal handling', () => {
expect(el.props.label).toBe('')
// regression: must NOT echo back as "true"
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', () => {
@ -517,7 +518,7 @@ describe('attribute value & removal handling', () => {
// malformed value must not throw and must still fire onChanges/render
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()
})
@ -538,3 +539,29 @@ describe('attribute value & removal handling', () => {
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')
})
})