diff --git a/src/WebComponent.js b/src/WebComponent.js index a075930..a9e64d9 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -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) { diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index 5eb56e0..70d8ff5 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -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 `

Hello ${this.props.myName}

` } } + // 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 `

Hello ${this.props.myName}

` + } + } + 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') + }) +})