@adobe/data-lit 0.10.31 → 0.10.33

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 (76) hide show
  1. package/README.md +12 -14
  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-window-event.js +1 -1
  30. package/dist/hooks/use-window-event.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/decorators/apply-decorator.test.ts +37 -0
  33. package/src/decorators/apply-service-decorators.test.ts +53 -0
  34. package/src/decorators/require-service.test.ts +31 -0
  35. package/src/elements/application-element.test.ts +80 -0
  36. package/src/elements/application-host.test.ts +57 -0
  37. package/src/elements/application-host.ts +16 -3
  38. package/src/elements/database-element.test.ts +111 -0
  39. package/src/functions/iterate-self-and-ancestors.test.ts +29 -0
  40. package/src/functions/iterate-self-and-ancestors.ts +2 -2
  41. package/src/hooks/attach-decorator.test.ts +45 -0
  42. package/src/hooks/component/hooks-controller.cleanup.test.ts +65 -0
  43. package/src/hooks/component/hooks-controller.ts +15 -5
  44. package/src/hooks/component/stack.test.ts +37 -0
  45. package/src/hooks/test-host.test-utils.ts +43 -0
  46. package/src/hooks/use-connected.test.ts +88 -0
  47. package/src/hooks/use-debounce.test.ts +69 -0
  48. package/src/hooks/use-drag-generator.test.ts +67 -0
  49. package/src/hooks/use-drag-observe.test.ts +74 -0
  50. package/src/hooks/use-drag-observe.ts +2 -1
  51. package/src/hooks/use-drag-transaction.test.ts +109 -0
  52. package/src/hooks/use-drag-transaction.ts +48 -27
  53. package/src/hooks/use-draggable.test.ts +148 -0
  54. package/src/hooks/use-draggable.ts +73 -48
  55. package/src/hooks/use-effect.test.ts +51 -0
  56. package/src/hooks/use-effect.ts +5 -2
  57. package/src/hooks/use-element.test.ts +82 -0
  58. package/src/hooks/use-element.ts +17 -60
  59. package/src/hooks/use-memo.test.ts +66 -0
  60. package/src/hooks/use-memo.ts +16 -11
  61. package/src/hooks/use-observable-values.test.ts +53 -0
  62. package/src/hooks/use-observable.test.ts +63 -0
  63. package/src/hooks/use-observable.ts +13 -8
  64. package/src/hooks/use-pointer-observe.test.ts +59 -0
  65. package/src/hooks/use-ref.test.ts +50 -0
  66. package/src/hooks/use-ref.ts +4 -3
  67. package/src/hooks/use-updated.test.ts +98 -0
  68. package/src/hooks/use-window-event.test.ts +61 -0
  69. package/src/hooks/use-window-event.ts +1 -1
  70. package/src/hooks/with-hooks.test.ts +45 -0
  71. package/src/index.test.ts +26 -0
  72. package/src/public-types.type-test.ts +17 -0
  73. package/tsconfig.json +1 -0
  74. package/tsconfig.test.json +9 -0
  75. package/tsconfig.tsbuildinfo +1 -1
  76. package/vitest.config.ts +7 -0
@@ -1,6 +1,6 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
 
3
- import { DragEnd, DragMove, DragObserveProps, useDragObserve } from './use-drag-observe.js';
3
+ import { DragEnd, DragMove, DragObserveProps, DragState, useDragObserve } from './use-drag-observe.js';
4
4
  import { useEffect } from "./use-effect.js";
5
5
  import type { AsyncArgsProvider } from '@adobe/data/ecs';
6
6
  import { Observe } from '@adobe/data/observe';
@@ -10,37 +10,58 @@ export type DragTransactionProps<T extends {} | null> = DragObserveProps & {
10
10
  update: (drag: DragMove | DragEnd) => T | void;
11
11
  };
12
12
 
