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:
parent
394e094693
commit
248ef14322
5 changed files with 112 additions and 21 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
9
size-change-log.md
Normal 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. |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue