fix: no attribute writes in constructor
This commit is contained in:
parent
ec2033fe26
commit
4ffb5f0674
2 changed files with 88 additions and 24 deletions
|
|
@ -20,6 +20,7 @@ export class WebComponent extends HTMLElement {
|
|||
#prevDOM
|
||||
#props
|
||||
#typeMap = {}
|
||||
#reflected = false
|
||||
|
||||
/**
|
||||
* Blueprint for the Proxy props
|
||||
|
|
@ -96,6 +97,7 @@ export class WebComponent extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#reflectDefaults()
|
||||
this.onInit()
|
||||
this.render()
|
||||
this.afterViewInit()
|
||||
|
|
@ -158,9 +160,7 @@ export class WebComponent extends HTMLElement {
|
|||
#initializeProps() {
|
||||
let initialProps = structuredClone(this.constructor.props) ?? {}
|
||||
Object.keys(initialProps).forEach((camelCase) => {
|
||||
const value = initialProps[camelCase]
|
||||
this.#typeMap[camelCase] = typeof value
|
||||
this.setAttribute(getKebabCase(camelCase), serialize(value))
|
||||
this.#typeMap[camelCase] = typeof initialProps[camelCase]
|
||||
})
|
||||
if (!this.#props) {
|
||||
this.#props = new Proxy(
|
||||
|
|
@ -169,6 +169,21 @@ export class WebComponent extends HTMLElement {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects default prop values onto attributes on first connect.
|
||||
* Runs outside the constructor (spec forbids attribute mutation there)
|
||||
* and skips any attribute already set by markup/SSR/user so those win.
|
||||
*/
|
||||
#reflectDefaults() {
|
||||
if (this.#reflected) return
|
||||
Object.keys(this.#props).forEach((camelCase) => {
|
||||
const kebab = getKebabCase(camelCase)
|
||||
if (!this.hasAttribute(kebab))
|
||||
this.setAttribute(kebab, serialize(this.#props[camelCase]))
|
||||
})
|
||||
this.#reflected = true
|
||||
}
|
||||
#initializeHost() {
|
||||
this.#host = this
|
||||
if (this.constructor.shadowRootInit) {
|
||||
|
|
|
|||
|
|
@ -210,57 +210,62 @@ describe('shadow DOM', () => {
|
|||
* Reactive-prop contract as documented on the docs site
|
||||
* (guides/usage, guides/prop-access, guides/life-cycle-hooks).
|
||||
*
|
||||
* These are marked `it.fails` because the current implementation cannot
|
||||
* construct any component that declares `static props`: the constructor
|
||||
* reflects each default with `setAttribute`, which synchronously fires
|
||||
* `attributeChangedCallback` -> `#handleUpdateProp` before the `#props`
|
||||
* proxy is assigned, throwing a TypeError. This is the Phase-0 correctness
|
||||
* work called out at the top of the README (no attribute writes in the
|
||||
* constructor; empty-string / removed-attribute handling). When those
|
||||
* fixes land, flip these back to `it` — vitest will flag each one that
|
||||
* starts passing.
|
||||
* Default props are no longer reflected onto attributes in the constructor
|
||||
* (the custom-elements spec forbids attribute mutation there, and it fired
|
||||
* `attributeChangedCallback` before the `#props` proxy existed). Reflection
|
||||
* now happens on first connect via `#reflectDefaults()`, skipping any
|
||||
* attribute already provided by markup/SSR/user.
|
||||
*/
|
||||
describe('reactive props (documented contract, pending Phase 0 fixes)', () => {
|
||||
describe('reactive props (documented contract)', () => {
|
||||
class HelloWorld extends WebComponent {
|
||||
static props = { myName: 'World', count: 0, active: false, user: { a: 1 } }
|
||||
get template() {
|
||||
return `<h1>Hello ${this.props.myName}</h1>`
|
||||
}
|
||||
}
|
||||
// A class may only be registered under one tag, so register once and
|
||||
// create a fresh, connected instance per test.
|
||||
const tag = 'reactive-hello-world'
|
||||
window.customElements.define(tag, HelloWorld)
|
||||
const connected = () => {
|
||||
const el = document.createElement(tag)
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
it.fails('reflects static props defaults onto attributes', () => {
|
||||
const el = mount(HelloWorld)
|
||||
it('reflects static props defaults onto attributes', () => {
|
||||
const el = connected()
|
||||
expect(el.getAttribute('my-name')).toBe('World')
|
||||
})
|
||||
|
||||
it.fails('exposes camelCase props with their declared types', () => {
|
||||
const el = mount(HelloWorld)
|
||||
it('exposes camelCase props with their declared types', () => {
|
||||
const el = connected()
|
||||
expect(el.props.myName).toBe('World')
|
||||
expect(el.props.count).toBe(0)
|
||||
expect(el.props.active).toBe(false)
|
||||
expect(el.props.user).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it.fails('re-renders and updates props when an attribute changes', () => {
|
||||
const el = mount(HelloWorld)
|
||||
it('re-renders and updates props when an attribute changes', () => {
|
||||
const el = connected()
|
||||
el.setAttribute('my-name', 'Ayo')
|
||||
expect(el.props.myName).toBe('Ayo')
|
||||
expect(el.querySelector('h1').textContent).toBe('Hello Ayo')
|
||||
})
|
||||
|
||||
it.fails('reflects prop writes back to the attribute (props === setAttribute)', () => {
|
||||
const el = mount(HelloWorld)
|
||||
it('reflects prop writes back to the attribute (props === setAttribute)', () => {
|
||||
const el = connected()
|
||||
el.props.myName = 'Ayo'
|
||||
expect(el.getAttribute('my-name')).toBe('Ayo')
|
||||
})
|
||||
|
||||
it.fails('preserves the declared type when updating a number prop', () => {
|
||||
const el = mount(HelloWorld)
|
||||
it('preserves the declared type when updating a number prop', () => {
|
||||
const el = connected()
|
||||
el.setAttribute('count', '5')
|
||||
expect(el.props.count).toBe(5)
|
||||
})
|
||||
|
||||
it.fails('fires onChanges with property/previousValue/currentValue', () => {
|
||||
it('fires onChanges with property/previousValue/currentValue', () => {
|
||||
const changes = []
|
||||
class WithChanges extends WebComponent {
|
||||
static props = { myName: 'World' }
|
||||
|
|
@ -276,3 +281,47 @@ describe('reactive props (documented contract, pending Phase 0 fixes)', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Spec conformance: no attribute mutation in the constructor. Defaults are
|
||||
* reflected on connect, and pre-existing attributes (markup/SSR/user) win.
|
||||
*/
|
||||
describe('default reflection (no setAttribute in constructor)', () => {
|
||||
class Defaulted extends WebComponent {
|
||||
static props = { myName: 'World', count: 0 }
|
||||
get template() {
|
||||
return `<h1>Hello ${this.props.myName}</h1>`
|
||||
}
|
||||
}
|
||||
const tag = 'reflect-defaulted'
|
||||
window.customElements.define(tag, Defaulted)
|
||||
|
||||
it('createElement does not throw and defers defaults until connect', () => {
|
||||
const el = document.createElement(tag)
|
||||
// no attributes reflected before connect (spec-legal constructor)
|
||||
expect(el.getAttribute('my-name')).toBeNull()
|
||||
|
||||
document.body.appendChild(el)
|
||||
// defaults appear as serialized attributes only after connect
|
||||
expect(el.getAttribute('my-name')).toBe('World')
|
||||
expect(el.getAttribute('count')).toBe('0')
|
||||
})
|
||||
|
||||
it('does not overwrite a markup-provided attribute with a default', () => {
|
||||
const el = document.createElement(tag)
|
||||
el.setAttribute('my-name', 'Zoe')
|
||||
document.body.appendChild(el)
|
||||
expect(el.props.myName).toBe('Zoe')
|
||||
expect(el.getAttribute('my-name')).toBe('Zoe')
|
||||
})
|
||||
|
||||
it('does not re-clobber a prop changed while connected on re-connect', () => {
|
||||
const el = document.createElement(tag)
|
||||
document.body.appendChild(el)
|
||||
el.props.myName = 'Ayo'
|
||||
el.remove()
|
||||
document.body.appendChild(el)
|
||||
expect(el.props.myName).toBe('Ayo')
|
||||
expect(el.getAttribute('my-name')).toBe('Ayo')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue