diff --git a/README.md b/README.md index 607052c..efe5470 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,7 @@ There are many ways to get in touch: ## Size change log -A running record of how each correctness/feature change affects the `WebComponent` base class bundle, measured as min + brotli via [size-limit](https://github.com/ai/size-limit). Baseline before the Phase 0 correctness work: **1.19 kB** (limit 1.2 kB). - -| Change | Size (min+brotli) | Limit | Reason & benefit | -| ---------------------------------------------------------------- | ------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Move default-attribute reflection out of the constructor | ~1.19 kB (≈0) | 1.2 kB | **Custom Elements Spec compliance.** Constructors may not mutate attributes, so `document.createElement` on a class with `static props` threw `NotSupportedError` in real browsers. Defaults now reflect on first connect, and markup/SSR-provided attributes win. Pure code movement — no net size cost. | -| 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. | +See [`size-change-log.md`](./size-change-log.md) for a running record of how each correctness/feature change affects the `WebComponent` base class bundle size, with the reason and benefit of each. --- diff --git a/docs/src/content/docs/guides/library-size.md b/docs/src/content/docs/guides/library-size.md index aa03807..957c2fc 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.31 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.33 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit). diff --git a/size-change-log.md b/size-change-log.md new file mode 100644 index 0000000..a3ebc8e --- /dev/null +++ b/size-change-log.md @@ -0,0 +1,9 @@ +# Size change log + +A running record of how each correctness/feature change affects the `WebComponent` base class bundle, measured as min + brotli via [size-limit](https://github.com/ai/size-limit). Baseline before the Phase 0 correctness work: **1.19 kB** (limit 1.2 kB). + +| Change | Size (min+brotli) | Limit | Reason & benefit | +| ---------------------------------------------------------------- | ------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Move default-attribute reflection out of the constructor | ~1.19 kB (≈0) | 1.2 kB | **Custom Elements Spec compliance.** Constructors may not mutate attributes, so `document.createElement` on a class with `static props` threw `NotSupportedError` in real browsers. Defaults now reflect on first connect, and markup/SSR-provided attributes win. Pure code movement — no net size cost. | +| 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. | diff --git a/src/WebComponent.js b/src/WebComponent.js index 88bdc39..77affd3 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -80,6 +80,13 @@ export class WebComponent extends HTMLElement { */ static shadowRootInit + /** + * When `true`, a declared-type violation on a prop write throws a + * `TypeError` instead of logging via `console.error` and skipping. + * @type {boolean} + */ + static strictProps + /** * Read-only property containing camelCase counterparts of observed attributes. * @see https://webcomponent.io/prop-access/ @@ -164,22 +171,19 @@ export class WebComponent extends HTMLElement { return { set(obj, prop, value) { - const oldValue = obj[prop] + // Types come from `static props` defaults only; undeclared props are + // untyped. A declared-type violation is logged and skipped rather + // than thrown, so a stray write can't halt render()/onChanges(). + // Opt into `static strictProps = true` to restore throwing. + const declared = typeMap[prop] - if (!(prop in typeMap)) { - typeMap[prop] = typeof value - } - - if (typeMap[prop] !== typeof value) { - throw TypeError( - `Cannot assign ${typeof value} to ${ - typeMap[prop] - } property (setting '${prop}' of ${meta.constructor.name})` - ) - } else if (oldValue !== value) { + if (declared && declared !== typeof value && value != null) { + const msg = `${meta.constructor.name}: cannot assign ${typeof value} to ${declared} prop "${prop}"` + if (meta.constructor.strictProps) throw TypeError(msg) + console.error(msg) + } else if (obj[prop] !== value) { obj[prop] = value - const kebab = getKebabCase(prop) - setter(kebab, serialize(value)) + setter(getKebabCase(prop), serialize(value)) } return true diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index c6e2260..112d740 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -366,3 +366,86 @@ describe('safe prop cloning + define-time validation', () => { expect(b.props.list).toEqual([1, 2]) }) }) + +/** + * Prop types are derived from `static props` defaults only. A declared-type + * violation is logged and skipped (never thrown), so a stray write can't + * escape `attributeChangedCallback` and silently halt rendering. Undeclared + * props are untyped and never lock a type on first write. + */ +describe('prop type handling (derive from defaults, log not throw)', () => { + it('logs and skips a declared-type violation without throwing', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + class Typed extends WebComponent { + static props = { label: 'hi' } + } + window.customElements.define('typed-label', Typed) + const el = document.createElement('typed-label') + document.body.appendChild(el) + + expect(() => { + el.props.label = 5 + }).not.toThrow() + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0][0]).toContain('label') + // value unchanged after the rejected write + expect(el.props.label).toBe('hi') + + // a subsequent same-type write still succeeds and reflects the attribute + el.props.label = 'ok' + expect(el.props.label).toBe('ok') + expect(el.getAttribute('label')).toBe('ok') + + error.mockRestore() + }) + + it('does not lock or throw on an undeclared prop written with mixed types', () => { + class Untyped extends WebComponent { + static props = { name: 'x' } + } + window.customElements.define('untyped-extra', Untyped) + const el = document.createElement('untyped-extra') + document.body.appendChild(el) + + expect(() => { + el.props.extra = 'first' + el.props.extra = 2 + el.props.extra = true + }).not.toThrow() + expect(el.props.extra).toBe(true) + }) + + it('throws on a declared-type violation when static strictProps is true', () => { + class Strict extends WebComponent { + static props = { label: 'hi' } + static strictProps = true + } + window.customElements.define('strict-label', Strict) + const el = document.createElement('strict-label') + document.body.appendChild(el) + + expect(() => { + el.props.label = 5 + }).toThrow(TypeError) + // value stays intact after the rejected write + expect(el.props.label).toBe('hi') + }) + + it('renders through the previously-poisoning empty-then-string sequence', () => { + class Poisonable extends WebComponent { + static props = { myName: 'World' } + get template() { + return `

Hello ${this.props.myName}

` + } + } + window.customElements.define('poison-seq', Poisonable) + const el = document.createElement('poison-seq') + document.body.appendChild(el) + + expect(() => { + el.setAttribute('my-name', '') + el.setAttribute('my-name', 'Ayo') + }).not.toThrow() + expect(el.querySelector('h1').textContent).toBe('Hello Ayo') + }) +})