@adobe/data-lit 0.10.8 → 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 +19 -1
- package/dist/hooks/component/hooks-controller.d.ts +28 -0
- package/dist/hooks/component/hooks-controller.integration.test.d.ts +1 -0
- package/dist/hooks/component/hooks-controller.integration.test.js +74 -0
- package/dist/hooks/component/hooks-controller.integration.test.js.map +1 -0
- package/dist/hooks/component/hooks-controller.js +96 -0
- package/dist/hooks/component/hooks-controller.js.map +1 -0
- package/dist/hooks/component/hooks-controller.test.d.ts +1 -0
- package/dist/hooks/component/hooks-controller.test.js +85 -0
- package/dist/hooks/component/hooks-controller.test.js.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/with-hooks.js +5 -0
- package/dist/hooks/with-hooks.js.map +1 -1
- package/package.json +3 -2
- package/src/hooks/component/hooks-controller.integration.test.ts +88 -0
- package/src/hooks/component/hooks-controller.test.ts +94 -0
- package/src/hooks/component/hooks-controller.ts +104 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/with-hooks.ts +5 -0
- package/tsconfig.tsbuildinfo +1 -1
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)
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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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"}
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -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";
|
package/dist/hooks/index.js.map
CHANGED
|
@@ -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"}
|
package/dist/hooks/with-hooks.js
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
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
|
+
});
|