13
+ // Thrown into a drag's argument generator so the transaction discards its transient state.
14
+ const dragCancelled = new Error('Drag cancelled');
15
+
16
+ async function* toTransactionArgs<T extends {} | null>(
17
+ drags: AsyncGenerator<DragState>,
18
+ update: DragTransactionProps<T>['update'],
19
+ ): AsyncGenerator<T> {
20
+ for await (const drag of drags) {
21
+ if (drag.type === 'move' || drag.type === 'end') {
22
+ const args = update(drag);
23
+ if (args !== undefined) {
24
+ yield args;
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Drives `transaction` with one argument generator per drag: each move or end
32
+ * yields `update(drag)` as intermediate arguments, the end commits the last
33
+ * arguments, and a cancellation (including unmount mid-drag) discards them.
34
+ */
13
35
  export function useDragTransaction<T extends {} | null>(props: DragTransactionProps<T>, dependencies: unknown[]) {
14
- const { transaction, update } = props;
15
36
  const dragObserve = useDragObserve(props, dependencies);
16
- const startDragTransaction = () => {
17
- let done = false;
18
- // we start the transaction by calling it with an async generator that will yield the args asynchronously
19
- transaction(
20
- // we create the async generator by mapping the dragObserve state changes to transaction args
21
- () =>
22
- Observe.toAsyncGenerator(
23
- // this uses the withMap function and the provided update and finish functions
24
- // any type 'start' will just be ignored and filtered out
25
- Observe.withFilter(dragObserve, value => {
26
- if (value.type === 'end') {
27
- done = true;
28
- return update(value);
29
- }
30
- if (value.type === 'move') {
31
- return update(value);
32
- }
33
- }),
34
- _value => done
35
- )
36
- );
37
- };
38
- // now we will observe the drag state and start a new transaction whenever a drag starts
39
37
  useEffect(() => {
40
- return dragObserve(value => {
38
+ const { transaction, update } = props;
39
+ let active: AsyncGenerator<DragState> | undefined;
40
+ const cancel = () => {
41
+ const drags = active;
42
+ active = undefined;
43
+ drags?.throw(dragCancelled).catch(() => undefined);
44
+ };
45
+ const unobserve = dragObserve(value => {
41
46
  if (value.type === 'start') {
42
- startDragTransaction();
47
+ cancel();
48
+ const drags = active = Observe.toAsyncGenerator(
49
+ dragObserve,
50
+ v => v.type === 'end' || v.type === 'cancel',
51
+ );
52
+ const result: unknown = transaction(() => toTransactionArgs(drags, update));
53
+ Promise.resolve(result).catch(error => {
54
+ if (error !== dragCancelled) throw error;
55
+ });
56
+ } else if (value.type === 'end') {
57
+ active = undefined;
58
+ } else if (value.type === 'cancel') {
59
+ cancel();
43
60
  }
44
61
  });
62
+ return () => {
63
+ unobserve();
64
+ cancel();
65
+ };
45
66
  }, dependencies);
46
67
  }
@@ -0,0 +1,148 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { Vec2 } from "@adobe/data/math";
5
+ import { useDraggable, type DraggableProps } from "./use-draggable.js";
6
+ import { createHost, pointerCancel, pointerDown, pointerMove, pointerUp, render, unmount } from "./test-host.test-utils.js";
7
+
8
+ function mountDraggable(options: Partial<DraggableProps> = {}, onDragStart: () => Vec2 | void = () => {}) {
9
+ const host = createHost();
10
+ const parent = document.createElement("section");
11
+ parent.append(host);
12
+ const props = {
13
+ onDragStart: vi.fn(onDragStart),
14
+ onDrag: vi.fn(),
15
+ onDragEnd: vi.fn(),
16
+ onDragCancel: vi.fn(),
17
+ ...options,
18
+ };
19
+ render(host, () => useDraggable(host, props, []));
20
+ return { host, parent, props };
21
+ }
22
+
23
+ describe("useDraggable", () => {
24
+ it("offsets the start position from onDragStart and ends at the pointerup coordinates", () => {
25
+ const { host, props } = mountDraggable({}, () => [100, 200]);
26
+ pointerDown(host);
27
+ pointerMove(20);
28
+ pointerUp(50);
29
+
30
+ expect(props.onDrag).toHaveBeenCalledWith(expect.any(PointerEvent), [120, 200], [20, 0]);
31
+ expect(props.onDragEnd).toHaveBeenCalledWith(expect.any(PointerEvent), [150, 200], [50, 0]);
32
+ unmount(host);
33
+ });
34
+
35
+ it("starts from the element offset when onDragStart returns nothing", () => {
36
+ const { host, props } = mountDraggable();
37
+ Object.defineProperty(host, "offsetLeft", { value: 7 });
38
+ Object.defineProperty(host, "offsetTop", { value: 3 });
39
+ pointerDown(host);
40
+ pointerMove(20);
41
+ expect(props.onDrag).toHaveBeenCalledWith(expect.any(PointerEvent), [27, 3], [20, 0]);
42
+ pointerUp(20);
43
+ unmount(host);
44
+ });
45
+
46
+ it("starts once the pointer has moved at least minDragDistance", () => {
47
+ const { host, props } = mountDraggable({ minDragDistance: 5 });
48
+ pointerDown(host);
49
+ pointerMove(4);
50
+ expect(props.onDragStart).not.toHaveBeenCalled();
51
+ pointerMove(5);
52
+ expect(props.onDragStart).toHaveBeenCalledTimes(1);
53
+ pointerMove(6);
54
+ expect(props.onDragStart).toHaveBeenCalledTimes(1);
55
+ pointerUp(6);
56
+ unmount(host);
57
+ });
58
+
59
+ it("does not end or cancel a press released before the threshold", () => {
60
+ const { host, props } = mountDraggable();
61
+ pointerDown(host);
62
+ pointerMove(3);
63
+ pointerUp(3);
64
+ expect(props.onDragEnd).not.toHaveBeenCalled();
65
+ expect(props.onDragCancel).not.toHaveBeenCalled();
66
+ unmount(host);
67
+ expect(props.onDragCancel).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("sets the drag cursor and restores the original cursor at the end", () => {
71
+ const { host } = mountDraggable({ dragCursor: "move" });
72
+ host.style.cursor = "pointer";
73
+ pointerDown(host);
74
+ pointerMove(20);
75
+ expect(host.style.cursor).toBe("move");
76
+ pointerUp(20);
77
+ expect(host.style.cursor).toBe("pointer");
78
+ unmount(host);
79
+ });
80
+
81
+ it("adds a placeholder to the parent while dragging when requested", () => {
82
+ const { host, parent } = mountDraggable({ addPlaceholder: true });
83
+ pointerDown(host);
84
+ pointerMove(20);
85
+ expect(parent.children).toHaveLength(2);
86
+ pointerUp(20);
87
+ expect(parent.children).toHaveLength(1);
88
+ unmount(host);
89
+ });
90
+
91
+ it("stops propagation of its pointer events when requested", () => {
92
+ const { host } = mountDraggable({ stopPropagation: true });
93
+ const parentListener = vi.fn();
94
+ host.parentElement!.addEventListener("pointerdown", parentListener);
95
+ pointerDown(host);
96
+ expect(parentListener).not.toHaveBeenCalled();
97
+ pointerUp(0);
98
+ unmount(host);
99
+ });
100
+
101
+ it("cancels on pointercancel, restores the cursor, and stops delivering moves", () => {
102
+ const { host, props } = mountDraggable();
103
+ pointerDown(host);
104
+ pointerMove(20);
105
+ expect(host.style.cursor).toBe("grab");
106
+
107
+ pointerCancel();
108
+ pointerMove(30);
109
+ pointerUp(30);
110
+
111
+ expect(props.onDragCancel).toHaveBeenCalledTimes(1);
112
+ expect(props.onDrag).toHaveBeenCalledTimes(1);
113
+ expect(props.onDragEnd).not.toHaveBeenCalled();
114
+ expect(host.style.cursor).toBe("");
115
+ unmount(host);
116
+ });
117
+
118
+ it("ignores secondary buttons and other pointers", () => {
119
+ const { host, props } = mountDraggable();
120
+ pointerDown(host, 0, 1, 2);
121
+ pointerMove(20);
122
+ expect(props.onDragStart).not.toHaveBeenCalled();
123
+
124
+ pointerDown(host);
125
+ pointerDown(host, 0, 2);
126
+ pointerMove(20, 2);
127
+ pointerUp(20, 2);
128
+ expect(props.onDragStart).not.toHaveBeenCalled();
129
+ pointerMove(20);
130
+ pointerUp(20);
131
+ expect(props.onDragEnd).toHaveBeenCalledTimes(1);
132
+ unmount(host);
133
+ });
134
+
135
+ it("cancels an active drag on unmount and stops listening", () => {
136
+ const { host, props } = mountDraggable();
137
+ pointerDown(host);
138
+ pointerMove(20);
139
+ unmount(host);
140
+
141
+ expect(props.onDragCancel).toHaveBeenCalledTimes(1);
142
+ expect(host.style.cursor).toBe("");
143
+ pointerMove(30);
144
+ pointerDown(host);
145
+ pointerMove(60);
146
+ expect(props.onDrag).toHaveBeenCalledTimes(1);
147
+ });
148
+ });
@@ -25,86 +25,111 @@ export interface DraggableProps {
25
25
  export function useDraggable(element: HTMLElement, props: DraggableProps, dependencies: unknown[]) {
26
26
  const { minDragDistance = 10, dragCursor = 'grab', addPlaceholder = false, stopPropagation = false } = props;
27
27
  useEffect(() => {
28
- let downPosition: Vec2 | null = null;
29
- // the bounds of the element when the pointer was first pressed down.
28
+ const minDragDistanceSquared = minDragDistance * minDragDistance;
29
+ // The pointer that started the session; other pointers are ignored until it ends.
30
+ let pointerId: number | null = null;
31
+ let downPosition: Vec2 = [0, 0];
32
+ // The element position when the drag started, or null before the threshold is crossed.
30
33
  let dragStartOffset: Vec2 | null = null;
31
34
  let originalCursor = '';
32
35
  let placeholder: HTMLElement | null = null;
33
- let movePosition: Vec2 = [0, 0];
34
- function notify(e: PointerEvent, dragListener: DraggableProps["onDrag"]) {
35
- const delta = Vec2.subtract(movePosition, downPosition!);
36
- dragListener(e, Vec2.add(dragStartOffset!, delta), delta);
36
+
37
+ function notify(e: PointerEvent, start: Vec2, dragListener: DraggableProps["onDrag"]) {
38
+ const delta: Vec2 = [e.clientX - downPosition[0], e.clientY - downPosition[1]];
39
+ dragListener(e, Vec2.add(start, delta), delta);
40
+ }
41
+
42
+ function startDrag(e: PointerEvent): Vec2 {
43
+ const offset: Vec2 = [element.offsetLeft, element.offsetTop];
44
+ const start = (props.onDragStart(e) as Vec2 | undefined) ?? offset;
45
+ dragStartOffset = start;
46
+ if (dragCursor) {
47
+ originalCursor = element.style.cursor;
48
+ element.style.cursor = dragCursor;
49
+ }
50
+ // add a placeholder so the parent element doesn't change size when the element is dragged.
51
+ if (addPlaceholder) {
52
+ placeholder = document.createElement('div');
53
+ Object.assign(placeholder.style, {
54
+ position: 'absolute',
55
+ backgroundColor: 'pink',
56
+ left: toCssUnitString(element.offsetLeft),
57
+ top: toCssUnitString(element.offsetTop),
58
+ width: toCssUnitString(element.offsetWidth),
59
+ height: toCssUnitString(element.offsetHeight),
60
+ visibility: 'hidden',
61
+ });
62
+ element.parentElement?.appendChild(placeholder);
63
+ }
64
+ return start;
37
65
  }
38
66
 
39
67
  function onPointerMove(e: PointerEvent) {
40
- movePosition = [e.clientX, e.clientY];
41
- if (Vec2.length(Vec2.subtract(movePosition, downPosition!)) >= minDragDistance) {
42
- if (!dragStartOffset) {
43
- dragStartOffset = [element.offsetLeft, element.offsetTop];
44
- props.onDragStart(e);
45
- if (dragCursor) {
46
- originalCursor = element.style.cursor;
47
- element.style.cursor = dragCursor;
48
- }
49
- // add a placeholder so the parent element doesn't change size when the element is dragged.
50
- if (addPlaceholder) {
51
- placeholder = document.createElement('div');
52
- Object.assign(placeholder.style, {
53
- position: 'absolute',
54
- backgroundColor: 'pink',
55
- left: toCssUnitString(element.offsetLeft),
56
- top: toCssUnitString(element.offsetTop),
57
- width: toCssUnitString(element.offsetWidth),
58
- height: toCssUnitString(element.offsetHeight),
59
- visibility: 'hidden',
60
- });
61
- element.parentElement?.appendChild(placeholder);
62
- }
68
+ if (e.pointerId !== pointerId) return;
69
+ let start = dragStartOffset;
70
+ if (!start) {
71
+ const dx = e.clientX - downPosition[0];
72
+ const dy = e.clientY - downPosition[1];
73
+ if (dx * dx + dy * dy >= minDragDistanceSquared) {
74
+ start = startDrag(e);
63
75
  }
64
76
  }
65
- if (dragStartOffset) {
66
- notify(e, props.onDrag);
77
+ if (start) {
78
+ notify(e, start, props.onDrag);
67
79
  }
68
80
  if (stopPropagation) e.stopPropagation();
69
81
  }
70
- function cleanup() {
82
+
83
+ /** Ends the session and restores the element; returns the drag start if a drag had started. */
84
+ function release(): Vec2 | null {
71
85
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
72
86
  window.removeEventListener('pointerup', onPointerUp);
87
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
88
+ window.removeEventListener('pointercancel', onPointerCancel);
73
89
  window.removeEventListener('pointermove', onPointerMove);
74
- if (dragCursor) {
90
+ const start = dragStartOffset;
91
+ if (start && dragCursor) {
75
92
  element.style.cursor = originalCursor;
76
93
  }
77
- if (placeholder) {
78
- placeholder.remove();
79
- placeholder = null;
80
- }
94
+ placeholder?.remove();
95
+ placeholder = null;
96
+ pointerId = null;
97
+ dragStartOffset = null;
98
+ return start;
81
99
  }
100
+
82
101
  function onPointerUp(e: PointerEvent) {
83
- cleanup();
84
- if (dragStartOffset && props.onDragEnd) {
85
- notify(e, props.onDragEnd);
102
+ if (e.pointerId !== pointerId) return;
103
+ const start = release();
104
+ if (start && props.onDragEnd) {
105
+ notify(e, start, props.onDragEnd);
86
106
  }
87
107
  if (stopPropagation) e.stopPropagation();
88
- downPosition = null;
89
- dragStartOffset = null;
90
108
  }
109
+
110
+ function onPointerCancel(e: PointerEvent) {
111
+ if (e.pointerId !== pointerId) return;
112
+ if (release()) {
113
+ props.onDragCancel?.();
114
+ }
115
+ }
116
+
91
117
  function onPointerDown(e: PointerEvent) {
92
118
  // Only start drag transaction for left mouse button clicks
93
- if (e.button !== 0) return;
119
+ if (e.button !== 0 || pointerId !== null) return;
94
120
 
121
+ pointerId = e.pointerId;
122
+ downPosition = [e.clientX, e.clientY];
95
123
  window.addEventListener('pointermove', onPointerMove);
96
124
  window.addEventListener('pointerup', onPointerUp);
97
- downPosition = [e.clientX, e.clientY];
125
+ window.addEventListener('pointercancel', onPointerCancel);
98
126
  if (stopPropagation) e.stopPropagation();
99
127
  }
100
128
 
101
129
  element?.addEventListener('pointerdown', onPointerDown);
102
130
  return () => {
103
131
  element?.removeEventListener('pointerdown', onPointerDown);
104
- if (downPosition) {
105
- cleanup();
106
- }
107
- if (dragStartOffset) {
132
+ if (pointerId !== null && release()) {
108
133
  props.onDragCancel?.();
109
134
  }
110
135
  };
@@ -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
  }