@adobe/data-lit 0.10.9 → 0.10.11

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # @adobe/data-lit
2
2
 
3
- Lit bindings for [@adobe/data](https://www.npmjs.com/package/@adobe/data) — hooks, elements, and decorators for building reactive UIs with the @adobe/data ECS database and observables.
3
+ Lit bindings for [@adobe/data](https://www.npmjs.com/package/@adobe/data): hooks, elements, and decorators
4
+ for building reactive UIs with the @adobe/data ECS database and observables.
4
5
 
5
6
  ## Install
6
7
 
@@ -15,4 +16,21 @@ import { ApplicationHost, DatabaseElement } from "@adobe/data-lit";
15
16
  import { createDatabase } from "@adobe/data/ecs";
16
17
  ```
17
18
 
19
+ ## Hooks and the disconnect lifecycle
20
+
21
+ Hooks (`useState`, `useEffect`, `useObservable`, `useObservableValues`, `useMemo`, `useRef`, ...) follow
22
+ React's unmount semantics. When a hook-using element is disconnected from the DOM, **all of its hook state is
23
+ reset**:
24
+
25
+ - every `useEffect` cleanup runs, so observable subscriptions and event listeners are torn down, and
26
+ - all stored values (`useState`, `useMemo`, `useRef`) are discarded.
27
+
28
+ On the next connect the element renders fresh from its initial hook state.
29
+
30
+ > **Footgun:** because a disconnect is a full reset, a DOM *move* (`el.remove()` then re-appending it, or
31
+ > reparenting the element) is treated as a full unmount followed by a remount. Any local UI state held in
32
+ > hooks is silently lost across the move: toggle positions, uncontrolled input values, scroll offsets. If a
33
+ > piece of state must survive a reparent, keep it in the `@adobe/data` database/service rather than in element
34
+ > hook state.
35
+
18
36
  See the `data-lit-todo` sample in this repository for a full example.
@@ -0,0 +1,28 @@
1
+ import type { Component } from "./component.js";
2
+ /**
3
+ * Give a hook host a real disconnect edge.
4
+ *
5
+ * The hook model stores every effect's cleanup in `host.hooks[i].dispose`,
6
+ * but {@link useEffect} only invokes it when dependencies change — never when
7
+ * the element leaves the DOM. Left alone, every `useObservable` /
8
+ * `useObservableValues` / `useEffect` subscription leaks on unmount and keeps
9
+ * firing `requestUpdate` on a detached element. `useConnected` is likewise
10
+ * inert because nothing dispatches the `"connected"` / `"disconnected"`
11
+ * events it listens for.
12
+ *
13
+ * This installs a single Lit {@link ReactiveController} — the one hook-friendly
14
+ * path to a genuine `hostDisconnected` — that:
15
+ * - on connect, dispatches `"connected"`, and on every *re*-connect forces a
16
+ * re-render so the (previously torn-down) hooks re-initialize and
17
+ * re-subscribe;
18
+ * - on disconnect, dispatches `"disconnected"` then disposes and clears all
19
+ * hook slots (full unmount semantics).
20
+ *
21
+ * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
22
+ * which runs after `connectedCallback`, so `addController` fires `hostConnected`
23
+ * synchronously on the first mount. The install is idempotent per host.
24
+ *
25
+ * Non-Lit hosts (no `addController`) are skipped; they may dispatch the
26
+ * lifecycle events themselves.
27
+ */
28
+ export declare function installHooksController(host: Component): void;
@@ -0,0 +1,74 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, it, expect } from "vitest";
4
+ import { LitElement, html } from "lit";
5
+ import { attachDecorator } from "../attach-decorator.js";
6
+ import { withHooks } from "../with-hooks.js";
7
+ import { useObservable } from "../use-observable.js";
8
+ /**
9
+ * Real-DOM regression test for the hook-disposal fix. Where the sibling
10
+ * hooks-controller.test.ts drives a hand-rolled FakeHost, this mounts a genuine
11
+ * LitElement in a happy-dom document, so it exercises the actual bug path:
12
+ * `withHooks` installs the controller, `addController` fires `hostConnected`
13
+ * synchronously during the real connect, and a real `useObservable` subscription
14
+ * is torn down when the element leaves the DOM (instead of leaking and firing
15
+ * `requestUpdate` on a detached element).
16
+ */
17
+ /** An Observe whose live subscriber count the test can inspect. */
18
+ function createTrackedObservable() {
19
+ const subscribers = new Set();
20
+ let current = 0;
21
+ const observable = observer => {
22
+ subscribers.add(observer);
23
+ observer(current);
24
+ return () => {
25
+ subscribers.delete(observer);
26
+ };
27
+ };
28
+ return {
29
+ observable,
30
+ emit: value => {
31
+ current = value;
32
+ for (const observer of subscribers)
33
+ observer(value);
34
+ },
35
+ subscriberCount: () => subscribers.size,
36
+ };
37
+ }
38
+ class HookProbeElement extends LitElement {
39
+ constructor() {
40
+ super();
41
+ attachDecorator(this, "render", withHooks);
42
+ }
43
+ render() {
44
+ const value = useObservable(this.observable);
45
+ return html `<span>${value ?? ""}</span>`;
46
+ }
47
+ }
48
+ customElements.define("hook-probe-element", HookProbeElement);
49
+ describe("hook disposal on real DOM disconnect", () => {
50
+ it("tears down a useObservable subscription and stops requestUpdate when the element is removed", async () => {
51
+ const { observable, emit, subscriberCount } = createTrackedObservable();
52
+ const el = document.createElement("hook-probe-element");
53
+ el.observable = observable;
54
+ document.body.appendChild(el);
55
+ await el.updateComplete;
56
+ // Mounted: the render subscribed exactly once.
57
+ expect(subscriberCount()).toBe(1);
58
+ // Track any requestUpdate that fires AFTER the element is removed.
59
+ let updatesAfterRemove = 0;
60
+ const originalRequestUpdate = el.requestUpdate.bind(el);
61
+ el.requestUpdate = (...args) => {
62
+ updatesAfterRemove++;
63
+ originalRequestUpdate(...args);
64
+ };
65
+ el.remove();
66
+ // Disconnect ran the effect cleanup: the subscription is gone.
67
+ expect(subscriberCount()).toBe(0);
68
+ // A later emit reaches no leaked subscriber, so the detached element is
69
+ // never asked to update. Without the disposal fix, this would be >= 1.
70
+ emit(99);
71
+ expect(updatesAfterRemove).toBe(0);
72
+ });
73
+ });
74
+ //# sourceMappingURL=hooks-controller.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks-controller.integration.test.js","sourceRoot":"","sources":["../../../src/hooks/component/hooks-controller.integration.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,96 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ /**
3
+ * Per-host flag marking that the hook-lifecycle controller is already
4
+ * installed, so repeated renders (and double-wrapped `render` methods) do
5
+ * not register duplicate controllers.
6
+ */
7
+ const HOOKS_CONTROLLER = Symbol("data-lit.hooksController");
8
+ function isReactiveHost(host) {
9
+ return "addController" in host && typeof host.addController === "function";
10
+ }
11
+ /**
12
+ * Dispose every effect-style hook slot and reset the hook cursor so the next
13
+ * render re-initializes hooks from scratch. Only slots that expose a
14
+ * `dispose` function (effects, subscriptions) are torn down; value slots
15
+ * (state, memo, ref) are simply dropped.
16
+ */
17
+ function disposeHooks(host) {
18
+ const { hooks } = host;
19
+ if (hooks) {
20
+ for (const hook of hooks) {
21
+ hook?.dispose?.();
22
+ }
23
+ }
24
+ // Invariant: every live subscription (useEffect / useObservable / ...) exposes a
25
+ // `dispose` and is torn down in the loop above, so nothing should fire after this.
26
+ // A useState setter closure that still runs post-disconnect would write into the
27
+ // emptied array and requestUpdate a detached host. That signals an un-torn-down
28
+ // subscription (a bug at the subscription site), not something to guard here.
29
+ host.hooks = [];
30
+ host.hookIndex = 0;
31
+ }
32
+ /**
33
+ * Give a hook host a real disconnect edge.
34
+ *
35
+ * The hook model stores every effect's cleanup in `host.hooks[i].dispose`,
36
+ * but {@link useEffect} only invokes it when dependencies change — never when
37
+ * the element leaves the DOM. Left alone, every `useObservable` /
38
+ * `useObservableValues` / `useEffect` subscription leaks on unmount and keeps
39
+ * firing `requestUpdate` on a detached element. `useConnected` is likewise
40
+ * inert because nothing dispatches the `"connected"` / `"disconnected"`
41
+ * events it listens for.
42
+ *
43
+ * This installs a single Lit {@link ReactiveController} — the one hook-friendly
44
+ * path to a genuine `hostDisconnected` — that:
45
+ * - on connect, dispatches `"connected"`, and on every *re*-connect forces a
46
+ * re-render so the (previously torn-down) hooks re-initialize and
47
+ * re-subscribe;
48
+ * - on disconnect, dispatches `"disconnected"` then disposes and clears all
49
+ * hook slots (full unmount semantics).
50
+ *
51
+ * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
52
+ * which runs after `connectedCallback`, so `addController` fires `hostConnected`
53
+ * synchronously on the first mount. The install is idempotent per host.
54
+ *
55
+ * Non-Lit hosts (no `addController`) are skipped; they may dispatch the
56
+ * lifecycle events themselves.
57
+ */
58
+ export function installHooksController(host) {
59
+ if (!isReactiveHost(host) || host[HOOKS_CONTROLLER]) {
60
+ return;
61
+ }
62
+ // Mark installed BEFORE addController below: on an already-connected host
63
+ // addController fires hostConnected synchronously, and setting the flag first
64
+ // guards against a re-entrant render re-installing a duplicate controller.
65
+ host[HOOKS_CONTROLLER] = true;
66
+ let connectedOnce = false;
67
+ const controller = {
68
+ hostConnected() {
69
+ // The "connected" / "disconnected" events are for EXTERNAL listeners only.
70
+ // The internal useConnected does NOT depend on them: this fires before the
71
+ // render body attaches any listener, so useConnected is driven by its direct
72
+ // isConnected check plus useEffect cleanup instead.
73
+ host.dispatchEvent(new Event("connected"));
74
+ if (connectedOnce) {
75
+ // Reconnect: the previous disconnect disposed and cleared every
76
+ // hook slot, so re-render to re-run hooks and re-subscribe.
77
+ host.requestUpdate();
78
+ }
79
+ connectedOnce = true;
80
+ },
81
+ hostDisconnected() {
82
+ host.dispatchEvent(new Event("disconnected"));
83
+ disposeHooks(host);
84
+ },
85
+ };
86
+ try {
87
+ host.addController(controller);
88
+ }
89
+ catch (error) {
90
+ // addController failed, so no controller is registered. Clear the flag so a
91
+ // later render can retry the install instead of being permanently skipped.
92
+ host[HOOKS_CONTROLLER] = undefined;
93
+ throw error;
94
+ }
95
+ }
96
+ //# sourceMappingURL=hooks-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks-controller.js","sourceRoot":"","sources":["../../../src/hooks/component/hooks-controller.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAKvD;;;;GAIG;AACH,MAAM,gBAAgB,GAAG,MAAM,CAAC,0BAA0B,CAAC,CAAC;AAI5D,SAAS,cAAc,CAAC,IAAe;IACnC,OAAO,eAAe,IAAI,IAAI,IAAI,OAAO,IAAI,CAAC,aAAa,KAAK,UAAU,CAAC;AAC/E,CAAC;AAED;;;;;GAKG;AACH,SAAS,YAAY,CAAC,IAAe;IACjC,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC;IACvB,IAAI,KAAK,EAAE,CAAC;QACR,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACtB,IAA6C,EAAE,OAAO,EAAE,EAAE,CAAC;QAChE,CAAC;IACL,CAAC;IACD,iFAAiF;IACjF,mFAAmF;IACnF,iFAAiF;IACjF,gFAAgF;IAChF,8EAA8E;IAC9E,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;IAChB,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC;AACvB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAAe;IAClD,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC;QAClD,OAAO;IACX,CAAC;IACD,0EAA0E;IAC1E,8EAA8E;IAC9E,2EAA2E;IAC3E,IAAI,CAAC,gBAAgB,CAAC,GAAG,IAAI,CAAC;IAE9B,IAAI,aAAa,GAAG,KAAK,CAAC;IAC1B,MAAM,UAAU,GAAuB;QACnC,aAAa;YACT,2EAA2E;YAC3E,2EAA2E;YAC3E,6EAA6E;YAC7E,oDAAoD;YACpD,IAAI,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC;YAC3C,IAAI,aAAa,EAAE,CAAC;gBAChB,gEAAgE;gBAChE,4DAA4D;gBAC5D,IAAI,CAAC,aAAa,EAAE,CAAC;YACzB,CAAC;YACD,aAAa,GAAG,IAAI,CAAC;QACzB,CAAC;QACD,gBAAgB;YACZ,IAAI,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC;YAC9C,YAAY,CAAC,IAAI,CAAC,CAAC;QACvB,CAAC;KACJ,CAAC;IACF,IAAI,CAAC;QACD,IAAI,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC;IACnC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACb,4EAA4E;QAC5E,2EAA2E;QAC3E,IAAI,CAAC,gBAAgB,CAAC,GAAG,SAAS,CAAC;QACnC,MAAM,KAAK,CAAC;IAChB,CAAC;AACL,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { installHooksController } from "./hooks-controller.js";
4
+ /**
5
+ * Minimal stand-in for a Lit host: an EventTarget that captures the controller
6
+ * installed on it so the test can drive `hostConnected` / `hostDisconnected`
7
+ * directly (a real Lit element would need a DOM). `addController` mirrors Lit's
8
+ * contract of firing `hostConnected` synchronously when already connected.
9
+ */
10
+ class FakeHost extends EventTarget {
11
+ constructor() {
12
+ super(...arguments);
13
+ this.isConnected = true;
14
+ this.hookIndex = 0;
15
+ this.hooks = [];
16
+ this.updatedListeners = new Set();
17
+ this.requestUpdate = vi.fn();
18
+ this.controllers = [];
19
+ }
20
+ addController(controller) {
21
+ this.controllers.push(controller);
22
+ }
23
+ // Simulate Lit dispatching lifecycle to every registered controller.
24
+ connect() {
25
+ for (const c of this.controllers)
26
+ c.hostConnected?.();
27
+ }
28
+ disconnect() {
29
+ for (const c of this.controllers)
30
+ c.hostDisconnected?.();
31
+ }
32
+ }
33
+ describe("installHooksController", () => {
34
+ it("disposes every effect hook and resets the cursor on disconnect", () => {
35
+ const host = new FakeHost();
36
+ const disposeA = vi.fn();
37
+ const disposeB = vi.fn();
38
+ host.hooks = [
39
+ { dispose: disposeA, dependencies: [] },
40
+ 42, // a value slot (useState) — must be skipped, not crash
41
+ { dispose: disposeB, dependencies: [] },
42
+ { current: null }, // a ref slot — no dispose
43
+ ];
44
+ host.hookIndex = 4;
45
+ installHooksController(host);
46
+ host.disconnect();
47
+ expect(disposeA).toHaveBeenCalledTimes(1);
48
+ expect(disposeB).toHaveBeenCalledTimes(1);
49
+ expect(host.hooks).toEqual([]);
50
+ expect(host.hookIndex).toBe(0);
51
+ });
52
+ it("dispatches connected / disconnected events across the lifecycle", () => {
53
+ const host = new FakeHost();
54
+ const onConnected = vi.fn();
55
+ const onDisconnected = vi.fn();
56
+ host.addEventListener("connected", onConnected);
57
+ host.addEventListener("disconnected", onDisconnected);
58
+ installHooksController(host);
59
+ host.connect();
60
+ expect(onConnected).toHaveBeenCalledTimes(1);
61
+ host.disconnect();
62
+ expect(onDisconnected).toHaveBeenCalledTimes(1);
63
+ });
64
+ it("forces a re-render only on RE-connect, not the first connect", () => {
65
+ const host = new FakeHost();
66
+ installHooksController(host);
67
+ host.connect(); // first mount
68
+ expect(host.requestUpdate).not.toHaveBeenCalled();
69
+ host.disconnect();
70
+ host.connect(); // reconnect
71
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
72
+ });
73
+ it("is idempotent — repeated installs register a single controller", () => {
74
+ const host = new FakeHost();
75
+ installHooksController(host);
76
+ installHooksController(host);
77
+ installHooksController(host);
78
+ expect(host.controllers).toHaveLength(1);
79
+ });
80
+ it("skips hosts that are not reactive controller hosts", () => {
81
+ const host = new EventTarget();
82
+ expect(() => installHooksController(host)).not.toThrow();
83
+ });
84
+ });
85
+ //# sourceMappingURL=hooks-controller.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks-controller.test.js","sourceRoot":"","sources":["../../../src/hooks/component/hooks-controller.test.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  export * from "./component/component.js";
2
2
  export * from "./component/stack.js";
3
+ export * from "./component/hooks-controller.js";
3
4
  export * from "./use-state.js";
4
5
  export * from "./use-effect.js";
5
6
  export * from "./use-connected.js";
@@ -1,6 +1,7 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  export * from "./component/component.js";
3
3
  export * from "./component/stack.js";
4
+ export * from "./component/hooks-controller.js";
4
5
  export * from "./use-state.js";
5
6
  export * from "./use-effect.js";
6
7
  export * from "./use-connected.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAEvD,cAAc,0BAA0B,CAAC;AACzC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAEvD,cAAc,0BAA0B,CAAC;AACzC,cAAc,sBAAsB,CAAC;AACrC,cAAc,iCAAiC,CAAC;AAChD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC"}
@@ -1,8 +1,13 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import { Component_stack } from "./component/stack.js";
3
+ import { installHooksController } from "./component/hooks-controller.js";
3
4
  export function withHooks(target, propertyKey, descriptor) {
4
5
  const originalMethod = descriptor.value;
5
6
  descriptor.value = function (...args) {
7
+ // Give the host a real disconnect edge so effect/subscription cleanups
8
+ // actually run on unmount. Idempotent per host, so double-wrapped
9
+ // `render` methods install it only once.
10
+ installHooksController(this);
6
11
  Component_stack.push(this);
7
12
  try {
8
13
  return originalMethod.apply(this, args);
@@ -1 +1 @@
1
- {"version":3,"file":"with-hooks.js","sourceRoot":"","sources":["../../src/hooks/with-hooks.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAGvD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,UAAU,SAAS,CACrB,MAAc,EACd,WAAmB,EACnB,UAA0E;IAE1E,MAAM,cAAc,GAAG,UAAU,CAAC,KAAM,CAAC;IACzC,UAAU,CAAC,KAAK,GAAG,UAAsB,GAAG,IAAU;QAClD,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC;YACD,OAAO,cAAc,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC5C,CAAC;gBACO,CAAC;YACL,eAAe,CAAC,GAAG,EAAE,CAAC;QAC1B,CAAC;IACL,CAAC,CAAA;IACD,OAAO,UAAU,CAAC;AACtB,CAAC"}
1
+ {"version":3,"file":"with-hooks.js","sourceRoot":"","sources":["../../src/hooks/with-hooks.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAGvD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EAAE,sBAAsB,EAAE,MAAM,iCAAiC,CAAC;AAEzE,MAAM,UAAU,SAAS,CACrB,MAAc,EACd,WAAmB,EACnB,UAA0E;IAE1E,MAAM,cAAc,GAAG,UAAU,CAAC,KAAM,CAAC;IACzC,UAAU,CAAC,KAAK,GAAG,UAAsB,GAAG,IAAU;QAClD,uEAAuE;QACvE,kEAAkE;QAClE,yCAAyC;QACzC,sBAAsB,CAAC,IAAI,CAAC,CAAC;QAC7B,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC;YACD,OAAO,cAAc,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC5C,CAAC;gBACO,CAAC;YACL,eAAe,CAAC,GAAG,EAAE,CAAC;QAC1B,CAAC;IACL,CAAC,CAAA;IACD,OAAO,UAAU,CAAC;AACtB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-lit",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "description": "Adobe data Lit bindings - hooks, elements, decorators",
5
5
  "type": "module",
6
6
  "private": false,
@@ -12,12 +12,13 @@
12
12
  "access": "public"
13
13
  },
14
14
  "dependencies": {
15
- "@adobe/data": "0.10.9"
15
+ "@adobe/data": "0.10.11"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "lit": "^3.3.1"
19
19
  },
20
20
  "devDependencies": {
21
+ "happy-dom": "^20.14.0",
21
22
  "lit": "^3.3.1",
22
23
  "typescript": "^5.8.3",
23
24
  "vitest": "^1.6.0"
@@ -0,0 +1,88 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ // @vitest-environment happy-dom
3
+ import { describe, it, expect } from "vitest";
4
+ import { LitElement, html } from "lit";
5
+ import type { Observe } from "@adobe/data/observe";
6
+ import { attachDecorator } from "../attach-decorator.js";
7
+ import { withHooks } from "../with-hooks.js";
8
+ import { useObservable } from "../use-observable.js";
9
+
10
+ /**
11
+ * Real-DOM regression test for the hook-disposal fix. Where the sibling
12
+ * hooks-controller.test.ts drives a hand-rolled FakeHost, this mounts a genuine
13
+ * LitElement in a happy-dom document, so it exercises the actual bug path:
14
+ * `withHooks` installs the controller, `addController` fires `hostConnected`
15
+ * synchronously during the real connect, and a real `useObservable` subscription
16
+ * is torn down when the element leaves the DOM (instead of leaking and firing
17
+ * `requestUpdate` on a detached element).
18
+ */
19
+
20
+ /** An Observe whose live subscriber count the test can inspect. */
21
+ function createTrackedObservable(): {
22
+ observable: Observe<number>;
23
+ emit: (value: number) => void;
24
+ subscriberCount: () => number;
25
+ } {
26
+ const subscribers = new Set<(value: number) => void>();
27
+ let current = 0;
28
+ const observable: Observe<number> = observer => {
29
+ subscribers.add(observer);
30
+ observer(current);
31
+ return () => {
32
+ subscribers.delete(observer);
33
+ };
34
+ };
35
+ return {
36
+ observable,
37
+ emit: value => {
38
+ current = value;
39
+ for (const observer of subscribers) observer(value);
40
+ },
41
+ subscriberCount: () => subscribers.size,
42
+ };
43
+ }
44
+
45
+ class HookProbeElement extends LitElement {
46
+ observable!: Observe<number>;
47
+ constructor() {
48
+ super();
49
+ attachDecorator(this, "render", withHooks);
50
+ }
51
+ render() {
52
+ const value = useObservable(this.observable);
53
+ return html`<span>${value ?? ""}</span>`;
54
+ }
55
+ }
56
+ customElements.define("hook-probe-element", HookProbeElement);
57
+
58
+ describe("hook disposal on real DOM disconnect", () => {
59
+ it("tears down a useObservable subscription and stops requestUpdate when the element is removed", async () => {
60
+ const { observable, emit, subscriberCount } = createTrackedObservable();
61
+
62
+ const el = document.createElement("hook-probe-element") as HookProbeElement;
63
+ el.observable = observable;
64
+ document.body.appendChild(el);
65
+ await el.updateComplete;
66
+
67
+ // Mounted: the render subscribed exactly once.
68
+ expect(subscriberCount()).toBe(1);
69
+
70
+ // Track any requestUpdate that fires AFTER the element is removed.
71
+ let updatesAfterRemove = 0;
72
+ const originalRequestUpdate = el.requestUpdate.bind(el);
73
+ el.requestUpdate = (...args: Parameters<HookProbeElement["requestUpdate"]>) => {
74
+ updatesAfterRemove++;
75
+ originalRequestUpdate(...args);
76
+ };
77
+
78
+ el.remove();
79
+
80
+ // Disconnect ran the effect cleanup: the subscription is gone.
81
+ expect(subscriberCount()).toBe(0);
82
+
83
+ // A later emit reaches no leaked subscriber, so the detached element is
84
+ // never asked to update. Without the disposal fix, this would be >= 1.
85
+ emit(99);
86
+ expect(updatesAfterRemove).toBe(0);
87
+ });
88
+ });
@@ -0,0 +1,94 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, it, expect, 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
+ /**
8
+ * Minimal stand-in for a Lit host: an EventTarget that captures the controller
9
+ * installed on it so the test can drive `hostConnected` / `hostDisconnected`
10
+ * directly (a real Lit element would need a DOM). `addController` mirrors Lit's
11
+ * contract of firing `hostConnected` synchronously when already connected.
12
+ */
13
+ class FakeHost extends EventTarget implements Component {
14
+ isConnected = true;
15
+ hookIndex = 0;
16
+ hooks: any[] = [];
17
+ updatedListeners = new Set<() => void>();
18
+ requestUpdate = vi.fn();
19
+ controllers: ReactiveController[] = [];
20
+
21
+ addController(controller: ReactiveController) {
22
+ this.controllers.push(controller);
23
+ }
24
+ // Simulate Lit dispatching lifecycle to every registered controller.
25
+ connect() {
26
+ for (const c of this.controllers) c.hostConnected?.();
27
+ }
28
+ disconnect() {
29
+ for (const c of this.controllers) c.hostDisconnected?.();
30
+ }
31
+ }
32
+
33
+ describe("installHooksController", () => {
34
+ it("disposes every effect hook and resets the cursor on disconnect", () => {
35
+ const host = new FakeHost();
36
+ const disposeA = vi.fn();
37
+ const disposeB = vi.fn();
38
+ host.hooks = [
39
+ { dispose: disposeA, dependencies: [] },
40
+ 42, // a value slot (useState) — must be skipped, not crash
41
+ { dispose: disposeB, dependencies: [] },
42
+ { current: null }, // a ref slot — no dispose
43
+ ];
44
+ host.hookIndex = 4;
45
+
46
+ installHooksController(host);
47
+ host.disconnect();
48
+
49
+ expect(disposeA).toHaveBeenCalledTimes(1);
50
+ expect(disposeB).toHaveBeenCalledTimes(1);
51
+ expect(host.hooks).toEqual([]);
52
+ expect(host.hookIndex).toBe(0);
53
+ });
54
+
55
+ it("dispatches connected / disconnected events across the lifecycle", () => {
56
+ const host = new FakeHost();
57
+ const onConnected = vi.fn();
58
+ const onDisconnected = vi.fn();
59
+ host.addEventListener("connected", onConnected);
60
+ host.addEventListener("disconnected", onDisconnected);
61
+
62
+ installHooksController(host);
63
+ host.connect();
64
+ expect(onConnected).toHaveBeenCalledTimes(1);
65
+
66
+ host.disconnect();
67
+ expect(onDisconnected).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it("forces a re-render only on RE-connect, not the first connect", () => {
71
+ const host = new FakeHost();
72
+ installHooksController(host);
73
+
74
+ host.connect(); // first mount
75
+ expect(host.requestUpdate).not.toHaveBeenCalled();
76
+
77
+ host.disconnect();
78
+ host.connect(); // reconnect
79
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it("is idempotent — repeated installs register a single controller", () => {
83
+ const host = new FakeHost();
84
+ installHooksController(host);
85
+ installHooksController(host);
86
+ installHooksController(host);
87
+ expect(host.controllers).toHaveLength(1);
88
+ });
89
+
90
+ it("skips hosts that are not reactive controller hosts", () => {
91
+ const host = new EventTarget() as unknown as Component;
92
+ expect(() => installHooksController(host)).not.toThrow();
93
+ });
94
+ });