From 61d0a5adf9f24fe20a4602e7220df6d1e57f831d Mon Sep 17 00:00:00 2001 From: Ayo Date: Sun, 5 Jul 2026 11:43:56 +0200 Subject: [PATCH] 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) --- docs/src/content/docs/guides/library-size.md | 2 +- .../content/docs/guides/life-cycle-hooks.md | 24 +++++++++++-- size-change-log.md | 1 + src/WebComponent.js | 23 ++++++------ test/WebComponent.test.mjs | 35 ++++++++++++++++--- 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/docs/src/content/docs/guides/library-size.md b/docs/src/content/docs/guides/library-size.md index 957c2fc..9c2736e 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.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). diff --git a/docs/src/content/docs/guides/life-cycle-hooks.md b/docs/src/content/docs/guides/life-cycle-hooks.md index bf95ebb..1c03247 100644 --- a/docs/src/content/docs/guides/life-cycle-hooks.md +++ b/docs/src/content/docs/guides/life-cycle-hooks.md @@ -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`. +::: diff --git a/size-change-log.md b/size-change-log.md index 27da8df..d1e5bed 100644 --- a/size-change-log.md +++ b/size-change-log.md @@ -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). | diff --git a/src/WebComponent.js b/src/WebComponent.js index 257af5c..3088cdc 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -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) { diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index d205a7c..7b665cb 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -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') + }) +})