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:
parent
0e367fa2ec
commit
61d0a5adf9
5 changed files with 66 additions and 19 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.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).
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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). |
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue