fix: no TypeErorr for default props; add strictProps

- Remove thrown error if type difference is found in defined props
- Introduce strictProps toggle for error behavior
- Extract size changes to its own log file
This commit is contained in:
ayo 2026-07-05 11:11:36 +02:00
parent 394e094693
commit 248ef14322
5 changed files with 112 additions and 21 deletions

View file

@ -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.
---

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.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).

9
size-change-log.md Normal file
View file

@ -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. |

View file

@ -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

View file

@ -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 `<h1>Hello ${this.props.myName}</h1>`
}
}
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')
})
})