From 9d5f7249a5be03a79ed7a32398fc9ac388b04b67 Mon Sep 17 00:00:00 2001 From: Cristian Necula Date: Mon, 16 Feb 2026 00:14:24 +0000 Subject: [PATCH 1/2] feat: add reflect option to useProperty for property-attribute reflection From 7004a533e8bb3eed0e91f30533ace64d2de74bc1 Mon Sep 17 00:00:00 2001 From: Cristian Necula Date: Mon, 16 Feb 2026 00:36:57 +0000 Subject: [PATCH 2/2] feat: add reflect option to useProperty for property-attribute reflection - Add reflectingSymbol to symbols.ts for loop guard - Modify attributeChangedCallback in component.ts to check reflectingSymbol guard and convert null (attribute removal) to false - Extend useProperty with optional { reflect: true } third argument that enables bidirectional property-attribute sync - Add 9 new tests for reflect behavior (boolean/string reflection, loop prevention, attribute-to-property sync, initial attribute, preventDefault, backward compat, lift integration) - Add 1 new test for attribute removal producing false --- src/component.ts | 5 + src/symbols.ts | 3 + src/use-property.ts | 63 ++++++++- test/attrs.test.ts | 31 ++++ test/use-property.test.ts | 288 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 4 deletions(-) diff --git a/src/component.ts b/src/component.ts index ba3b75b..4306f5f 100644 --- a/src/component.ts +++ b/src/component.ts @@ -1,5 +1,6 @@ import { GenericRenderer, RenderFunction, RenderResult } from "./core"; import { BaseScheduler } from "./scheduler"; +import { reflectingSymbol } from "./symbols"; import { sheets } from "./util"; const toCamelCase = (val = ""): string => @@ -143,7 +144,11 @@ function makeComponent(render: RenderFunction): Creator { if (oldValue === newValue) { return; } + if ((this as any)[reflectingSymbol]) { + return; + } let val = newValue === "" ? true : newValue; + if (val === null) val = false; Reflect.set(this, toCamelCase(name), val); } } diff --git a/src/symbols.ts b/src/symbols.ts index ef1526c..9fed694 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -9,6 +9,8 @@ const layoutEffectsSymbol = Symbol("haunted.layoutEffects"); type EffectsSymbols = typeof effectsSymbol | typeof layoutEffectsSymbol; type Phase = typeof updateSymbol | typeof commitSymbol | typeof effectsSymbol; +const reflectingSymbol = Symbol("haunted.reflecting"); + const contextEvent = "haunted.context"; export { @@ -18,6 +20,7 @@ export { commitSymbol, effectsSymbol, layoutEffectsSymbol, + reflectingSymbol, contextEvent, Phase, EffectsSymbols, diff --git a/src/use-property.ts b/src/use-property.ts index e94c730..4867e8d 100644 --- a/src/use-property.ts +++ b/src/use-property.ts @@ -1,5 +1,6 @@ import { hook, Hook } from "./hook"; import { State } from "./state"; +import { reflectingSymbol } from "./symbols"; import type { InitialState, NewState, @@ -7,29 +8,44 @@ import type { StateTuple, } from "./use-state"; -type Host = Element & { [key: string]: T }; +type Host = Element & { [key: string]: T } & { + [reflectingSymbol]?: boolean; +}; type ChangeEvent = { value: T; path: string; }; +export interface UsePropertyOptions { + reflect?: boolean; +} + export interface UseProperty { (property: string): StateTuple; (property: string, value?: InitialState): StateTuple; + ( + property: string, + value: InitialState, + options: UsePropertyOptions + ): StateTuple; } const UPPER = /([A-Z])/gu; export const useProperty = hook( - class extends Hook<[string, T], StateTuple, Host> { + class extends Hook<[string, T, UsePropertyOptions?], StateTuple, Host> { property: string; eventName: string; + attrName: string; + reflect: boolean; + lastReflected: T | undefined; constructor( id: number, state: State>, property: string, - initialValue: InitialState + initialValue: InitialState, + options?: UsePropertyOptions ) { super(id, state); @@ -39,8 +55,20 @@ export const useProperty = hook( this.updater = this.updater.bind(this); this.property = property; + this.reflect = options?.reflect ?? false; this.eventName = property.replace(UPPER, "-$1").toLowerCase() + "-changed"; + this.attrName = property.replace(UPPER, "-$1").toLowerCase(); + + // If reflecting, read initial value from attribute if present + if (this.reflect && this.state.host.hasAttribute(this.attrName)) { + const attrVal = this.state.host.getAttribute(this.attrName); + const coerced = (attrVal === "" ? true : attrVal) as T; + if (this.state.host[this.property] == null) { + this.updateProp(coerced); + return; + } + } // set the initial value only if it was not already set by the parent if (this.state.host[this.property] != null) return; @@ -55,7 +83,13 @@ export const useProperty = hook( this.updateProp(initialValue); } - update(ignored: string, ignored2: T): StateTuple { + update(ignored: string, ignored2: T, ignored3?: UsePropertyOptions): StateTuple { + if (this.reflect) { + const currentValue = this.state.host[this.property]; + if (!Object.is(currentValue, this.lastReflected)) { + this.reflectToAttribute(currentValue); + } + } return [this.state.host[this.property], this.updater]; } @@ -78,6 +112,27 @@ export const useProperty = hook( const ev = this.notify(value); if (ev.defaultPrevented) return; this.state.host[this.property] = value; + + if (this.reflect) { + this.reflectToAttribute(value); + } + } + + reflectToAttribute(value: T): void { + const host = this.state.host; + this.lastReflected = value; + host[reflectingSymbol] = true; + try { + if (value == null || value === false) { + host.removeAttribute(this.attrName); + } else if (value === true) { + host.setAttribute(this.attrName, ""); + } else { + host.setAttribute(this.attrName, String(value)); + } + } finally { + host[reflectingSymbol] = false; + } } notify(value: T) { diff --git a/test/attrs.test.ts b/test/attrs.test.ts index 5d8dd52..61719c7 100644 --- a/test/attrs.test.ts +++ b/test/attrs.test.ts @@ -178,6 +178,37 @@ describe("Observed attributes", () => { expect(div.textContent).to.equal("test test"); }); + it("Attribute removal sets property to false", async () => { + const tag = "attrs-removal-test"; + let val: unknown; + + interface Props { + open: boolean; + } + + function Drawer({ open }) { + val = open; + return html`Test`; + } + + customElements.define( + tag, + component(Drawer, { observedAttributes: ["open"] }) + ); + + const el = (await fixture( + html`` + )) as HTMLElement & Props; + + expect(el.open).to.be.true; + expect(val).to.be.true; + + el.removeAttribute("open"); + await nextFrame(); + expect(el.open).to.equal(false); + expect(val).to.equal(false); + }); + it("observed attribute names turn into camelCase props", async () => { const tag = "attrs-camelcase-test"; let el; diff --git a/test/use-property.test.ts b/test/use-property.test.ts index 7d37273..d6f3e99 100644 --- a/test/use-property.test.ts +++ b/test/use-property.test.ts @@ -248,4 +248,292 @@ describe("useProperty", () => { render(vApp(), el); expect(spy).to.not.have.been.calledOnce; }); + + describe("reflect option", () => { + it("reflects boolean property to attribute", async () => { + const tag = "use-property-reflect-bool"; + let setter: (v: any) => void; + + function App() { + let [opened, setOpened] = useProperty("opened", false, { + reflect: true, + }); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }) + ); + + const el = await fixture( + html`` + ); + + // Initial value false — attribute should not be present + expect(el.hasAttribute("opened")).to.be.false; + + setter!(true); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.true; + expect(el.getAttribute("opened")).to.equal(""); + + setter!(false); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.false; + }); + + it("reflects string property to attribute", async () => { + const tag = "use-property-reflect-str"; + let setter: (v: any) => void; + + function App() { + let [name, setName] = useProperty("name", "", { + reflect: true, + }); + setter = setName; + return html`${name}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["name"] }) + ); + + const el = await fixture( + html`` + ); + + setter!("hello"); + await nextFrame(); + expect(el.getAttribute("name")).to.equal("hello"); + + setter!("world"); + await nextFrame(); + expect(el.getAttribute("name")).to.equal("world"); + }); + + it("does not cause feedback loop", async () => { + const tag = "use-property-reflect-no-loop"; + let renderCount = 0; + let setter: (v: any) => void; + + function App() { + renderCount++; + let [opened, setOpened] = useProperty("opened", false, { + reflect: true, + }); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }) + ); + + await fixture( + html`` + ); + + const countBefore = renderCount; + setter!(true); + await nextFrame(); + // Should only render once more after the setter, not loop + expect(renderCount).to.equal(countBefore + 1); + }); + + it("syncs attribute change to property", async () => { + const tag = "use-property-reflect-attr-to-prop"; + let setter: (v: any) => void; + + function App() { + let [opened, setOpened] = useProperty("opened", false, { + reflect: true, + }); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }) + ); + + const el = await fixture( + html`` + ); + + const span = el.shadowRoot?.firstElementChild; + expect(span?.textContent).to.equal("false"); + + // Set attribute externally — should sync to property + el.setAttribute("opened", ""); + await nextFrame(); + expect(span?.textContent).to.equal("true"); + expect((el as any).opened).to.be.true; + }); + + it("syncs attribute removal to property as false", async () => { + const tag = "use-property-reflect-attr-remove"; + let setter: (v: any) => void; + + function App() { + let [opened, setOpened] = useProperty("opened", false, { + reflect: true, + }); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }) + ); + + const el = await fixture( + html`` + ); + + // Open it first + setter!(true); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.true; + expect((el as any).opened).to.be.true; + + // Remove attribute externally + el.removeAttribute("opened"); + await nextFrame(); + expect((el as any).opened).to.be.false; + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("false"); + }); + + it("honors initial attribute value", async () => { + const tag = "use-property-reflect-initial-attr"; + + function App() { + let [opened] = useProperty("opened", false, { + reflect: true, + }); + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }) + ); + + const el = await fixture( + html`` + ); + + expect((el as any).opened).to.be.true; + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("true"); + }); + + it("does not reflect when event is prevented", async () => { + const tag = "use-property-reflect-prevent"; + let setter: (v: any) => void; + + function App() { + let [opened, setOpened] = useProperty("opened", false, { + reflect: true, + }); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }) + ); + + const el = await fixture( + html` ev.preventDefault()} + >` + ); + + setter!(true); + await nextFrame(); + // Event was prevented — property and attribute should NOT update + expect((el as any).opened).to.not.be.true; + expect(el.hasAttribute("opened")).to.be.false; + }); + + it("is backward compatible without reflect option", async () => { + const tag = "use-property-reflect-compat"; + let setter: (v: any) => void; + + function App() { + let [age, setAge] = useProperty("age", 5); + setter = setAge; + return html`${age}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html`` + ); + + // Without reflect, no attribute should be set + expect(el.hasAttribute("age")).to.be.false; + + setter!(10); + await nextFrame(); + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("10"); + expect(el.hasAttribute("age")).to.be.false; + }); + + it("works with lift", async () => { + const tag = "use-property-reflect-lift-parent"; + const childTag = "use-property-reflect-lift-child"; + let parentSetter: (v: any) => void; + let childSetter: (v: any) => void; + + function Parent() { + let [opened, setOpened] = useState(false); + parentSetter = setOpened; + return html` setOpened(v))} + >`; + } + + function Child() { + let [opened, setOpened] = useProperty("opened", false, { + reflect: true, + }); + childSetter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(Parent)); + customElements.define( + childTag, + component(Child, { observedAttributes: ["opened"] }) + ); + + const el = await fixture( + html`` + ); + + const child = el.shadowRoot?.firstElementChild as any; + expect(child?.opened).to.be.false; + + // Parent sets opened + parentSetter!(true); + await nextFrame(); + expect(child?.opened).to.be.true; + expect(child?.hasAttribute("opened")).to.be.true; + + // Child requests change — parent lifts it + childSetter!(false); + await nextFrame(); + expect(child?.opened).to.be.false; + expect(child?.hasAttribute("opened")).to.be.false; + }); + }); });