diff --git a/packages/data-lit/README.md b/packages/data-lit/README.md index 8f028f9a..372d25a3 100644 --- a/packages/data-lit/README.md +++ b/packages/data-lit/README.md @@ -16,6 +16,18 @@ import { ApplicationHost, DatabaseElement } from "@adobe/data-lit"; import { createDatabase } from "@adobe/data/ecs"; ``` +## State and resize callbacks + +`useState` returns a setter whose identity is stable for the lifetime of the hook state. The setter +requests a render only when `Object.is` reports that the value has changed. Lazy initializers run once +per hook state, including when the initial value is `null` or `undefined`. + +Treat state objects and arrays as immutable: pass a new object or array when its contents change. +Mutating a value in place and passing the same reference to the setter does not request a render. + +`useResizeObserver` keeps its observer attached across renders and invokes the latest callback. +Inline callbacks do not cause the observer to disconnect and reattach. + ## Hooks and the disconnect lifecycle Hooks (`useState`, `useEffect`, `useObservable`, `useObservableValues`, `useMemo`, `useRef`, ...) follow diff --git a/packages/data-lit/src/hooks/use-resize-observer.test.ts b/packages/data-lit/src/hooks/use-resize-observer.test.ts new file mode 100644 index 00000000..3f03a0c9 --- /dev/null +++ b/packages/data-lit/src/hooks/use-resize-observer.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { afterEach, describe, it, expect, vi } from "vitest"; +import { LitElement, html } from "lit"; +import { attachDecorator } from "./attach-decorator.js"; +import { withHooks } from "./with-hooks.js"; +import { useMemo } from "./use-memo.js"; +import { useResizeObserver } from "./use-resize-observer.js"; +import { useState } from "./use-state.js"; + +class ResizeProbeElement extends LitElement { + renders = 0; + offset = 0; + widths: number[] = []; + calculateCallback = vi.fn((setWidth: (value: number) => void) => setWidth); + + constructor() { + super(); + attachDecorator(this, "render", withHooks); + } + + render() { + this.renders++; + const [width, setWidth] = useState(0); + const memoizedSetter = useMemo(() => this.calculateCallback(setWidth), [setWidth]); + const offset = this.offset; + useResizeObserver(info => { + this.widths.push(info.width + offset); + memoizedSetter(Math.floor(info.width + offset)); + }); + return html`${width}`; + } +} +customElements.define("resize-state-probe", ResizeProbeElement); + +function mockResizeObserver() { + const observers: { emit: (width: number) => void; observe: ReturnType; disconnect: ReturnType }[] = []; + vi.stubGlobal("ResizeObserver", class { + observe = vi.fn(); + disconnect = vi.fn(); + + constructor(callback: ResizeObserverCallback) { + observers.push({ + emit: width => callback([ + { contentRect: { width, height: 20 } } as ResizeObserverEntry, + ], this as unknown as ResizeObserver), + observe: this.observe, + disconnect: this.disconnect, + }); + } + }); + return observers; +} + +afterEach(async () => { + document.body.replaceChildren(); + await new Promise(resolve => queueMicrotask(resolve)); + vi.unstubAllGlobals(); +}); + +describe("useResizeObserver with state", () => { + it("keeps one observer, uses the latest inline callback, and skips unchanged widths", async () => { + const observers = mockResizeObserver(); + const element = document.createElement("resize-state-probe") as ResizeProbeElement; + document.body.appendChild(element); + await element.updateComplete; + const observer = observers[0]; + expect(observer.observe).toHaveBeenCalledExactlyOnceWith(element); + + observer.emit(100.2); + await element.updateComplete; + expect(element.shadowRoot?.textContent).toBe("100"); + expect(element.renders).toBe(2); + expect(observers).toHaveLength(1); + expect(observer.disconnect).not.toHaveBeenCalled(); + expect(element.calculateCallback).toHaveBeenCalledTimes(1); + + const requestUpdate = vi.spyOn(element, "requestUpdate"); + observer.emit(100.8); + await element.updateComplete; + expect(requestUpdate).not.toHaveBeenCalled(); + expect(element.renders).toBe(2); + + element.offset = 10; + element.requestUpdate(); + await element.updateComplete; + observer.emit(200); + await element.updateComplete; + expect(element.widths).toEqual([100.2, 100.8, 210]); + expect(element.shadowRoot?.textContent).toBe("210"); + expect(observers).toHaveLength(1); + expect(observer.disconnect).not.toHaveBeenCalled(); + expect(element.calculateCallback).toHaveBeenCalledTimes(1); + }); + + it("disconnects on unmount and creates fresh state and observer on remount", async () => { + const observers = mockResizeObserver(); + const element = document.createElement("resize-state-probe") as ResizeProbeElement; + document.body.appendChild(element); + await element.updateComplete; + observers[0].emit(100); + await element.updateComplete; + + element.remove(); + await new Promise(resolve => queueMicrotask(resolve)); + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + + document.body.appendChild(element); + await element.updateComplete; + expect(element.shadowRoot?.textContent).toBe("0"); + expect(observers).toHaveLength(2); + expect(observers[1].observe).toHaveBeenCalledExactlyOnceWith(element); + expect(element.calculateCallback).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/packages/data-lit/src/hooks/use-resize-observer.ts b/packages/data-lit/src/hooks/use-resize-observer.ts index 7f02a10c..bacbc132 100644 --- a/packages/data-lit/src/hooks/use-resize-observer.ts +++ b/packages/data-lit/src/hooks/use-resize-observer.ts @@ -2,6 +2,7 @@ import { useEffect } from "./use-effect.js"; import { useElement } from "./use-element.js"; +import { useRef } from "./use-ref.js"; export interface ResizeInfo { width: number; @@ -17,15 +18,17 @@ export interface ResizeInfo { */ export function useResizeObserver(onResize: (info: ResizeInfo) => void) { const element = useElement(); + const latestCallback = useRef(onResize); + latestCallback.current = onResize; useEffect(() => { const observer = new ResizeObserver(entries => { for (const entry of entries) { const { width, height } = entry.contentRect; - onResize({ width, height, entry }); + latestCallback.current({ width, height, entry }); } }); observer.observe(element); return () => observer.disconnect(); - }, [element, onResize]); + }, [element]); } \ No newline at end of file diff --git a/packages/data-lit/src/hooks/use-state.test.ts b/packages/data-lit/src/hooks/use-state.test.ts new file mode 100644 index 00000000..296b88e9 --- /dev/null +++ b/packages/data-lit/src/hooks/use-state.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Component } from "./component/component.js"; +import { Component_stack } from "./component/stack.js"; +import { useState } from "./use-state.js"; + +function createHost(): Component { + return Object.assign(new EventTarget(), { + isConnected: true, + hookIndex: 0, + hooks: [], + updatedListeners: new Set<() => void>(), + requestUpdate: vi.fn(), + }); +} + +function render(host: Component, callback: () => T): T { + Component_stack.push(host); + try { + return callback(); + } finally { + Component_stack.pop(); + } +} + +describe("useState", () => { + it("keeps the setter stable across renders and updates", () => { + const host = createHost(); + const [, setValue] = render(host, () => useState(0)); + expect(render(host, () => useState(0))[1]).toBe(setValue); + + setValue(1); + + const [value, nextSetter] = render(host, () => useState(0)); + expect(value).toBe(1); + expect(nextSetter).toBe(setValue); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + }); + + it.each([0, NaN, null, undefined, "value", false, {}, []])( + "does not request an update for an Object.is-equal value: %s", + initial => { + const host = createHost(); + const [, setValue] = render(host, () => useState(initial)); + + setValue(initial); + setValue(initial); + + expect(host.requestUpdate).not.toHaveBeenCalled(); + }, + ); + + it("compares against the latest value before another render", () => { + const host = createHost(); + const [, setValue] = render(host, () => useState(0)); + + setValue(1); + setValue(1); + setValue(2); + + expect(host.requestUpdate).toHaveBeenCalledTimes(2); + expect(render(host, () => useState(0))[0]).toBe(2); + }); + + it("distinguishes positive and negative zero", () => { + const host = createHost(); + const [, setValue] = render(host, () => useState(0)); + + setValue(-0); + + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + expect(render(host, () => useState(0))[0]).toBe(-0); + }); + + it.each([null, undefined])("initializes a nullish value only once: %s", initial => { + const host = createHost(); + const initialize = vi.fn(() => initial); + + render(host, () => useState(initialize)); + render(host, () => useState(initialize)); + + expect(initialize).toHaveBeenCalledTimes(1); + }); + + it.each([null, undefined])("preserves a nullish update: %s", nextValue => { + const host = createHost(); + const [, setValue] = render(host, () => useState(1)); + + setValue(nextValue); + + expect(render(host, () => useState(1))[0]).toBe(nextValue); + }); + + it("keeps state slots and hosts independent", () => { + const host = createHost(); + const otherHost = createHost(); + const read = () => [useState(0), useState(10)] as const; + const [first, second] = render(host, read); + const [other] = render(otherHost, read); + + first[1](1); + + expect(render(host, read).map(([value]) => value)).toEqual([1, 10]); + expect(render(otherHost, read).map(([value]) => value)).toEqual([0, 10]); + expect(first[1]).not.toBe(second[1]); + expect(first[1]).not.toBe(other[1]); + expect(otherHost.requestUpdate).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/data-lit/src/hooks/use-state.ts b/packages/data-lit/src/hooks/use-state.ts index 33148809..3618cf62 100644 --- a/packages/data-lit/src/hooks/use-state.ts +++ b/packages/data-lit/src/hooks/use-state.ts @@ -2,18 +2,23 @@ import { Component_stack } from "./component/stack.js"; +interface State { + value: T; + setValue: (value: T) => void; +} + export function useState(initializer: () => T): [T, (value: T) => void] export function useState(initialValue: T): [T, (value: T) => void] -export function useState(initial: () => T) { +export function useState(initial: T | (() => T)): [T, (value: T) => void] { const component = Component_stack.active(); const hookIndex = component.hookIndex++; - const value = component.hooks[hookIndex] ??= (typeof initial === "function" ? initial() : initial); - return [ - value, - (newValue: T) => { - component.hooks[hookIndex] = newValue; + const state: State = component.hooks[hookIndex] ??= { + value: typeof initial === "function" ? (initial as () => T)() : initial, + setValue: (newValue: T) => { + if (Object.is(state.value, newValue)) return; + state.value = newValue; component.requestUpdate(); - } - ]; - + }, + }; + return [state.value, state.setValue]; } \ No newline at end of file