@adobe/data-lit 0.10.30 → 0.10.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +22 -12
  2. package/dist/elements/application-host.d.ts +6 -2
  3. package/dist/elements/application-host.js +17 -1
  4. package/dist/elements/application-host.js.map +1 -1
  5. package/dist/functions/iterate-self-and-ancestors.d.ts +1 -0
  6. package/dist/functions/iterate-self-and-ancestors.js +2 -2
  7. package/dist/functions/iterate-self-and-ancestors.js.map +1 -1
  8. package/dist/hooks/component/hooks-controller.js +16 -5
  9. package/dist/hooks/component/hooks-controller.js.map +1 -1
  10. package/dist/hooks/use-drag-observe.js +2 -1
  11. package/dist/hooks/use-drag-observe.js.map +1 -1
  12. package/dist/hooks/use-drag-transaction.d.ts +5 -0
  13. package/dist/hooks/use-drag-transaction.js +42 -22
  14. package/dist/hooks/use-drag-transaction.js.map +1 -1
  15. package/dist/hooks/use-draggable.js +70 -48
  16. package/dist/hooks/use-draggable.js.map +1 -1
  17. package/dist/hooks/use-effect.js +6 -2
  18. package/dist/hooks/use-effect.js.map +1 -1
  19. package/dist/hooks/use-element.d.ts +4 -2
  20. package/dist/hooks/use-element.js +10 -51
  21. package/dist/hooks/use-element.js.map +1 -1
  22. package/dist/hooks/use-memo.js +11 -9
  23. package/dist/hooks/use-memo.js.map +1 -1
  24. package/dist/hooks/use-observable.d.ts +4 -0
  25. package/dist/hooks/use-observable.js +13 -8
  26. package/dist/hooks/use-observable.js.map +1 -1
  27. package/dist/hooks/use-ref.js +4 -3
  28. package/dist/hooks/use-ref.js.map +1 -1
  29. package/dist/hooks/use-resize-observer.js +5 -2
  30. package/dist/hooks/use-resize-observer.js.map +1 -1
  31. package/dist/hooks/use-resize-observer.test.d.ts +1 -0
  32. package/dist/hooks/use-resize-observer.test.js +102 -0
  33. package/dist/hooks/use-resize-observer.test.js.map +1 -0
  34. package/dist/hooks/use-state.js +9 -7
  35. package/dist/hooks/use-state.js.map +1 -1
  36. package/dist/hooks/use-state.test.d.ts +1 -0
  37. package/dist/hooks/use-state.test.js +83 -0
  38. package/dist/hooks/use-state.test.js.map +1 -0
  39. package/dist/hooks/use-window-event.js +1 -1
  40. package/dist/hooks/use-window-event.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/decorators/apply-decorator.test.ts +37 -0
  43. package/src/decorators/apply-service-decorators.test.ts +53 -0
  44. package/src/decorators/require-service.test.ts +31 -0
  45. package/src/elements/application-element.test.ts +80 -0
  46. package/src/elements/application-host.test.ts +57 -0
  47. package/src/elements/application-host.ts +16 -3
  48. package/src/elements/database-element.test.ts +111 -0
  49. package/src/functions/iterate-self-and-ancestors.test.ts +29 -0
  50. package/src/functions/iterate-self-and-ancestors.ts +2 -2
  51. package/src/hooks/attach-decorator.test.ts +45 -0
  52. package/src/hooks/component/hooks-controller.cleanup.test.ts +65 -0
  53. package/src/hooks/component/hooks-controller.ts +15 -5
  54. package/src/hooks/component/stack.test.ts +37 -0
  55. package/src/hooks/test-host.test-utils.ts +43 -0
  56. package/src/hooks/use-connected.test.ts +88 -0
  57. package/src/hooks/use-debounce.test.ts +69 -0
  58. package/src/hooks/use-drag-generator.test.ts +67 -0
  59. package/src/hooks/use-drag-observe.test.ts +74 -0
  60. package/src/hooks/use-drag-observe.ts +2 -1
  61. package/src/hooks/use-drag-transaction.test.ts +109 -0
  62. package/src/hooks/use-drag-transaction.ts +48 -27
  63. package/src/hooks/use-draggable.test.ts +148 -0
  64. package/src/hooks/use-draggable.ts +73 -48
  65. package/src/hooks/use-effect.test.ts +51 -0
  66. package/src/hooks/use-effect.ts +5 -2
  67. package/src/hooks/use-element.test.ts +82 -0
  68. package/src/hooks/use-element.ts +17 -60
  69. package/src/hooks/use-memo.test.ts +66 -0
  70. package/src/hooks/use-memo.ts +16 -11
  71. package/src/hooks/use-observable-values.test.ts +53 -0
  72. package/src/hooks/use-observable.test.ts +63 -0
  73. package/src/hooks/use-observable.ts +13 -8
  74. package/src/hooks/use-pointer-observe.test.ts +59 -0
  75. package/src/hooks/use-ref.test.ts +50 -0
  76. package/src/hooks/use-ref.ts +4 -3
  77. package/src/hooks/use-resize-observer.test.ts +114 -0
  78. package/src/hooks/use-resize-observer.ts +5 -2
  79. package/src/hooks/use-state.test.ts +108 -0
  80. package/src/hooks/use-state.ts +14 -9
  81. package/src/hooks/use-updated.test.ts +98 -0
  82. package/src/hooks/use-window-event.test.ts +61 -0
  83. package/src/hooks/use-window-event.ts +1 -1
  84. package/src/hooks/with-hooks.test.ts +45 -0
  85. package/src/index.test.ts +26 -0
  86. package/src/public-types.type-test.ts +17 -0
  87. package/tsconfig.json +1 -0
  88. package/tsconfig.test.json +9 -0
  89. package/tsconfig.tsbuildinfo +1 -1
  90. package/vitest.config.ts +7 -0
