@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.
@@ -0,0 +1,104 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ import type { ReactiveController, ReactiveControllerHost } from "lit";
4
+ import type { Component } from "./component.js";
5
+
6
+ /**
7
+ * Per-host flag marking that the hook-lifecycle controller is already
8
+ * installed, so repeated renders (and double-wrapped `render` methods) do
9
+ * not register duplicate controllers.
10
+ */
11
+ const HOOKS_CONTROLLER = Symbol("data-lit.hooksController");
12
+
13
+ type ReactiveHost = Component & ReactiveControllerHost & { [HOOKS_CONTROLLER]?: boolean };
14
+
15
+ function isReactiveHost(host: Component): host is ReactiveHost {
16
+ return "addController" in host && typeof host.addController === "function";
17
+ }
18
+
19
+ /**
20
+ * Dispose every effect-style hook slot and reset the hook cursor so the next
21
+ * render re-initializes hooks from scratch. Only slots that expose a
22
+ * `dispose` function (effects, subscriptions) are torn down; value slots
23
+ * (state, memo, ref) are simply dropped.
24
+ */
25
+ function disposeHooks(host: Component): void {
26
+ const { hooks } = host;
27
+ if (hooks) {
28
+ for (const hook of hooks) {
29
+ (hook as { dispose?: () => void } | undefined)?.dispose?.();
30
+ }
31
+ }
32
+ // Invariant: every live subscription (useEffect / useObservable / ...) exposes a
33
+ // `dispose` and is torn down in the loop above, so nothing should fire after this.
34
+ // A useState setter closure that still runs post-disconnect would write into the
35
+ // emptied array and requestUpdate a detached host. That signals an un-torn-down
36
+ // subscription (a bug at the subscription site), not something to guard here.
37
+ host.hooks = [];
38
+ host.hookIndex = 0;
39
+ }
40
+
41
+ /**
42
+ * Give a hook host a real disconnect edge.
43
+ *
44
+ * The hook model stores every effect's cleanup in `host.hooks[i].dispose`,
45
+ * but {@link useEffect} only invokes it when dependencies change — never when
46
+ * the element leaves the DOM. Left alone, every `useObservable` /
47
+ * `useObservableValues` / `useEffect` subscription leaks on unmount and keeps
48
+ * firing `requestUpdate` on a detached element. `useConnected` is likewise
49
+ * inert because nothing dispatches the `"connected"` / `"disconnected"`
50
+ * events it listens for.
51
+ *
52
+ * This installs a single Lit {@link ReactiveController} — the one hook-friendly
53
+ * path to a genuine `hostDisconnected` — that:
54
+ * - on connect, dispatches `"connected"`, and on every *re*-connect forces a
55
+ * re-render so the (previously torn-down) hooks re-initialize and
56
+ * re-subscribe;
57
+ * - on disconnect, dispatches `"disconnected"` then disposes and clears all
58
+ * hook slots (full unmount semantics).
59
+ *
60
+ * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
61
+ * which runs after `connectedCallback`, so `addController` fires `hostConnected`
62
+ * synchronously on the first mount. The install is idempotent per host.
63
+ *
64
+ * Non-Lit hosts (no `addController`) are skipped; they may dispatch the
65
+ * lifecycle events themselves.
66
+ */
67
+ export function installHooksController(host: Component): void {
68
+ if (!isReactiveHost(host) || host[HOOKS_CONTROLLER]) {
69
+ return;
70
+ }
71
+ // Mark installed BEFORE addController below: on an already-connected host
72
+ // addController fires hostConnected synchronously, and setting the flag first
73
+ // guards against a re-entrant render re-installing a duplicate controller.
74
+ host[HOOKS_CONTROLLER] = true;
75
+
76
+ let connectedOnce = false;
77
+ const controller: ReactiveController = {
78
+ hostConnected() {
79
+ // The "connected" / "disconnected" events are for EXTERNAL listeners only.
80
+ // The internal useConnected does NOT depend on them: this fires before the
81
+ // render body attaches any listener, so useConnected is driven by its direct
82
+ // isConnected check plus useEffect cleanup instead.
83
+ host.dispatchEvent(new Event("connected"));
84
+ if (connectedOnce) {
85
+ // Reconnect: the previous disconnect disposed and cleared every
86
+ // hook slot, so re-render to re-run hooks and re-subscribe.
87
+ host.requestUpdate();
88
+ }
89
+ connectedOnce = true;
90
+ },
91
+ hostDisconnected() {
92
+ host.dispatchEvent(new Event("disconnected"));
93
+ disposeHooks(host);
94
+ },
95
+ };
96
+ try {
97
+ host.addController(controller);
98
+ } catch (error) {
99
+ // addController failed, so no controller is registered. Clear the flag so a
100
+ // later render can retry the install instead of being permanently skipped.
101
+ host[HOOKS_CONTROLLER] = undefined;
102
+ throw error;
103
+ }
104
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  export * from "./component/component.js";
4
4
  export * from "./component/stack.js";
5
+ export * from "./component/hooks-controller.js";
5
6
  export * from "./use-state.js";
6
7
  export * from "./use-effect.js";
7
8
  export * from "./use-connected.js";
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { Component } from "./component/component.js";
4
4
  import { Component_stack } from "./component/stack.js";
5
+ import { installHooksController } from "./component/hooks-controller.js";
5
6
 
6
7
  export function withHooks<This extends Component, Args extends any[], Return>(
7
8
  target: object,
@@ -10,6 +11,10 @@ export function withHooks<This extends Component, Args extends any[], Return>(
10
11
  ): TypedPropertyDescriptor<(this: This, ...args: Args) => Return> {
11
12
  const originalMethod = descriptor.value!;
12
13
  descriptor.value = function (this: This, ...args: Args): Return {
14
+ // Give the host a real disconnect edge so effect/subscription cleanups
15
+ // actually run on unmount. Idempotent per host, so double-wrapped
16
+ // `render` methods install it only once.
17
+ installHooksController(this);
13
18
  Component_stack.push(this);
14
19
  try {
15
20
  return originalMethod.apply(this, args);