@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.
- package/README.md +22 -12
- package/dist/elements/application-host.d.ts +6 -2
- package/dist/elements/application-host.js +17 -1
- package/dist/elements/application-host.js.map +1 -1
- package/dist/functions/iterate-self-and-ancestors.d.ts +1 -0
- package/dist/functions/iterate-self-and-ancestors.js +2 -2
- package/dist/functions/iterate-self-and-ancestors.js.map +1 -1
- package/dist/hooks/component/hooks-controller.js +16 -5
- package/dist/hooks/component/hooks-controller.js.map +1 -1
- package/dist/hooks/use-drag-observe.js +2 -1
- package/dist/hooks/use-drag-observe.js.map +1 -1
- package/dist/hooks/use-drag-transaction.d.ts +5 -0
- package/dist/hooks/use-drag-transaction.js +42 -22
- package/dist/hooks/use-drag-transaction.js.map +1 -1
- package/dist/hooks/use-draggable.js +70 -48
- package/dist/hooks/use-draggable.js.map +1 -1
- package/dist/hooks/use-effect.js +6 -2
- package/dist/hooks/use-effect.js.map +1 -1
- package/dist/hooks/use-element.d.ts +4 -2
- package/dist/hooks/use-element.js +10 -51
- package/dist/hooks/use-element.js.map +1 -1
- package/dist/hooks/use-memo.js +11 -9
- package/dist/hooks/use-memo.js.map +1 -1
- package/dist/hooks/use-observable.d.ts +4 -0
- package/dist/hooks/use-observable.js +13 -8
- package/dist/hooks/use-observable.js.map +1 -1
- package/dist/hooks/use-ref.js +4 -3
- package/dist/hooks/use-ref.js.map +1 -1
- package/dist/hooks/use-resize-observer.js +5 -2
- package/dist/hooks/use-resize-observer.js.map +1 -1
- package/dist/hooks/use-resize-observer.test.d.ts +1 -0
- package/dist/hooks/use-resize-observer.test.js +102 -0
- package/dist/hooks/use-resize-observer.test.js.map +1 -0
- package/dist/hooks/use-state.js +9 -7
- package/dist/hooks/use-state.js.map +1 -1
- package/dist/hooks/use-state.test.d.ts +1 -0
- package/dist/hooks/use-state.test.js +83 -0
- package/dist/hooks/use-state.test.js.map +1 -0
- package/dist/hooks/use-window-event.js +1 -1
- package/dist/hooks/use-window-event.js.map +1 -1
- package/package.json +3 -3
- package/src/decorators/apply-decorator.test.ts +37 -0
- package/src/decorators/apply-service-decorators.test.ts +53 -0
- package/src/decorators/require-service.test.ts +31 -0
- package/src/elements/application-element.test.ts +80 -0
- package/src/elements/application-host.test.ts +57 -0
- package/src/elements/application-host.ts +16 -3
- package/src/elements/database-element.test.ts +111 -0
- package/src/functions/iterate-self-and-ancestors.test.ts +29 -0
- package/src/functions/iterate-self-and-ancestors.ts +2 -2
- package/src/hooks/attach-decorator.test.ts +45 -0
- package/src/hooks/component/hooks-controller.cleanup.test.ts +65 -0
- package/src/hooks/component/hooks-controller.ts +15 -5
- package/src/hooks/component/stack.test.ts +37 -0
- package/src/hooks/test-host.test-utils.ts +43 -0
- package/src/hooks/use-connected.test.ts +88 -0
- package/src/hooks/use-debounce.test.ts +69 -0
- package/src/hooks/use-drag-generator.test.ts +67 -0
- package/src/hooks/use-drag-observe.test.ts +74 -0
- package/src/hooks/use-drag-observe.ts +2 -1
- package/src/hooks/use-drag-transaction.test.ts +109 -0
- package/src/hooks/use-drag-transaction.ts +48 -27
- package/src/hooks/use-draggable.test.ts +148 -0
- package/src/hooks/use-draggable.ts +73 -48
- package/src/hooks/use-effect.test.ts +51 -0
- package/src/hooks/use-effect.ts +5 -2
- package/src/hooks/use-element.test.ts +82 -0
- package/src/hooks/use-element.ts +17 -60
- package/src/hooks/use-memo.test.ts +66 -0
- package/src/hooks/use-memo.ts +16 -11
- package/src/hooks/use-observable-values.test.ts +53 -0
- package/src/hooks/use-observable.test.ts +63 -0
- package/src/hooks/use-observable.ts +13 -8
- package/src/hooks/use-pointer-observe.test.ts +59 -0
- package/src/hooks/use-ref.test.ts +50 -0
- package/src/hooks/use-ref.ts +4 -3
- package/src/hooks/use-resize-observer.test.ts +114 -0
- package/src/hooks/use-resize-observer.ts +5 -2
- package/src/hooks/use-state.test.ts +108 -0
- package/src/hooks/use-state.ts +14 -9
- package/src/hooks/use-updated.test.ts +98 -0
- package/src/hooks/use-window-event.test.ts +61 -0
- package/src/hooks/use-window-event.ts +1 -1
- package/src/hooks/with-hooks.test.ts +45 -0
- package/src/index.test.ts +26 -0
- package/src/public-types.type-test.ts +17 -0
- package/tsconfig.json +1 -0
- package/tsconfig.test.json +9 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
+
});
|
package/src/hooks/use-effect.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
+
});
|
package/src/hooks/use-element.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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
|
-
|
|
23
|
-
const
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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
|
+
});
|
package/src/hooks/use-memo.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { equalsShallow } from "@adobe/data/equals-shallow";
|
|
4
|
+
import { Component_stack } from "./component/stack.js";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}, [observable]);
|
|
14
|
-
return
|
|
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
|
+
});
|
package/src/hooks/use-ref.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Component_stack } from "./component/stack.js";
|
|
4
4
|
|
|
5
5
|
export function useRef<T>(initialValue: T): { current: T } {
|
|
6
|
-
const
|
|
7
|
-
|
|
6
|
+
const component = Component_stack.active();
|
|
7
|
+
const hookIndex = component.hookIndex++;
|
|
8
|
+
return component.hooks[hookIndex] ??= { current: initialValue };
|
|
8
9
|
}
|