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..d63edbd 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -9,6 +9,9 @@ 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 attributeObserverSymbol = Symbol("haunted.attributeObserver"); + const contextEvent = "haunted.context"; export { @@ -18,6 +21,8 @@ export { commitSymbol, effectsSymbol, layoutEffectsSymbol, + reflectingSymbol, + attributeObserverSymbol, contextEvent, Phase, EffectsSymbols, diff --git a/src/use-attribute.ts b/src/use-attribute.ts new file mode 100644 index 0000000..300d564 --- /dev/null +++ b/src/use-attribute.ts @@ -0,0 +1,310 @@ +import { hook, Hook } from "./hook"; +import { State } from "./state"; +import { reflectingSymbol, attributeObserverSymbol } from "./symbols"; +import type { NewState, StateUpdater, StateTuple } from "./use-state"; + +// --- Types --- + +type AttributeHost = Element & { + [key: string]: unknown; + [reflectingSymbol]?: boolean; + [attributeObserverSymbol]?: ObserverRegistry; +}; + +type ChangeEvent = { + value: T; + path: string; +}; + +type AttributeTypeMap = { + Boolean: BooleanConstructor; + String: StringConstructor; + Number: NumberConstructor; +}; + +type AttributeType = AttributeTypeMap[keyof AttributeTypeMap]; + +type TypeToValue = C extends BooleanConstructor + ? boolean + : C extends StringConstructor + ? string + : C extends NumberConstructor + ? number + : never; + +export interface UseAttribute { + ( + name: string, + type: C, + defaultValue?: TypeToValue, + ): StateTuple>; +} + +// --- Shared MutationObserver registry per host element --- + +interface ObserverRegistry { + observer: MutationObserver; + hooks: Map>>; +} + +function getRegistry(host: AttributeHost): ObserverRegistry { + let registry = host[attributeObserverSymbol]; + if (!registry) { + const hooks: ObserverRegistry["hooks"] = new Map(); + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type !== "attributes") continue; + const attrName = mutation.attributeName!; + const hookSet = hooks.get(attrName); + if (!hookSet) continue; + for (const h of hookSet) { + h.onAttributeChanged(); + } + } + }); + registry = { observer, hooks }; + host[attributeObserverSymbol] = registry; + } + return registry; +} + +function registerHook( + host: AttributeHost, + attrName: string, + hookInstance: UseAttributeHook, +): void { + const registry = getRegistry(host); + let hookSet = registry.hooks.get(attrName); + const needsRestart = !hookSet; + + if (!hookSet) { + hookSet = new Set(); + registry.hooks.set(attrName, hookSet); + } + hookSet.add(hookInstance); + + if (needsRestart) { + // Restart observer with updated attribute filter + registry.observer.disconnect(); + const attributeFilter = [...registry.hooks.keys()]; + registry.observer.observe(host, { attributes: true, attributeFilter }); + } +} + +function unregisterHook( + host: AttributeHost, + attrName: string, + hookInstance: UseAttributeHook, +): void { + const registry = host[attributeObserverSymbol]; + if (!registry) return; + + const hookSet = registry.hooks.get(attrName); + if (!hookSet) return; + + hookSet.delete(hookInstance); + if (hookSet.size === 0) { + registry.hooks.delete(attrName); + } + + if (registry.hooks.size === 0) { + registry.observer.disconnect(); + delete host[attributeObserverSymbol]; + } else if (hookSet.size === 0) { + // Restart observer with updated attribute filter + registry.observer.disconnect(); + const attributeFilter = [...registry.hooks.keys()]; + registry.observer.observe(host, { attributes: true, attributeFilter }); + } +} + +// --- Coercion helpers --- + +const UPPER = /([A-Z])/gu; + +function typeDefault(type: C): TypeToValue { + if (type === Boolean) return false as TypeToValue; + if (type === Number) return 0 as TypeToValue; + return "" as TypeToValue; +} + +function fromAttribute( + type: C, + value: string | null, + defaultValue: TypeToValue, +): TypeToValue { + if (type === Boolean) { + return (value !== null) as TypeToValue; + } + if (value === null) { + return defaultValue; + } + if (type === Number) { + const n = parseFloat(value); + return (Number.isNaN(n) ? defaultValue : n) as TypeToValue; + } + return value as TypeToValue; +} + +function toAttribute( + type: C, + value: TypeToValue, +): string | null { + if (type === Boolean) { + return value ? "" : null; + } + if (value == null) { + return null; + } + return String(value); +} + +// --- Hook implementation --- + +class UseAttributeHook extends Hook< + [string, C, TypeToValue?], + StateTuple>, + AttributeHost +> { + property: string; + attrName: string; + eventName: string; + type: C; + defaultValue: TypeToValue; + lastReflected: TypeToValue | undefined; + + constructor( + id: number, + state: State, + attrName: string, + type: C, + defaultValue?: TypeToValue, + ) { + super(id, state); + + if (this.state.virtual) { + throw new Error("useAttribute can't be used with virtual components."); + } + + this.updater = this.updater.bind(this); + this.attrName = attrName; + this.type = type; + this.defaultValue = + defaultValue !== undefined ? defaultValue : typeDefault(type); + this.property = attrName.replace(/-+([a-z])?/g, (_, char) => + char ? char.toUpperCase() : "", + ); + this.eventName = + this.property.replace(UPPER, "-$1").toLowerCase() + "-changed"; + + // Register with the shared MutationObserver + registerHook( + this.state.host, + this.attrName, + this as unknown as UseAttributeHook, + ); + + // If property is already set by parent (via .prop= binding), keep it + if (this.state.host[this.property] != null) return; + + // Read initial value from attribute if present + const host = this.state.host; + if (host.hasAttribute(this.attrName)) { + const attrVal = host.getAttribute(this.attrName); + const coerced = fromAttribute(this.type, attrVal, this.defaultValue); + this.updateProp(coerced); + } else { + // Set the default value + this.updateProp(this.defaultValue); + } + } + + update( + _attrName: string, + _type: C, + _defaultValue?: TypeToValue, + ): StateTuple> { + // Check if property value has diverged from last reflected attribute value + const currentValue = this.state.host[this.property] as TypeToValue; + if (!Object.is(currentValue, this.lastReflected)) { + this.reflectToAttribute(currentValue); + } + return [currentValue, this.updater]; + } + + updater(value: NewState>): void { + const previousValue = this.state.host[this.property] as TypeToValue; + + if (typeof value === "function") { + const updaterFn = value as (prev: TypeToValue) => TypeToValue; + value = updaterFn(previousValue); + } + + if (Object.is(previousValue, value)) { + return; + } + + this.updateProp(value); + } + + updateProp(value: TypeToValue): void { + const ev = this.notify(value); + if (ev.defaultPrevented) return; + this.state.host[this.property] = value; + this.reflectToAttribute(value); + } + + reflectToAttribute(value: TypeToValue): void { + const host = this.state.host; + this.lastReflected = value; + host[reflectingSymbol] = true; + try { + const attrVal = toAttribute(this.type, value); + if (attrVal === null) { + host.removeAttribute(this.attrName); + } else { + host.setAttribute(this.attrName, attrVal); + } + } finally { + host[reflectingSymbol] = false; + } + } + + onAttributeChanged(): void { + // Skip if this change was caused by our own reflection + if (this.state.host[reflectingSymbol]) return; + + const attrVal = this.state.host.getAttribute(this.attrName); + const coerced = fromAttribute(this.type, attrVal, this.defaultValue); + const currentValue = this.state.host[this.property] as TypeToValue; + + if (Object.is(currentValue, coerced)) return; + + this.state.host[this.property] = coerced; + } + + notify(value: TypeToValue) { + const ev = new CustomEvent>>(this.eventName, { + detail: { value, path: this.property }, + cancelable: true, + }); + this.state.host.dispatchEvent(ev); + return ev; + } + + teardown(): void { + unregisterHook( + this.state.host, + this.attrName, + this as unknown as UseAttributeHook, + ); + } +} + +export const useAttribute = hook( + UseAttributeHook as unknown as new ( + id: number, + state: State, + ...args: [string, AttributeType, unknown?] + ) => UseAttributeHook, +) as UseAttribute; diff --git a/test/use-attribute.test.ts b/test/use-attribute.test.ts new file mode 100644 index 0000000..5ae44b7 --- /dev/null +++ b/test/use-attribute.test.ts @@ -0,0 +1,637 @@ +import { + component, + html, + lift, + useAttribute, + useState, +} from "../src/haunted.js"; +import { fixture, expect, nextFrame } from "@open-wc/testing"; + +describe("useAttribute", () => { + describe("Boolean type", () => { + it("reflects boolean property to attribute", async () => { + const tag = "use-attr-bool"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened", Boolean); + setter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + // Initial value false — attribute should not be present + expect(el.hasAttribute("opened")).to.be.false; + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("false"); + + setter!(true); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.true; + expect(el.getAttribute("opened")).to.equal(""); + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("true"); + + setter!(false); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.false; + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("false"); + }); + + it("honors initial attribute value", async () => { + const tag = "use-attr-bool-initial"; + + function App() { + const [opened] = useAttribute("opened", Boolean); + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).opened).to.be.true; + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("true"); + }); + + it("supports function updater", async () => { + const tag = "use-attr-bool-fn-updater"; + let toggle: () => void; + + function App() { + const [opened, setOpened] = useAttribute("opened", Boolean); + toggle = () => setOpened((prev: boolean) => !prev); + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).opened).to.be.false; + + toggle!(); + await nextFrame(); + expect((el as any).opened).to.be.true; + expect(el.hasAttribute("opened")).to.be.true; + + toggle!(); + await nextFrame(); + expect((el as any).opened).to.be.false; + expect(el.hasAttribute("opened")).to.be.false; + }); + }); + + describe("String type", () => { + it("reflects string property to attribute", async () => { + const tag = "use-attr-str"; + let setter: (v: any) => void; + + function App() { + const [name, setName] = useAttribute("name", String); + setter = setName; + return html`${name}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + // Default is empty string — attribute should be set to "" + expect(el.getAttribute("name")).to.equal(""); + + setter!("hello"); + await nextFrame(); + expect(el.getAttribute("name")).to.equal("hello"); + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("hello"); + + setter!("world"); + await nextFrame(); + expect(el.getAttribute("name")).to.equal("world"); + }); + + it("honors initial attribute value for string", async () => { + const tag = "use-attr-str-initial"; + + function App() { + const [name] = useAttribute("name", String); + return html`${name}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).name).to.equal("Alice"); + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("Alice"); + }); + + it("supports custom default value", async () => { + const tag = "use-attr-str-default"; + + function App() { + const [name] = useAttribute("name", String, "unnamed"); + return html`${name}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).name).to.equal("unnamed"); + expect(el.getAttribute("name")).to.equal("unnamed"); + }); + }); + + describe("Number type", () => { + it("reflects number property to attribute", async () => { + const tag = "use-attr-num"; + let setter: (v: any) => void; + + function App() { + const [count, setCount] = useAttribute("count", Number); + setter = setCount; + return html`${count}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + // Default is 0 + expect((el as any).count).to.equal(0); + expect(el.getAttribute("count")).to.equal("0"); + + setter!(42); + await nextFrame(); + expect(el.getAttribute("count")).to.equal("42"); + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("42"); + }); + + it("parses number from attribute value", async () => { + const tag = "use-attr-num-parse"; + + function App() { + const [count] = useAttribute("count", Number); + return html`${count}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).count).to.equal(99); + expect(el.shadowRoot?.firstElementChild?.textContent).to.equal("99"); + }); + + it("supports custom default value for number", async () => { + const tag = "use-attr-num-default"; + + function App() { + const [count] = useAttribute("count", Number, 10); + return html`${count}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).count).to.equal(10); + expect(el.getAttribute("count")).to.equal("10"); + }); + + it("supports function updater for number", async () => { + const tag = "use-attr-num-fn"; + let increment: () => void; + + function App() { + const [count, setCount] = useAttribute("count", Number, 0); + increment = () => setCount((prev: number) => prev + 1); + return html`${count}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + increment!(); + await nextFrame(); + expect((el as any).count).to.equal(1); + + increment!(); + await nextFrame(); + expect((el as any).count).to.equal(2); + }); + }); + + describe("feedback loop prevention", () => { + 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", Boolean); + setter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + 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); + }); + }); + + describe("external attribute changes via MutationObserver", () => { + it("syncs external setAttribute to property", async () => { + const tag = "use-attr-ext-set"; + + function App() { + const [opened] = useAttribute("opened", Boolean); + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).opened).to.be.false; + + // Externally set attribute + el.setAttribute("opened", ""); + // MutationObserver fires asynchronously (microtask) + await nextFrame(); + expect((el as any).opened).to.be.true; + expect( + el.shadowRoot?.firstElementChild?.textContent, + ).to.equal("true"); + }); + + it("syncs external removeAttribute to property", async () => { + const tag = "use-attr-ext-remove"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened", Boolean); + setter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + // Open it first + setter!(true); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.true; + + // Externally remove attribute + el.removeAttribute("opened"); + await nextFrame(); + expect((el as any).opened).to.be.false; + expect( + el.shadowRoot?.firstElementChild?.textContent, + ).to.equal("false"); + }); + + it("syncs external string attribute change", async () => { + const tag = "use-attr-ext-str"; + + function App() { + const [name] = useAttribute("name", String); + return html`${name}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + el.setAttribute("name", "externally-set"); + await nextFrame(); + expect((el as any).name).to.equal("externally-set"); + expect( + el.shadowRoot?.firstElementChild?.textContent, + ).to.equal("externally-set"); + }); + + it("syncs external number attribute change", async () => { + const tag = "use-attr-ext-num"; + + function App() { + const [count] = useAttribute("count", Number); + return html`${count}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + el.setAttribute("count", "77"); + await nextFrame(); + expect((el as any).count).to.equal(77); + }); + }); + + describe("events", () => { + it("dispatches *-changed event", async () => { + const tag = "use-attr-event"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened", Boolean); + setter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + 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("does not update when event is prevented", async () => { + const tag = "use-attr-prevent"; + let setter: (v: any) => void; + + function App() { + const [opened, setOpened] = useAttribute("opened", Boolean); + setter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + 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; + }); + }); + + describe("kebab-case attribute to camelCase property", () => { + it("converts kebab-case attribute to camelCase property", async () => { + const tag = "use-attr-kebab"; + let setter: (v: any) => void; + + function App() { + const [openOnHover, setOpenOnHover] = useAttribute( + "open-on-hover", + Boolean, + ); + setter = setOpenOnHover; + return html`${openOnHover}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).openOnHover).to.be.false; + + setter!(true); + await nextFrame(); + expect((el as any).openOnHover).to.be.true; + expect(el.hasAttribute("open-on-hover")).to.be.true; + }); + + it("honors initial kebab-case attribute", async () => { + const tag = "use-attr-kebab-initial"; + + function App() { + const [openOnHover] = useAttribute("open-on-hover", Boolean); + return html`${openOnHover}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).openOnHover).to.be.true; + }); + }); + + describe("lift pattern", () => { + it("works with lift for parent-controlled state", 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", Boolean); + childSetter = setOpened; + return html`${opened}`; + } + + customElements.define(parentTag, component(Parent)); + customElements.define(childTag, component(Child)); + + 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; + }); + }); + + describe("multiple useAttribute on same host", () => { + it("supports multiple attributes on one component", async () => { + const tag = "use-attr-multi"; + let setOpened: (v: any) => void; + let setName: (v: any) => void; + + function App() { + const [opened, _setOpened] = useAttribute("opened", Boolean); + const [name, _setName] = useAttribute("name", String, "default"); + setOpened = _setOpened; + setName = _setName; + return html`${opened} ${name}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + expect((el as any).opened).to.be.false; + expect((el as any).name).to.equal("default"); + + setOpened!(true); + setName!("test"); + await nextFrame(); + + expect(el.hasAttribute("opened")).to.be.true; + expect(el.getAttribute("name")).to.equal("test"); + }); + + it("external changes work for multiple attributes", async () => { + const tag = "use-attr-multi-ext"; + + function App() { + const [opened] = useAttribute("opened", Boolean); + const [count] = useAttribute("count", Number); + return html`${opened} ${count}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + el.setAttribute("opened", ""); + el.setAttribute("count", "5"); + await nextFrame(); + + expect((el as any).opened).to.be.true; + expect((el as any).count).to.equal(5); + }); + }); + + describe("no observedAttributes required", () => { + it("works without declaring observedAttributes", async () => { + const tag = "use-attr-no-observed"; + let setter: (v: any) => void; + + // Note: no observedAttributes option passed to component() + function App() { + const [opened, setOpened] = useAttribute("opened", Boolean); + setter = setOpened; + return html`${opened}`; + } + + customElements.define(tag, component(App)); + + const el = await fixture( + html``, + ); + + // Property works + setter!(true); + await nextFrame(); + expect(el.hasAttribute("opened")).to.be.true; + expect((el as any).opened).to.be.true; + + // External attribute change works (via MutationObserver, not attributeChangedCallback) + el.removeAttribute("opened"); + await nextFrame(); + expect((el as any).opened).to.be.false; + }); + }); + + describe("parent .prop= override", () => { + it("respects parent-provided property value over default", 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", Boolean); + return html`${opened}`; + } + + customElements.define(parentTag, component(Parent)); + customElements.define(childTag, component(Child)); + + const el = await fixture( + html``, + ); + + const child = el.shadowRoot?.firstElementChild as any; + await nextFrame(); + expect(child?.opened).to.be.true; + }); + }); +});