@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,57 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { html } from "lit";
4
+ import type { Service } from "@adobe/data/service";
5
+ import "./application-host.js";
6
+
7
+ function deferred<T>() {
8
+ let resolve!: (value: T) => void;
9
+ const promise = new Promise<T>(r => { resolve = r; });
10
+ return { promise, resolve };
11
+ }
12
+
13
+ afterEach(() => {
14
+ document.body.replaceChildren();
15
+ });
16
+
17
+ describe("ApplicationHost", () => {
18
+ it("creates one service across a DOM move", async () => {
19
+ const service = { serviceName: "main" } as unknown as Service;
20
+ const pending = deferred<Service>();
21
+ const createService = vi.fn(() => pending.promise);
22
+ const host = document.createElement("application-host");
23
+ host.createService = createService;
24
+ host.renderElement = () => html``;
25
+
26
+ const [parentA, parentB] = [document.createElement("div"), document.createElement("div")];
27
+ document.body.append(parentA, parentB);
28
+ parentA.append(host);
29
+ parentB.append(host);
30
+ pending.resolve(service);
31
+ await pending.promise;
32
+ await host.updateComplete;
33
+
34
+ expect(createService).toHaveBeenCalledTimes(1);
35
+ expect(host.service).toBe(service);
36
+
37
+ parentA.append(host);
38
+ await host.updateComplete;
39
+ expect(createService).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it("retries creation after a failure", async () => {
43
+ const service = { serviceName: "main" } as unknown as Service;
44
+ const createService = vi.fn()
45
+ .mockRejectedValueOnce(new Error("offline"))
46
+ .mockResolvedValueOnce(service);
47
+ const host = document.createElement("application-host");
48
+ host.createService = createService;
49
+ host.renderElement = () => html``;
50
+
51
+ await expect(host.connectedCallback()).rejects.toThrow("offline");
52
+ await host.connectedCallback();
53
+
54
+ expect(createService).toHaveBeenCalledTimes(2);
55
+ expect(host.service).toBe(service);
56
+ });
57
+ });
@@ -3,13 +3,12 @@
3
3
  import { LitElement, nothing, TemplateResult, css } from 'lit';
4
4
  import { customElement, property } from 'lit/decorators.js';
5
5
  import { Service } from '@adobe/data/service';
6
- import { ApplicationElement } from './application-element.js';
7
6
 
8
7
  const tagName = "application-host";
9
8
 
10
9
  declare global {
11
10
  interface HTMLElementTagNameMap {
12
- 'application-element': ApplicationElement<Service>;
11
+ 'application-host': ApplicationHost;
13
12
  }
14
13
  }
15
14
 
@@ -31,9 +30,23 @@ export class ApplicationHost<MainService extends Service = Service> extends LitE
31
30
  @property({ attribute: false })
32
31
  public service!: MainService;
33
32
 
33
+ #pendingService?: Promise<MainService>;
34
+
35
+ /**
36
+ * Creates the service once per host: reconnecting, including a DOM move, reuses the
37
+ * service or the in-flight creation. A failed creation is retried on the next connect.
38
+ */
34
39
  override async connectedCallback() {
35
40
  super.connectedCallback();
36
- this.service = await this.createService();
41
+ if (this.service) return;
42
+ const pending = this.#pendingService ??= this.createService();
43
+ try {
44
+ const service = await pending;
45
+ this.service ??= service;
46
+ } catch (error) {
47
+ if (this.#pendingService === pending) this.#pendingService = undefined;
48
+ throw error;
49
+ }
37
50
  }
38
51
 
39
52
  override render() {
@@ -0,0 +1,111 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { html } from "lit";
5
+ import { Database } from "@adobe/data/ecs";
6
+ import { DatabaseElement } from "./database-element.js";
7
+
8
+ const rootPlugin = Database.Plugin.create({ resources: { name: { default: "root" as string } } });
9
+ const childPlugin = Database.Plugin.create({ resources: { count: { default: 0 as number } } });
10
+
11
+ class ProbeElement extends DatabaseElement<typeof childPlugin> {
12
+ get plugin() {
13
+ return childPlugin;
14
+ }
15
+ fullDatabase() {
16
+ return this.database;
17
+ }
18
+ override render() {
19
+ return html`${this.database.resources.count}`;
20
+ }
21
+ }
22
+ customElements.define("database-element-probe", ProbeElement);
23
+
24
+ class UnrenderedElement extends DatabaseElement<typeof childPlugin> {
25
+ get plugin() {
26
+ return childPlugin;
27
+ }
28
+ }
29
+ customElements.define("database-element-unrendered", UnrenderedElement);
30
+
31
+ const create = () => document.createElement("database-element-probe") as ProbeElement;
32
+ const resources = (db: unknown) => (db as { resources: Record<string, unknown> }).resources;
33
+
34
+ afterEach(async () => {
35
+ document.body.replaceChildren();
36
+ await Promise.resolve();
37
+ });
38
+
39
+ describe("DatabaseElement", () => {
40
+ it("creates its own database when no ancestor provides one", async () => {
41
+ const element = create();
42
+ document.body.append(element);
43
+ expect(Database.is(element.fullDatabase())).toBe(true);
44
+ expect(resources(element.service).count).toBe(0);
45
+ await element.updateComplete;
46
+ expect(element.shadowRoot?.textContent).toBe("0");
47
+ });
48
+
49
+ it("extends the nearest ancestor database with its plugin", () => {
50
+ const root = Database.create(rootPlugin);
51
+ const parent = Object.assign(document.createElement("div"), { service: root });
52
+ document.body.append(parent);
53
+ const element = create();
54
+ parent.append(element);
55
+ expect(resources(element.service).name).toBe("root");
56
+ expect(resources(element.service).count).toBe(0);
57
+ });
58
+
59
+ it("finds the database of an ancestor database element", () => {
60
+ const outer = create();
61
+ document.body.append(outer);
62
+ const inner = create();
63
+ outer.append(inner);
64
+ expect(inner.fullDatabase()).toBe(outer.fullDatabase());
65
+ });
66
+
67
+ it("skips ancestor services that are not databases", () => {
68
+ const parent = Object.assign(document.createElement("div"), { service: { serviceName: "other" } });
69
+ document.body.append(parent);
70
+ const element = create();
71
+ parent.append(element);
72
+ expect(Database.is(element.fullDatabase())).toBe(true);
73
+ });
74
+
75
+ it("keeps an injected database and requests an update when it changes", () => {
76
+ const injected = Database.create(childPlugin);
77
+ const element = create();
78
+ const requestUpdate = vi.spyOn(element, "requestUpdate");
79
+ element.service = injected;
80
+ expect(requestUpdate).toHaveBeenCalledWith("service", undefined);
81
+ document.body.append(element);
82
+ expect(element.fullDatabase()).toBe(injected);
83
+ });
84
+
85
+ it("routes a database bound before the element upgraded through the service setter", () => {
86
+ const injected = Database.create(childPlugin);
87
+ const element = document.createElement("database-element-late");
88
+ (element as unknown as { service: unknown }).service = injected;
89
+ document.body.append(element);
90
+
91
+ class LateElement extends ProbeElement {}
92
+ customElements.define("database-element-late", LateElement);
93
+ const upgraded = element as unknown as LateElement;
94
+ expect(Object.prototype.hasOwnProperty.call(upgraded, "service")).toBe(false);
95
+ expect(upgraded.fullDatabase()).toBe(injected);
96
+ });
97
+
98
+ it("keeps its database across a reconnect", () => {
99
+ const element = create();
100
+ document.body.append(element);
101
+ const database = element.fullDatabase();
102
+ element.remove();
103
+ document.body.append(element);
104
+ expect(element.fullDatabase()).toBe(database);
105
+ });
106
+
107
+ it("throws from the default render", () => {
108
+ const element = new UnrenderedElement();
109
+ expect(() => element.render()).toThrow("render function must be overridden");
110
+ });
111
+ });
@@ -0,0 +1,29 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { iterateSelfAndAncestors } from "./iterate-self-and-ancestors.js";
4
+
5
+ const tags = (element: Element) => [...iterateSelfAndAncestors(element)].map(e => e.tagName.toLowerCase());
6
+
7
+ describe("iterateSelfAndAncestors", () => {
8
+ it("walks light-DOM parents", () => {
9
+ const section = document.createElement("section");
10
+ const span = section.appendChild(document.createElement("div")).appendChild(document.createElement("span"));
11
+ expect(tags(span)).toEqual(["span", "div", "section"]);
12
+ });
13
+
14
+ it("crosses from a shadow root to its host", () => {
15
+ const host = document.createElement("section");
16
+ const inner = host.attachShadow({ mode: "open" }).appendChild(document.createElement("article"));
17
+ expect(tags(inner)).toEqual(["article", "section"]);
18
+ });
19
+
20
+ it("follows an assigned slot through the shadow tree before the light parent", () => {
21
+ const host = document.createElement("section");
22
+ const article = host.attachShadow({ mode: "open" }).appendChild(document.createElement("article"));
23
+ const slot = article.appendChild(document.createElement("slot"));
24
+ const span = host.appendChild(document.createElement("span"));
25
+ Object.defineProperty(span, "assignedSlot", { value: slot });
26
+
27
+ expect(tags(span)).toEqual(["span", "slot", "article", "section"]);
28
+ });
29
+ });
@@ -1,13 +1,13 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
 
3
+ /** Yields `element` and its ancestors along the composed tree: assigned slots, parents, then shadow hosts. */
3
4
  export function* iterateSelfAndAncestors(element: Element): IterableIterator<Element> {
4
5
  let current: Element | null = element;
5
6
 
6
7
  while (current) {
7
8
  yield current;
8
9
 
9
- if (current instanceof HTMLSlotElement && current.assignedSlot) {
10
- // Move to the slot's parent if the current element is assigned to a slot
10
+ if (current.assignedSlot) {
11
11
  current = current.assignedSlot;
12
12
  } else if (current.parentNode instanceof Element) {
13
13
  // Move up to the parent node if it is an element
@@ -0,0 +1,45 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { attachDecorator } from "./attach-decorator.js";
4
+
5
+ const double = (_target: unknown, _key: string, descriptor: PropertyDescriptor) => {
6
+ const original = descriptor.value as (this: unknown, ...args: unknown[]) => number;
7
+ return { ...descriptor, value(this: unknown, ...args: unknown[]) { return original.apply(this, args) * 2; } };
8
+ };
9
+
10
+ describe("attachDecorator", () => {
11
+ it("decorates an inherited method and installs it on the instance", () => {
12
+ class Base { value = 3; get() { return this.value; } }
13
+ const instance = new Base();
14
+ attachDecorator(instance, "get", double);
15
+ expect(instance.get()).toBe(6);
16
+ expect(Object.prototype.hasOwnProperty.call(instance, "get")).toBe(true);
17
+ expect(new Base().get()).toBe(3);
18
+ });
19
+
20
+ it("decorates an own method", () => {
21
+ const instance = { get: () => 2 };
22
+ attachDecorator(instance, "get", double);
23
+ expect(instance.get()).toBe(4);
24
+ });
25
+
26
+ it("passes the instance, property name, and current method to the decorator", () => {
27
+ const method = () => 1;
28
+ const instance = { method };
29
+ const decorator = vi.fn((_target: unknown, _key: string, descriptor: PropertyDescriptor) => descriptor);
30
+ attachDecorator(instance, "method", decorator);
31
+ expect(decorator).toHaveBeenCalledWith(instance, "method", expect.objectContaining({ value: method }));
32
+ });
33
+
34
+ it("applies repeated decorators in order", () => {
35
+ const instance = { get: () => 1 };
36
+ attachDecorator(instance, "get", double);
37
+ attachDecorator(instance, "get", double);
38
+ expect(instance.get()).toBe(4);
39
+ });
40
+
41
+ it("throws for a missing property", () => {
42
+ const instance = {} as { missing?: () => void };
43
+ expect(() => attachDecorator(instance, "missing", double)).toThrow("Property missing does not exist");
44
+ });
45
+ });
@@ -0,0 +1,65 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { ReactiveController } from "lit";
4
+ import type { Component } from "./component.js";
5
+ import { installHooksController } from "./hooks-controller.js";
6
+
7
+ afterEach(() => {
8
+ vi.unstubAllGlobals();
9
+ });
10
+
11
+ /** Disconnects a host and returns its finalization, captured instead of queued. */
12
+ function disconnect(hooks: { dispose?: () => void }[]) {
13
+ const controllers: ReactiveController[] = [];
14
+ const host = Object.assign(new EventTarget(), {
15
+ isConnected: false,
16
+ hooks,
17
+ hookIndex: hooks.length,
18
+ updatedListeners: new Set<() => void>(),
19
+ requestUpdate: vi.fn(),
20
+ addController: (controller: ReactiveController) => controllers.push(controller),
21
+ }) as Component & EventTarget;
22
+ let finalize!: () => void;
23
+ vi.stubGlobal("queueMicrotask", (callback: () => void) => { finalize = callback; });
24
+ installHooksController(host);
25
+ controllers[0].hostDisconnected?.();
26
+ return { host, finalize };
27
+ }
28
+
29
+ describe("installHooksController cleanup failures", () => {
30
+ it("disposes every hook and resets slots when a disposer throws", () => {
31
+ const later = vi.fn();
32
+ const { host, finalize } = disconnect([
33
+ { dispose: () => { throw new Error("first failed"); } },
34
+ { dispose: later },
35
+ ]);
36
+
37
+ expect(finalize).toThrow("first failed");
38
+ expect(later).toHaveBeenCalledTimes(1);
39
+ expect(host.hooks).toEqual([]);
40
+ expect(host.hookIndex).toBe(0);
41
+ });
42
+
43
+ it("reports multiple disposer failures together", () => {
44
+ const { finalize } = disconnect([
45
+ { dispose: () => { throw new Error("a"); } },
46
+ { dispose: () => { throw new Error("b"); } },
47
+ ]);
48
+ let caught: unknown;
49
+ try {
50
+ finalize();
51
+ } catch (error) {
52
+ caught = error;
53
+ }
54
+ expect(caught).toBeInstanceOf(AggregateError);
55
+ expect((caught as AggregateError).errors.map(error => (error as Error).message)).toEqual(["a", "b"]);
56
+ });
57
+
58
+ it("does not dispose a hook twice after a failed finalization", () => {
59
+ const dispose = vi.fn(() => { throw new Error("failed"); });
60
+ const { finalize } = disconnect([{ dispose }]);
61
+ expect(finalize).toThrow();
62
+ expect(finalize).not.toThrow();
63
+ expect(dispose).toHaveBeenCalledTimes(1);
64
+ });
65
+ });
@@ -20,17 +20,27 @@ function isReactiveHost(host: Component): host is ReactiveHost {
20
20
  * Finalize a host: dispose every hook slot's cleanup and clear the slots so the
21
21
  * next render re-initializes from scratch. Only slots that expose a `dispose`
22
22
  * function (effects, subscriptions) are torn down; value slots (state, memo,
23
- * ref) are simply dropped.
23
+ * ref) are simply dropped. Every disposer runs even if an earlier one throws;
24
+ * the failures are rethrown afterwards.
24
25
  */
25
26
  function finalizeHooks(host: Component): void {
26
27
  const { hooks } = host;
27
- if (hooks) {
28
- for (const hook of hooks) {
28
+ host.hooks = [];
29
+ host.hookIndex = 0;
30
+ const errors: unknown[] = [];
31
+ for (const hook of hooks ?? []) {
32
+ try {
29
33
  (hook as { dispose?: () => void } | undefined)?.dispose?.();
34
+ } catch (error) {
35
+ errors.push(error);
30
36
  }
31
37
  }
32
- host.hooks = [];
33
- host.hookIndex = 0;
38
+ if (errors.length === 1) {
39
+ throw errors[0];
40
+ }
41
+ if (errors.length > 1) {
42
+ throw new AggregateError(errors, "Hook cleanup failed");
43
+ }
34
44
  }
35
45
 
36
46
  /**
@@ -0,0 +1,37 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, expect, it } from "vitest";
3
+ import type { Component } from "./component.js";
4
+ import { Component_stack } from "./stack.js";
5
+
6
+ const createComponent = () => ({ hookIndex: 5 }) as unknown as Component;
7
+
8
+ describe("Component_stack", () => {
9
+ it("makes the most recently pushed component active and restores the previous one on pop", () => {
10
+ const outer = createComponent();
11
+ const inner = createComponent();
12
+ Component_stack.push(outer);
13
+ Component_stack.push(inner);
14
+ expect(Component_stack.active()).toBe(inner);
15
+ Component_stack.pop();
16
+ expect(Component_stack.active()).toBe(outer);
17
+ Component_stack.pop();
18
+ expect(Component_stack.active()).toBeUndefined();
19
+ });
20
+
21
+ it("resets the hook cursor and creates the slot array on push", () => {
22
+ const component = createComponent();
23
+ Component_stack.push(component);
24
+ Component_stack.pop();
25
+ expect(component.hookIndex).toBe(0);
26
+ expect(component.hooks).toEqual([]);
27
+ });
28
+
29
+ it("keeps existing hook slots on push", () => {
30
+ const component = createComponent();
31
+ const hooks = [1, 2];
32
+ component.hooks = hooks;
33
+ Component_stack.push(component);
34
+ Component_stack.pop();
35
+ expect(component.hooks).toBe(hooks);
36
+ });
37
+ });
@@ -0,0 +1,43 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { vi } from "vitest";
3
+ import type { Component } from "./component/component.js";
4
+ import { Component_stack } from "./component/stack.js";
5
+
6
+ export type TestHost = HTMLElement & Component;
7
+
8
+ /** A detached element that hooks can render into; `requestUpdate` is a mock. */
9
+ export function createHost(): TestHost {
10
+ return Object.assign(document.createElement("div"), {
11
+ hookIndex: 0,
12
+ hooks: [] as unknown[],
13
+ updatedListeners: new Set<() => void>(),
14
+ requestUpdate: vi.fn(),
15
+ }) as unknown as TestHost;
16
+ }
17
+
18
+ export function render<T>(host: Component, hooks: () => T): T {
19
+ Component_stack.push(host);
20
+ try {
21
+ return hooks();
22
+ } finally {
23
+ Component_stack.pop();
24
+ }
25
+ }
26
+
27
+ /** Disposes and clears every hook slot, as a genuine unmount does. */
28
+ export function unmount(host: Component): void {
29
+ for (const hook of host.hooks) {
30
+ (hook as { dispose?: () => void } | undefined)?.dispose?.();
31
+ }
32
+ host.hooks = [];
33
+ host.hookIndex = 0;
34
+ }
35
+
36
+ const pointer = (type: string, clientX: number, pointerId: number, button = 0) =>
37
+ new PointerEvent(type, { clientX, clientY: 0, pointerId, button, bubbles: true });
38
+
39
+ export const pointerDown = (target: EventTarget, x = 0, pointerId = 1, button = 0) =>
40
+ target.dispatchEvent(pointer("pointerdown", x, pointerId, button));
41
+ export const pointerMove = (x: number, pointerId = 1) => window.dispatchEvent(pointer("pointermove", x, pointerId));
42
+ export const pointerUp = (x: number, pointerId = 1) => window.dispatchEvent(pointer("pointerup", x, pointerId));
43
+ export const pointerCancel = (pointerId = 1) => window.dispatchEvent(pointer("pointercancel", 0, pointerId));
@@ -0,0 +1,88 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { useConnected } from "./use-connected.js";
5
+ import { createHost, render, unmount, type TestHost } from "./test-host.test-utils.js";
6
+
7
+ const connected = (host: TestHost) => host.dispatchEvent(new Event("connected"));
8
+ const disconnected = (host: TestHost) => host.dispatchEvent(new Event("disconnected"));
9
+
10
+ function mountConnected(host: TestHost) {
11
+ document.body.append(host);
12
+ return host;
13
+ }
14
+
15
+ describe("useConnected", () => {
16
+ it("runs setup immediately when the host is already connected", () => {
17
+ const host = mountConnected(createHost());
18
+ const setup = vi.fn();
19
+ render(host, () => useConnected(setup));
20
+ expect(setup).toHaveBeenCalledTimes(1);
21
+ unmount(host);
22
+ host.remove();
23
+ });
24
+
25
+ it("waits for the connected edge when the host is detached", () => {
26
+ const host = createHost();
27
+ const setup = vi.fn();
28
+ render(host, () => useConnected(setup));
29
+ expect(setup).not.toHaveBeenCalled();
30
+ connected(host);
31
+ expect(setup).toHaveBeenCalledTimes(1);
32
+ unmount(host);
33
+ });
34
+
35
+ it("tears down on each disconnect and sets up again on each connect", () => {
36
+ const host = createHost();
37
+ const events: string[] = [];
38
+ render(host, () => useConnected(() => {
39
+ events.push("setup");
40
+ return () => events.push("teardown");
41
+ }));
42
+ connected(host);
43
+ connected(host);
44
+ disconnected(host);
45
+ disconnected(host);
46
+ connected(host);
47
+ expect(events).toEqual(["setup", "teardown", "setup"]);
48
+ unmount(host);
49
+ });
50
+
51
+ it("uses the latest callback on the next connect without re-subscribing", () => {
52
+ const host = createHost();
53
+ const first = vi.fn();
54
+ const second = vi.fn();
55
+ render(host, () => useConnected(first));
56
+ render(host, () => useConnected(second));
57
+ connected(host);
58
+ expect(first).not.toHaveBeenCalled();
59
+ expect(second).toHaveBeenCalledTimes(1);
60
+ unmount(host);
61
+ });
62
+
63
+ it("re-runs setup when dependencies change while connected", () => {
64
+ const host = mountConnected(createHost());
65
+ const events: string[] = [];
66
+ const callback = (n: number) => () => {
67
+ events.push(`setup ${n}`);
68
+ return () => events.push(`teardown ${n}`);
69
+ };
70
+ render(host, () => useConnected(callback(1), [1]));
71
+ render(host, () => useConnected(callback(2), [2]));
72
+ expect(events).toEqual(["setup 1", "teardown 1", "setup 2"]);
73
+ unmount(host);
74
+ host.remove();
75
+ });
76
+
77
+ it("tears down and stops listening on unmount", () => {
78
+ const host = createHost();
79
+ const teardown = vi.fn();
80
+ const setup = vi.fn(() => teardown);
81
+ render(host, () => useConnected(setup));
82
+ connected(host);
83
+ unmount(host);
84
+ connected(host);
85
+ expect(teardown).toHaveBeenCalledTimes(1);
86
+ expect(setup).toHaveBeenCalledTimes(1);
87
+ });
88
+ });
@@ -0,0 +1,69 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { vi } from "vitest";
5
+ import { useDebounce } from "./use-debounce.js";
6
+ import { createHost, render, unmount } from "./test-host.test-utils.js";
7
+
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ describe("useDebounce", () => {
17
+ it("returns the initial value immediately", () => {
18
+ const host = createHost();
19
+ expect(render(host, () => useDebounce("a", 100))).toBe("a");
20
+ });
21
+
22
+ it("returns a new value only after the delay", () => {
23
+ const host = createHost();
24
+ render(host, () => useDebounce("a", 100));
25
+ render(host, () => useDebounce("b", 100));
26
+
27
+ vi.advanceTimersByTime(99);
28
+ expect(render(host, () => useDebounce("b", 100))).toBe("a");
29
+ vi.advanceTimersByTime(1);
30
+ expect(render(host, () => useDebounce("b", 100))).toBe("b");
31
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
32
+ unmount(host);
33
+ });
34
+
35
+ it("restarts the delay on each change and keeps only the last value", () => {
36
+ const host = createHost();
37
+ render(host, () => useDebounce("a", 100));
38
+ render(host, () => useDebounce("b", 100));
39
+ vi.advanceTimersByTime(60);
40
+ render(host, () => useDebounce("c", 100));
41
+ vi.advanceTimersByTime(60);
42
+ expect(render(host, () => useDebounce("c", 100))).toBe("a");
43
+ vi.advanceTimersByTime(40);
44
+ expect(render(host, () => useDebounce("c", 100))).toBe("c");
45
+ unmount(host);
46
+ });
47
+
48
+ it("restarts the delay when the delay changes", () => {
49
+ const host = createHost();
50
+ render(host, () => useDebounce("a", 100));
51
+ render(host, () => useDebounce("b", 100));
52
+ vi.advanceTimersByTime(50);
53
+ render(host, () => useDebounce("b", 200));
54
+ vi.advanceTimersByTime(150);
55
+ expect(render(host, () => useDebounce("b", 200))).toBe("a");
56
+ vi.advanceTimersByTime(50);
57
+ expect(render(host, () => useDebounce("b", 200))).toBe("b");
58
+ unmount(host);
59
+ });
60
+
61
+ it("cancels the pending update on unmount", () => {
62
+ const host = createHost();
63
+ render(host, () => useDebounce("a", 100));
64
+ render(host, () => useDebounce("b", 100));
65
+ unmount(host);
66
+ vi.advanceTimersByTime(100);
67
+ expect(host.requestUpdate).not.toHaveBeenCalled();
68
+ });
69
+ });