@@ -0,0 +1,51 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { useEffect } from "./use-effect.js";
5
+ import { createHost, render, unmount } from "./test-host.test-utils.js";
6
+
7
+ describe("useEffect", () => {
8
+ it("runs synchronously on the first render with the host as this", () => {
9
+ const host = createHost();
10
+ let self: unknown;
11
+ render(host, () => useEffect(function (this: unknown) { self = this; }, []));
12
+ expect(self).toBe(host);
13
+ });
14
+
15
+ it("skips equal dependencies and reruns changed ones, cleaning up first", () => {
16
+ const host = createHost();
17
+ const calls: string[] = [];
18
+ const effect = (n: number) => () => {
19
+ calls.push(`setup ${n}`);
20
+ return () => calls.push(`cleanup ${n}`);
21
+ };
22
+ render(host, () => useEffect(effect(1), [1]));
23
+ render(host, () => useEffect(effect(1), [1]));
24
+ render(host, () => useEffect(effect(2), [2]));
25
+ expect(calls).toEqual(["setup 1", "cleanup 1", "setup 2"]);
26
+ });
27
+
28
+ it("runs once with the default dependencies", () => {
29
+ const host = createHost();
30
+ const effect = vi.fn();
31
+ render(host, () => useEffect(effect));
32
+ render(host, () => useEffect(effect));
33
+ expect(effect).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it("runs the new effect when the previous cleanup throws", () => {
37
+ const host = createHost();
38
+ const next = vi.fn();
39
+ render(host, () => useEffect(() => () => { throw new Error("cleanup failed"); }, [1]));
40
+ expect(() => render(host, () => useEffect(next, [2]))).toThrow("cleanup failed");
41
+ expect(next).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it("runs its cleanup on unmount", () => {
45
+ const host = createHost();
46
+ const cleanup = vi.fn();
47
+ render(host, () => useEffect(() => cleanup, []));
48
+ unmount(host);
49
+ expect(cleanup).toHaveBeenCalledTimes(1);
50
+ });
51
+ });
@@ -13,7 +13,10 @@ export function useEffect<T extends Component>(callback: EffectCallback, depende
13
13
  const oldHookState = component.hooks[hookIndex] as EffectHookState | undefined;
14
14
  const rerunEffect = !oldHookState || !equalsShallow(dependencies, oldHookState.dependencies);
15
15
  if (rerunEffect) {
16
- oldHookState?.dispose?.();
17
- component.hooks[hookIndex] = { dispose: callback.call(component) ?? undefined, dependencies };
16
+ try {
17
+ oldHookState?.dispose?.();
18
+ } finally {
19
+ component.hooks[hookIndex] = { dispose: callback.call(component) ?? undefined, dependencies };
20
+ }
18
21
  }
19
22
  }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { LitElement, html, nothing } from "lit";
4
+ import { attachDecorator } from "./attach-decorator.js";
5
+ import { withHooks } from "./with-hooks.js";
6
+ import { useElement } from "./use-element.js";
7
+
8
+ class SelectorProbe extends LitElement {
9
+ selector = "span";
10
+ shadowMatch = false;
11
+ found: Element | null = null;
12
+
13
+ constructor() {
14
+ super();
15
+ attachDecorator(this, "render", withHooks);
16
+ }
17
+
18
+ render() {
19
+ this.found = useElement(this.selector);
20
+ return html`${this.shadowMatch ? html`<span class="shadow"></span>` : nothing}<slot></slot>`;
21
+ }
22
+ }
23
+ customElements.define("use-element-probe", SelectorProbe);
24
+
25
+ const flushMutations = () => new Promise<void>(resolve => setTimeout(resolve));
26
+
27
+ async function mount(...children: Element[]): Promise<SelectorProbe> {
28
+ const element = document.createElement("use-element-probe") as SelectorProbe;
29
+ element.append(...children);
30
+ document.body.append(element);
31
+ await element.updateComplete;
32
+ return element;
33
+ }
34
+
35
+ afterEach(async () => {
36
+ document.body.replaceChildren();
37
+ await flushMutations();
38
+ });
39
+
40
+ describe("useElement(selector)", () => {
41
+ it("returns an existing light-DOM match on the first render", async () => {
42
+ const span = document.createElement("span");
43
+ const element = await mount(span);
44
+ expect(element.found).toBe(span);
45
+ });
46
+
47
+ it("renders when a match is inserted and when it is removed", async () => {
48
+ const element = await mount();
49
+ expect(element.found).toBeNull();
50
+
51
+ const span = document.createElement("span");
52
+ element.append(span);
53
+ await flushMutations();
54
+ await element.updateComplete;
55
+ expect(element.found).toBe(span);
56
+
57
+ span.remove();
58
+ await flushMutations();
59
+ await element.updateComplete;
60
+ expect(element.found).toBeNull();
61
+ });
62
+
63
+ it("re-queries when the selector changes", async () => {
64
+ const element = await mount(document.createElement("span"));
65
+ element.selector = "button";
66
+ element.requestUpdate();
67
+ await element.updateComplete;
68
+ expect(element.found).toBeNull();
69
+ });
70
+
71
+ it("prefers a shadow-root match over a light-DOM match", async () => {
72
+ const light = document.createElement("span");
73
+ const element = document.createElement("use-element-probe") as SelectorProbe;
74
+ element.shadowMatch = true;
75
+ element.append(light);
76
+ document.body.append(element);
77
+ await element.updateComplete;
78
+ await flushMutations();
79
+ await element.updateComplete;
80
+ expect(element.found).toBe(element.shadowRoot?.querySelector(".shadow"));
81
+ });
82
+ });
@@ -6,78 +6,35 @@ import { useState } from "./use-state.js";
6
6
 
7
7
  /**
8
8
  * Hook that returns the currently active component.
9
- * If a querySelector is provided, it dynamically observes for child elements
10
- * matching the selector and triggers rerender when found.
9
+ * If a querySelector is provided, it returns the first match in the component's
10
+ * shadow root, then its light DOM. The match is re-queried on every render and
11
+ * whenever either tree's children change; a changed match requests a render.
11
12
  */
12
13
  export function useElement<K extends keyof HTMLElementTagNameMap>(querySelector: K): HTMLElementTagNameMap[K] | null
14
+ export function useElement<T extends Element = HTMLElement>(querySelector: string): T | null
13
15
  export function useElement<T = HTMLElement>(): T
14
16
  export function useElement<T = HTMLElement>(querySelector?: string): T | null {
15
17
  const component = Component_stack.active();
16
-
17
- // If no querySelector provided, return the active component
18
+
18
19
  if (!querySelector) {
19
20
  return component as unknown as T;
20
21
  }
21
22
 
22
- // Use state to track the found element
23
- const [foundElement, setFoundElement] = useState<T | null>(null);
24
-
23
+ const host = component as unknown as HTMLElement;
24
+ const find = () => (host.shadowRoot?.querySelector(querySelector) ?? host.querySelector(querySelector)) as T | null;
25
+ const [, setFoundElement] = useState<T | null>(null);
26
+ const foundElement = find();
27
+ setFoundElement(foundElement);
28
+
25
29
  useEffect(() => {
26
- const element = component as unknown as HTMLElement;
27
-
28
- // Check if element already exists
29
- const existingElement = element.querySelector(querySelector) as T | null;
30
- if (existingElement) {
31
- setFoundElement(existingElement);
32
- return;
33
- }
34
-
35
- // Set up MutationObserver to watch for new child elements
36
- const observer = new MutationObserver((mutations) => {
37
- for (const mutation of mutations) {
38
- if (mutation.type === 'childList') {
39
- // Check if any added nodes match the selector
40
- for (const node of mutation.addedNodes) {
41
- if (node.nodeType === Node.ELEMENT_NODE) {
42
- const element = node as Element;
43
- // Check if the added node itself matches
44
- if (element.matches(querySelector)) {
45
- setFoundElement(element as T);
46
- observer.disconnect();
47
- return;
48
- }
49
- // Check if any descendant matches
50
- const descendant = element.querySelector(querySelector) as T | null;
51
- if (descendant) {
52
- setFoundElement(descendant);
53
- observer.disconnect();
54
- return;
55
- }
56
- }
57
- }
58
- }
59
- }
60
- });
61
-
62
- // Start observing both element and shadowRoot
63
- for (const observe of [element.shadowRoot, element]) {
64
- if (observe) {
65
- observer.observe(observe, {
66
- childList: true,
67
- subtree: true
68
- });
30
+ const observer = new MutationObserver(() => setFoundElement(find()));
31
+ for (const root of [host.shadowRoot, host]) {
32
+ if (root) {
33
+ observer.observe(root, { childList: true, subtree: true });
69
34
  }
70
35
  }
71
- observer.observe(element.shadowRoot ?? element, {
72
- childList: true,
73
- subtree: true
74
- });
75
-
76
- // Cleanup function
77
- return () => {
78
- observer.disconnect();
79
- };
36
+ return () => observer.disconnect();
80
37
  }, [querySelector]);
81
-
38
+
82
39
  return foundElement;
83
40
  }
@@ -0,0 +1,66 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { useMemo } from "./use-memo.js";
5
+ import { createHost, render, unmount } from "./test-host.test-utils.js";
6
+
7
+ describe("useMemo", () => {
8
+ it("returns the cached value while dependencies are equal", () => {
9
+ const host = createHost();
10
+ const calculate = vi.fn(() => ({}));
11
+ const first = render(host, () => useMemo(calculate, [1, "a"]));
12
+ expect(render(host, () => useMemo(calculate, [1, "a"]))).toBe(first);
13
+ expect(calculate).toHaveBeenCalledTimes(1);
14
+ });
15
+
16
+ it("recalculates when a dependency changes", () => {
17
+ const host = createHost();
18
+ render(host, () => useMemo(() => 1, [1]));
19
+ expect(render(host, () => useMemo(() => 2, [2]))).toBe(2);
20
+ });
21
+
22
+ it("calculates once with the default dependencies", () => {
23
+ const host = createHost();
24
+ const calculate = vi.fn(() => 1);
25
+ render(host, () => useMemo(calculate));
26
+ render(host, () => useMemo(calculate));
27
+ expect(calculate).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it.each([undefined, null, 0, false])("caches the falsy result %s", result => {
31
+ const host = createHost();
32
+ const calculate = vi.fn(() => result);
33
+ render(host, () => useMemo(calculate, []));
34
+ expect(render(host, () => useMemo(calculate, []))).toBe(result);
35
+ expect(calculate).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it("retries a calculation that threw", () => {
39
+ const host = createHost();
40
+ expect(() => render(host, () => useMemo(() => { throw new Error("failed"); }, []))).toThrow("failed");
41
+ expect(render(host, () => useMemo(() => 1, []))).toBe(1);
42
+ });
43
+
44
+ it("never requests a render", () => {
45
+ const host = createHost();
46
+ render(host, () => useMemo(() => 1, [1]));
47
+ render(host, () => useMemo(() => 2, [2]));
48
+ expect(host.requestUpdate).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("keeps independent memos per slot", () => {
52
+ const host = createHost();
53
+ const read = (n: number) => [useMemo(() => `a${n}`, [n]), useMemo(() => "b", [])];
54
+ render(host, () => read(1));
55
+ expect(render(host, () => read(2))).toEqual(["a2", "b"]);
56
+ });
57
+
58
+ it("recalculates after an unmount", () => {
59
+ const host = createHost();
60
+ const calculate = vi.fn(() => 1);
61
+ render(host, () => useMemo(calculate, []));
62
+ unmount(host);
63
+ render(host, () => useMemo(calculate, []));
64
+ expect(calculate).toHaveBeenCalledTimes(2);
65
+ });
66
+ });
@@ -1,16 +1,21 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
 
3
- import { useEffect } from "./use-effect.js";
4
- import { useState } from "./use-state.js";
3
+ import { equalsShallow } from "@adobe/data/equals-shallow";
4
+ import { Component_stack } from "./component/stack.js";
5
5
 
6
- export function useMemo<T>(calculateValue: () => T, dependencies: unknown[] = []): T {
7
- const [state, setState] = useState<T | undefined>(undefined);
8
- let currentValue = state;
9
-
10
- useEffect(() => {
11
- currentValue = calculateValue();
12
- setState(currentValue);
13
- }, dependencies);
6
+ interface Memo<T> {
7
+ value: T;
8
+ dependencies: unknown[];
9
+ }
14
10
 
15
- return currentValue!;
11
+ export function useMemo<T>(calculateValue: () => T, dependencies: unknown[] = []): T {
12
+ const component = Component_stack.active();
13
+ const hookIndex = component.hookIndex++;
14
+ const memo = component.hooks[hookIndex] as Memo<T> | undefined;
15
+ if (memo && equalsShallow(dependencies, memo.dependencies)) {
16
+ return memo.value;
17
+ }
18
+ const value = calculateValue();
19
+ component.hooks[hookIndex] = { value, dependencies } satisfies Memo<T>;
20
+ return value;
16
21
  }
@@ -0,0 +1,53 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Observe } from "@adobe/data/observe";
5
+ import { useObservableValues } from "./use-observable-values.js";
6
+ import { createHost, render, unmount } from "./test-host.test-utils.js";
7
+
8
+ describe("useObservableValues", () => {
9
+ it("returns undefined until every source has emitted, then all latest values", () => {
10
+ const host = createHost();
11
+ const [name, setName] = Observe.createEvent<string>();
12
+ const [count, setCount] = Observe.createEvent<number>();
13
+ const factory = () => ({ name, count });
14
+
15
+ expect(render(host, () => useObservableValues(factory))).toBeUndefined();
16
+ setName("a");
17
+ expect(render(host, () => useObservableValues(factory))).toBeUndefined();
18
+ setCount(1);
19
+ expect(render(host, () => useObservableValues(factory))).toEqual({ name: "a", count: 1 });
20
+ setName("b");
21
+ expect(render(host, () => useObservableValues(factory))).toEqual({ name: "b", count: 1 });
22
+ unmount(host);
23
+ });
24
+
25
+ it("combines sources that emit synchronously in the first render", () => {
26
+ const host = createHost();
27
+ const factory = () => ({ a: Observe.fromConstant(1), b: Observe.fromConstant("x") });
28
+ expect(render(host, () => useObservableValues(factory))).toEqual({ a: 1, b: "x" });
29
+ unmount(host);
30
+ });
31
+
32
+ it("calls the factory again only when dependencies change", () => {
33
+ const host = createHost();
34
+ const factory = vi.fn(() => ({ a: Observe.fromConstant(1) }));
35
+ render(host, () => useObservableValues(factory, [1]));
36
+ render(host, () => useObservableValues(factory, [1]));
37
+ render(host, () => useObservableValues(factory, [2]));
38
+ expect(factory).toHaveBeenCalledTimes(2);
39
+ unmount(host);
40
+ });
41
+
42
+ it("unsubscribes from every source on unmount", () => {
43
+ const host = createHost();
44
+ const unsubscribed = vi.fn();
45
+ const tracked = <T,>(value: T): Observe<T> => observer => {
46
+ observer(value);
47
+ return unsubscribed;
48
+ };
49
+ render(host, () => useObservableValues(() => ({ a: tracked(1), b: tracked(2) })));
50
+ unmount(host);
51
+ expect(unsubscribed).toHaveBeenCalledTimes(2);
52
+ });
53
+ });
@@ -0,0 +1,63 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Observe } from "@adobe/data/observe";
5
+ import { useObservable } from "./use-observable.js";
6
+ import { createHost, render, unmount } from "./test-host.test-utils.js";
7
+
8
+ function source<T>() {
9
+ const [observe, notify] = Observe.createEvent<T>();
10
+ const subscribe = vi.fn(observe);
11
+ return { observable: subscribe as Observe<T>, notify, subscribe };
12
+ }
13
+
14
+ describe("useObservable", () => {
15
+ it("returns undefined until the source emits, then the latest value", () => {
16
+ const host = createHost();
17
+ const { observable, notify } = source<number>();
18
+ expect(render(host, () => useObservable(observable))).toBeUndefined();
19
+ notify(1);
20
+ notify(2);
21
+ expect(render(host, () => useObservable(observable))).toBe(2);
22
+ });
23
+
24
+ it("returns a value emitted synchronously on subscribe in the same render", () => {
25
+ const host = createHost();
26
+ const observable: Observe<number> = observer => { observer(7); return () => {}; };
27
+ expect(render(host, () => useObservable(observable))).toBe(7);
28
+ });
29
+
30
+ it("requests a render for every notification, including a repeated reference", () => {
31
+ const host = createHost();
32
+ const { observable, notify } = source<{ count: number }>();
33
+ const value = { count: 0 };
34
+ render(host, () => useObservable(observable));
35
+ notify(value);
36
+ value.count = 1;
37
+ notify(value);
38
+ expect(host.requestUpdate).toHaveBeenCalledTimes(2);
39
+ expect(render(host, () => useObservable(observable))).toBe(value);
40
+ });
41
+
42
+ it("subscribes once per source and resubscribes when the source changes", () => {
43
+ const host = createHost();
44
+ const a = source<number>();
45
+ const b = source<number>();
46
+ render(host, () => useObservable(a.observable));
47
+ render(host, () => useObservable(a.observable));
48
+ render(host, () => useObservable(b.observable));
49
+ a.notify(1);
50
+ expect(a.subscribe).toHaveBeenCalledTimes(1);
51
+ expect(b.subscribe).toHaveBeenCalledTimes(1);
52
+ expect(host.requestUpdate).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it("unsubscribes on unmount", () => {
56
+ const host = createHost();
57
+ const { observable, notify } = source<number>();
58
+ render(host, () => useObservable(observable));
59
+ unmount(host);
60
+ notify(1);
61
+ expect(host.requestUpdate).not.toHaveBeenCalled();
62
+ });
63
+ });
@@ -1,15 +1,20 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
 
3
- import { useState } from "./use-state.js";
3
+ import { Component_stack } from "./component/stack.js";
4
4
  import { useEffect } from "./use-effect.js";
5
+ import { useRef } from "./use-ref.js";
5
6
  import { Observe } from "@adobe/data/observe";
6
7
 
8
+ /**
9
+ * Subscribes to `observable` and returns its latest value. Every notification
10
+ * requests a render, including one that repeats the previous value or reference.
11
+ */
7
12
  export function useObservable<T>(observable: Observe<T>): T | undefined {
8
- let [value, setValue] = useState<T | undefined>(undefined);
9
- useEffect(() => {
10
- return observable(newValue => {
11
- setValue(value = newValue);
12
- });
13
- }, [observable]);
14
- return value;
13
+ const component = Component_stack.active();
14
+ const latest = useRef<T | undefined>(undefined);
15
+ useEffect(() => observable(value => {
16
+ latest.current = value;
17
+ component.requestUpdate();
18
+ }), [observable]);
19
+ return latest.current;
15
20
  }
@@ -0,0 +1,59 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Vec2 } from "@adobe/data/math";
5
+ import { usePointerObserve } from "./use-pointer-observe.js";
6
+ import { createHost, render, unmount, type TestHost } from "./test-host.test-utils.js";
7
+
8
+ function placeAt(host: TestHost, left: number, top: number) {
9
+ host.getBoundingClientRect = () => ({ left, top, x: left, y: top, width: 100, height: 100, right: left + 100, bottom: top + 100, toJSON() {} });
10
+ }
11
+
12
+ const pointerMoveOver = (host: TestHost, clientX: number, clientY: number) =>
13
+ host.dispatchEvent(new PointerEvent("pointermove", { clientX, clientY }));
14
+
15
+ describe("usePointerObserve", () => {
16
+ it("emits pointer positions relative to the host", () => {
17
+ const host = createHost();
18
+ placeAt(host, 10, 20);
19
+ const positions: Vec2[] = [];
20
+ render(host, () => usePointerObserve([]))(position => positions.push(position));
21
+
22
+ pointerMoveOver(host, 15, 30);
23
+ pointerMoveOver(host, 50, 60);
24
+ expect(positions).toEqual([[5, 10], [40, 40]]);
25
+ unmount(host);
26
+ });
27
+
28
+ it("returns the same stream while dependencies are equal", () => {
29
+ const host = createHost();
30
+ const first = render(host, () => usePointerObserve([1]));
31
+ expect(render(host, () => usePointerObserve([1]))).toBe(first);
32
+ expect(render(host, () => usePointerObserve([2]))).not.toBe(first);
33
+ unmount(host);
34
+ });
35
+
36
+ it("delivers to every subscriber", () => {
37
+ const host = createHost();
38
+ placeAt(host, 0, 0);
39
+ const observe = render(host, () => usePointerObserve([]));
40
+ const a: Vec2[] = [];
41
+ const b: Vec2[] = [];
42
+ observe(position => a.push(position));
43
+ observe(position => b.push(position));
44
+ pointerMoveOver(host, 1, 2);
45
+ expect(a).toEqual([[1, 2]]);
46
+ expect(b).toEqual([[1, 2]]);
47
+ unmount(host);
48
+ });
49
+
50
+ it("stops listening on unmount", () => {
51
+ const host = createHost();
52
+ placeAt(host, 0, 0);
53
+ const positions: Vec2[] = [];
54
+ render(host, () => usePointerObserve([]))(position => positions.push(position));
55
+ unmount(host);
56
+ pointerMoveOver(host, 1, 1);
57
+ expect(positions).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,50 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it } from "vitest";
4
+ import { useRef } from "./use-ref.js";
5
+ import { createHost, render, unmount } from "./test-host.test-utils.js";
6
+
7
+ describe("useRef", () => {
8
+ it("returns the same ref across renders and keeps its mutations", () => {
9
+ const host = createHost();
10
+ const ref = render(host, () => useRef(1));
11
+ ref.current = 2;
12
+
13
+ const next = render(host, () => useRef(1));
14
+ expect(next).toBe(ref);
15
+ expect(next.current).toBe(2);
16
+ });
17
+
18
+ it("ignores the initial value after the first render", () => {
19
+ const host = createHost();
20
+ render(host, () => useRef("first"));
21
+ expect(render(host, () => useRef("second")).current).toBe("first");
22
+ });
23
+
24
+ it("stores a function as a value", () => {
25
+ const host = createHost();
26
+ const callback = () => 1;
27
+ expect(render(host, () => useRef(callback)).current).toBe(callback);
28
+ });
29
+
30
+ it("does not request a render when current changes", () => {
31
+ const host = createHost();
32
+ render(host, () => useRef(0)).current = 1;
33
+ expect(host.requestUpdate).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it("keeps independent refs per slot and per host", () => {
37
+ const host = createHost();
38
+ const other = createHost();
39
+ const [a, b] = render(host, () => [useRef(0), useRef(0)]);
40
+ const c = render(other, () => useRef(0));
41
+ expect(new Set([a, b, c]).size).toBe(3);
42
+ });
43
+
44
+ it("starts from the initial value after an unmount", () => {
45
+ const host = createHost();
46
+ render(host, () => useRef(0)).current = 5;
47
+ unmount(host);
48
+ expect(render(host, () => useRef(0)).current).toBe(0);
49
+ });
50
+ });
@@ -1,8 +1,9 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
 
3
- import { useState } from "./use-state.js";
3
+ import { Component_stack } from "./component/stack.js";
4
4
 
5
5
  export function useRef<T>(initialValue: T): { current: T } {
6
- const [state] = useState({ current: initialValue });
7
- return state;
6
+ const component = Component_stack.active();
7
+ const hookIndex = component.hookIndex++;
8
+ return component.hooks[hookIndex] ??= { current: initialValue };
8
9
  }