From 7aa2d3b406d75b8b4ea4fea477fd6006fa470a6d Mon Sep 17 00:00:00 2001 From: Cristian Necula Date: Mon, 16 Feb 2026 06:52:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20useAttribute=20hook=20for=20boole?= =?UTF-8?q?an=20property=20=E2=86=94=20attribute=20reflection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a standalone useAttribute hook that provides bidirectional boolean property ↔ attribute sync with built-in loop prevention. API: const [opened, setOpened] = useAttribute('opened'); - Always reflects: true → setAttribute('', ''), false → removeAttribute - Dispatches cancelable *-changed events (same convention as useProperty) - Supports function updater: setter(prev => !prev) - Supports lift() pattern for parent-controlled state - Reads initial attribute value from host element - Respects parent-provided .prop= bindings - Throws when used in virtual components - Handles kebab-case ↔ camelCase conversion Also adds reflectingSymbol guard + null→false coercion to attributeChangedCallback in component.ts to prevent feedback loops when the hook reflects property changes back to attributes. Closes NEO-1055 --- src/component.ts | 5 + src/core.ts | 1 + src/symbols.ts | 3 + src/use-attribute.ts | 134 ++++++++++++ test/use-attribute.test.ts | 425 +++++++++++++++++++++++++++++++++++++ 5 files changed, 568 insertions(+) create mode 100644 src/use-attribute.ts create mode 100644 test/use-attribute.test.ts 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/core.ts b/src/core.ts index 1a65cbb..d5f99f4 100644 --- a/src/core.ts +++ b/src/core.ts @@ -51,6 +51,7 @@ export { useReducer } from "./use-reducer"; export { useMemo } from "./use-memo"; export { useContext } from "./use-context"; export { useProperty, lift } from "./use-property"; +export { useAttribute } from "./use-attribute"; export { useRef } from "./use-ref"; export { useHost } from "./use-host"; export { hook, Hook } from "./hook"; 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-attribute.ts b/src/use-attribute.ts new file mode 100644 index 0000000..be692bf --- /dev/null +++ b/src/use-attribute.ts @@ -0,0 +1,134 @@ +import { hook, Hook } from "./hook"; +import { State } from "./state"; +import { reflectingSymbol } from "./symbols"; +import type { NewState, StateUpdater, StateTuple } from "./use-state"; + +type Host = Element & { [key: string]: any } & { + [reflectingSymbol]?: boolean; +}; +type ChangeEvent = { + value: boolean; + path: string; +}; + +export interface UseAttribute { + (attribute: string): StateTuple; +} + +const toCamelCase = (val = ""): string => + val.replace(/-+([a-z])?/g, (_, char: string) => + char ? char.toUpperCase() : "" + ); + +const UPPER = /([A-Z])/gu; +const toKebabCase = (val: string): string => + val.replace(UPPER, "-$1").toLowerCase(); + +/** + * Hook for bidirectional boolean property ↔ attribute sync. + * + * The `attribute` parameter is the **attribute name** (kebab-case), e.g. `'opened'` + * or `'open-on-focus'`. The hook derives the corresponding camelCase property name + * for host element access. + * + * Always reflects: setting the value to `true` adds the attribute (empty string), + * setting it to `false` removes the attribute. + * + * Dispatches a `{attribute}-changed` CustomEvent (cancelable) on every state change. + * + * @example + * ```ts + * const [opened, setOpened] = useAttribute('opened'); + * ``` + */ +export const useAttribute = hook( + class extends Hook<[string], StateTuple, Host> { + property: string; + eventName: string; + attrName: string; + lastReflected: boolean | undefined; + + constructor(id: number, state: State, attribute: string) { + super(id, state); + + if (this.state.virtual) { + throw new Error("Can't be used with virtual components."); + } + + this.updater = this.updater.bind(this); + this.property = toCamelCase(attribute); + this.attrName = toKebabCase(this.property); + this.eventName = this.attrName + "-changed"; + + // If the attribute is already present on the host, start as true + if (this.state.host.hasAttribute(this.attrName)) { + if (this.state.host[this.property] == null) { + this.updateProp(true); + return; + } + } + + // Respect parent-provided value via .prop= binding + if (this.state.host[this.property] != null) return; + + // Default: false (boolean semantics) + this.updateProp(false); + } + + update(_attribute: string): StateTuple { + const currentValue = !!this.state.host[this.property]; + if (!Object.is(currentValue, this.lastReflected)) { + this.reflectToAttribute(currentValue); + } + return [currentValue, this.updater]; + } + + updater(value: NewState): void { + const previousValue = !!this.state.host[this.property]; + + if (typeof value === "function") { + const updaterFn = value as (previousState: boolean) => boolean; + value = updaterFn(previousValue); + } + + value = !!value; // coerce to boolean + + if (Object.is(previousValue, value)) { + return; + } + + this.updateProp(value); + } + + updateProp(value: boolean): void { + const ev = this.notify(value); + if (ev.defaultPrevented) return; + this.state.host[this.property] = value; + this.reflectToAttribute(value); + } + + reflectToAttribute(value: boolean): void { + const host = this.state.host; + this.lastReflected = value; + host[reflectingSymbol] = true; + try { + if (value) { + host.setAttribute(this.attrName, ""); + } else { + host.removeAttribute(this.attrName); + } + } finally { + host[reflectingSymbol] = false; + } + } + + notify(value: boolean) { + const ev = new CustomEvent(this.eventName, { + detail: { value, path: this.property }, + cancelable: true, + }); + this.state.host.dispatchEvent(ev); + return ev; + } + } +) as UseAttribute; diff --git a/test/use-attribute.test.ts b/test/use-attribute.test.ts new file mode 100644 index 0000000..37aef91 --- /dev/null +++ b/test/use-attribute.test.ts @@ -0,0 +1,425 @@ +import { + component, + html, + lift, + useAttribute, + useState, + virtual, +} from "../src/haunted.js"; +import { fixture, expect, nextFrame } from "@open-wc/testing"; + +describe("useAttribute", () => { + it("returns [false, setter] by default", async () => { + const tag = "use-attr-default"; + let value: boolean; + + function App() { + const [opened] = useAttribute("opened"); + value = opened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + await fixture(html``); + + expect(value!).to.be.false; + }); + + it("reflects true to attribute and false removes it", async () => { + const tag = "use-attr-reflect"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened"); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }), + ); + + const el = await fixture( + html``, + ); + + // Initial: false — no attribute + 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("does not cause feedback loop", async () => { + const tag = "use-attr-no-loop"; + let renderCount = 0; + let setter: (v: any) => void; + + function App() { + renderCount++; + const [opened, setOpened] = useAttribute("opened"); + 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 external setAttribute to property", async () => { + const tag = "use-attr-ext-set"; + + function App() { + const [opened] = useAttribute("opened"); + 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 external removeAttribute to false", async () => { + const tag = "use-attr-ext-remove"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened"); + 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-attr-initial"; + + function App() { + const [opened] = useAttribute("opened"); + 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("dispatches *-changed event", async () => { + const tag = "use-attr-event"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened"); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }), + ); + + const el = await fixture( + html``, + ); + + let eventDetail: any; + el.addEventListener("opened-changed", ((ev: CustomEvent) => { + eventDetail = ev.detail; + }) as EventListener); + + setter!(true); + expect(eventDetail).to.deep.equal({ value: true, path: "opened" }); + }); + + it("preventDefault blocks update and attribute reflection", async () => { + const tag = "use-attr-prevent"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened"); + 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("works with lift pattern", async () => { + const parentTag = "use-attr-lift-parent"; + const childTag = "use-attr-lift-child"; + let parentSetter: (v: any) => void; + let childSetter: (v: any) => void; + + function Parent() { + const [opened, setOpened] = useState(false); + parentSetter = setOpened; + return html` setOpened(v))} + >`; + } + + function Child() { + const [opened, setOpened] = useAttribute("opened"); + childSetter = setOpened; + return html`${opened}`; + } + + customElements.define(parentTag, 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(); + await nextFrame(); // extra frame for child to render + expect(child?.opened).to.be.true; + expect(child?.hasAttribute("opened")).to.be.true; + + // Child requests change — parent lifts it + childSetter!(false); + await nextFrame(); + await nextFrame(); + expect(child?.opened).to.be.false; + expect(child?.hasAttribute("opened")).to.be.false; + }); + + it("supports function updater", async () => { + const tag = "use-attr-fn-updater"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened"); + setter = setOpened; + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }), + ); + + const el = await fixture( + html``, + ); + + expect((el as any).opened).to.be.false; + + // Toggle using function updater + setter!((prev: boolean) => !prev); + await nextFrame(); + expect((el as any).opened).to.be.true; + expect(el.hasAttribute("opened")).to.be.true; + + // Toggle back + setter!((prev: boolean) => !prev); + await nextFrame(); + expect((el as any).opened).to.be.false; + expect(el.hasAttribute("opened")).to.be.false; + }); + + it("throws in virtual component", async () => { + const tag = "use-attr-virtual-render"; + let error: Error | null = null; + + const VApp = virtual(() => { + try { + useAttribute("opened"); + } catch (e) { + error = e as Error; + } + return html`virtual`; + }); + + function App() { + return html`${VApp()}`; + } + + customElements.define(tag, component(App)); + + await fixture( + html``, + ); + + expect(error).to.be.instanceOf(Error); + expect(error!.message).to.include("virtual"); + }); + + it("handles kebab-case attribute with camelCase property", async () => { + const tag = "use-attr-kebab"; + let setter: (v: any) => void; + + function App() { + const [openOnFocus, setOpenOnFocus] = useAttribute("open-on-focus"); + setter = setOpenOnFocus; + return html`${openOnFocus}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["open-on-focus"] }), + ); + + const el = await fixture( + html``, + ); + + expect((el as any).openOnFocus).to.be.false; + expect(el.hasAttribute("open-on-focus")).to.be.false; + + setter!(true); + await nextFrame(); + expect((el as any).openOnFocus).to.be.true; + expect(el.hasAttribute("open-on-focus")).to.be.true; + expect(el.getAttribute("open-on-focus")).to.equal(""); + + // External attribute change + el.removeAttribute("open-on-focus"); + await nextFrame(); + expect((el as any).openOnFocus).to.be.false; + }); + + it("respects parent-provided property value", async () => { + const parentTag = "use-attr-parent-override-p"; + const childTag = "use-attr-parent-override-c"; + + function Parent() { + return html``; + } + + function Child() { + const [opened] = useAttribute("opened"); + return html`${opened}`; + } + + customElements.define(parentTag, component(Parent)); + customElements.define( + childTag, + component(Child, { observedAttributes: ["opened"] }), + ); + + const el = await fixture( + html``, + ); + + const child = el.shadowRoot?.firstElementChild as any; + await nextFrame(); + expect(child?.opened).to.be.true; + }); + + it("setter is referentially stable across renders", async () => { + const tag = "use-attr-stable-setter"; + const setters: any[] = []; + + function App() { + const [opened, setOpened] = useAttribute("opened"); + setters.push(setOpened); + return html`${opened}`; + } + + customElements.define( + tag, + component(App, { observedAttributes: ["opened"] }), + ); + + await fixture( + html``, + ); + + // Trigger a re-render + setters[0](true); + await nextFrame(); + + expect(setters.length).to.be.greaterThan(1); + expect(setters[0]).to.equal(setters[1]); + }); +});