@adobe/data-lit 0.10.14 → 0.10.16

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,312 @@
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
+ import { useState } from "../use-state.js";
9
+ import { useConnected } from "../use-connected.js";
10
+ import { useEffect } from "../use-effect.js";
11
+ /**
12
+ * Real-DOM regression tests for the two-slot hook lifecycle:
13
+ *
14
+ * - Value/subscription slots (useState, useObservable) SURVIVE a DOM move and
15
+ * are finalized only on a genuine unmount (deferred `isConnected` re-check).
16
+ * - Connectedness edges (useConnected) fire SYNCHRONOUSLY on every raw
17
+ * connect/disconnect edge — including a move — so listener/portal/drag setup
18
+ * tears down and re-attaches deterministically, with no deferral.
19
+ *
20
+ * These pin the contract that neither the 0.10.11 nuke-on-disconnect nor the
21
+ * 0.10.14 defer-everything controller could satisfy simultaneously.
22
+ */
23
+ const flushMicrotasks = () => new Promise(resolve => queueMicrotask(resolve));
24
+ /** An Observe whose subscribe/unsubscribe churn the test can inspect. */
25
+ function createTrackedObservable(initial = 0) {
26
+ const subscribers = new Set();
27
+ let subscribes = 0;
28
+ let unsubscribes = 0;
29
+ const observable = observer => {
30
+ subscribes++;
31
+ subscribers.add(observer);
32
+ observer(initial);
33
+ return () => {
34
+ unsubscribes++;
35
+ subscribers.delete(observer);
36
+ };
37
+ };
38
+ return {
39
+ observable,
40
+ subscriberCount: () => subscribers.size,
41
+ totalSubscribes: () => subscribes,
42
+ totalUnsubscribes: () => unsubscribes,
43
+ };
44
+ }
45
+ // Module-scoped probes the element writes into, so a test can read edge/render
46
+ // history without reaching into the element instance.
47
+ let connectEdges = [];
48
+ let disconnectEdges = [];
49
+ let openResources = 0;
50
+ let effectSetups = 0;
51
+ let effectCleanups = 0;
52
+ let renderCount = 0;
53
+ let observableUnderTest;
54
+ class LifecycleProbeElement extends LitElement {
55
+ constructor() {
56
+ super();
57
+ attachDecorator(this, "render", withHooks);
58
+ }
59
+ render() {
60
+ renderCount++;
61
+ // Edge primitive: must fire synchronously on every connect/disconnect edge,
62
+ // and open exactly one resource per connect (balanced by its teardown).
63
+ useConnected(() => {
64
+ connectEdges.push("connect");
65
+ openResources++;
66
+ return () => {
67
+ disconnectEdges.push("disconnect");
68
+ openResources--;
69
+ };
70
+ });
71
+ // Plain effect slot: React-parity — cleanup on finalization/dep-change,
72
+ // never on a bare disconnect (so it survives a move too).
73
+ useEffect(() => {
74
+ effectSetups++;
75
+ return () => {
76
+ effectCleanups++;
77
+ };
78
+ }, []);
79
+ // Value slot: must survive a move (retain its number across re-parenting).
80
+ const [n] = useState(() => 41);
81
+ // Subscription slot: must survive a move (no unsubscribe/re-subscribe).
82
+ const value = useObservable(observableUnderTest);
83
+ return html `<span>${n}:${value ?? ""}</span>`;
84
+ }
85
+ }
86
+ customElements.define("lifecycle-probe-element", LifecycleProbeElement);
87
+ /**
88
+ * Renders a plain mutable field (no hook, no requestUpdate on change). Used to prove
89
+ * that a reconnect forces a re-render so mutable non-hook state refreshes after a move.
90
+ */
91
+ class MutableProbeElement extends LitElement {
92
+ constructor() {
93
+ super();
94
+ this.flag = "INITIAL";
95
+ this.rendered = "";
96
+ attachDecorator(this, "render", withHooks);
97
+ }
98
+ render() {
99
+ this.rendered = this.flag;
100
+ return html `<span>${this.flag}</span>`;
101
+ }
102
+ }
103
+ customElements.define("mutable-probe-element", MutableProbeElement);
104
+ function resetProbes(obs) {
105
+ connectEdges = [];
106
+ disconnectEdges = [];
107
+ openResources = 0;
108
+ effectSetups = 0;
109
+ effectCleanups = 0;
110
+ renderCount = 0;
111
+ observableUnderTest = obs;
112
+ }
113
+ describe("two-slot hook lifecycle — real DOM", () => {
114
+ it("useConnected fires its disconnect edge SYNCHRONOUSLY on a genuine unmount", async () => {
115
+ const { observable } = createTrackedObservable();
116
+ resetProbes(observable);
117
+ const el = document.createElement("lifecycle-probe-element");
118
+ document.body.appendChild(el);
119
+ await el.updateComplete;
120
+ expect(connectEdges).toEqual(["connect"]);
121
+ expect(disconnectEdges).toEqual([]);
122
+ el.remove();
123
+ // The edge is synchronous: no microtask flush before this assertion.
124
+ expect(disconnectEdges).toEqual(["disconnect"]);
125
+ });
126
+ it("useConnected runs setup ONCE across ordinary re-renders — no per-render leak", async () => {
127
+ const { observable } = createTrackedObservable();
128
+ resetProbes(observable);
129
+ const el = document.createElement("lifecycle-probe-element");
130
+ document.body.appendChild(el);
131
+ await el.updateComplete;
132
+ expect(connectEdges).toEqual(["connect"]);
133
+ expect(openResources).toBe(1);
134
+ // Ordinary re-renders (no connect/disconnect edge) re-run the hook body but
135
+ // must NOT re-invoke setup — the teardown handle persists across renders.
136
+ el.requestUpdate();
137
+ await el.updateComplete;
138
+ el.requestUpdate();
139
+ await el.updateComplete;
140
+ expect(connectEdges).toEqual(["connect"]);
141
+ expect(openResources).toBe(1);
142
+ // Unmount tears down exactly the one open resource — nothing leaked.
143
+ el.remove();
144
+ expect(disconnectEdges).toEqual(["disconnect"]);
145
+ expect(openResources).toBe(0);
146
+ });
147
+ it("useConnected churns disconnect→connect SYNCHRONOUSLY on a MOVE (re-parent)", async () => {
148
+ const { observable } = createTrackedObservable();
149
+ resetProbes(observable);
150
+ const parentA = document.createElement("div");
151
+ const parentB = document.createElement("div");
152
+ document.body.append(parentA, parentB);
153
+ const el = document.createElement("lifecycle-probe-element");
154
+ parentA.appendChild(el);
155
+ await el.updateComplete;
156
+ expect(connectEdges).toEqual(["connect"]);
157
+ // MOVE: disconnectedCallback then connectedCallback fire synchronously.
158
+ parentB.appendChild(el);
159
+ expect(el.isConnected).toBe(true);
160
+ // The edge primitive tore down and re-attached, synchronously and in order.
161
+ expect(disconnectEdges).toEqual(["disconnect"]);
162
+ expect(connectEdges).toEqual(["connect", "connect"]);
163
+ });
164
+ it("value + subscription slots SURVIVE a MOVE (no re-subscribe), and the reconnect re-renders once", async () => {
165
+ const { observable, subscriberCount, totalSubscribes, totalUnsubscribes } = createTrackedObservable();
166
+ resetProbes(observable);
167
+ const parentA = document.createElement("div");
168
+ const parentB = document.createElement("div");
169
+ document.body.append(parentA, parentB);
170
+ const el = document.createElement("lifecycle-probe-element");
171
+ parentA.appendChild(el);
172
+ await el.updateComplete;
173
+ expect(totalSubscribes()).toBe(1);
174
+ const rendersBeforeMove = renderCount;
175
+ parentB.appendChild(el);
176
+ await flushMicrotasks();
177
+ await el.updateComplete;
178
+ // The subscription is untouched — this is the perf win (no re-subscribe, no
179
+ // producer re-run, no state reset)...
180
+ expect(subscriberCount()).toBe(1);
181
+ expect(totalSubscribes()).toBe(1);
182
+ expect(totalUnsubscribes()).toBe(0);
183
+ // ...but the reconnect forces exactly one re-render (a cheap Lit diff over the
184
+ // preserved hook state) so an element reading mutable non-hook state refreshes.
185
+ expect(renderCount).toBe(rendersBeforeMove + 1);
186
+ });
187
+ it("an element reading MUTABLE non-hook state refreshes across a MOVE (the reconnect re-render)", async () => {
188
+ // Regression: a move fires no DOM re-render, so without the controller's forced
189
+ // reconnect re-render an element whose render reads a plain mutable field shows
190
+ // stale output after a re-parent.
191
+ const { observable } = createTrackedObservable();
192
+ resetProbes(observable);
193
+ const parentA = document.createElement("div");
194
+ const parentB = document.createElement("div");
195
+ document.body.append(parentA, parentB);
196
+ const el = document.createElement("mutable-probe-element");
197
+ parentA.appendChild(el);
198
+ await el.updateComplete;
199
+ expect(el.rendered).toBe("INITIAL");
200
+ el.flag = "CHANGED"; // mutate a plain field (no hook, no requestUpdate)
201
+ parentB.appendChild(el); // MOVE
202
+ await flushMicrotasks();
203
+ await el.updateComplete;
204
+ expect(el.isConnected).toBe(true);
205
+ expect(el.rendered).toBe("CHANGED");
206
+ });
207
+ it("finalizes on a genuine unmount — subscription disposed, no leak", async () => {
208
+ const { observable, subscriberCount, totalUnsubscribes } = createTrackedObservable();
209
+ resetProbes(observable);
210
+ const el = document.createElement("lifecycle-probe-element");
211
+ document.body.appendChild(el);
212
+ await el.updateComplete;
213
+ expect(subscriberCount()).toBe(1);
214
+ el.remove();
215
+ await flushMicrotasks();
216
+ expect(subscriberCount()).toBe(0);
217
+ expect(totalUnsubscribes()).toBe(1);
218
+ });
219
+ it("a genuine unmount→remount (across a task) re-initializes and re-subscribes the slots", async () => {
220
+ const { observable, subscriberCount, totalSubscribes } = createTrackedObservable();
221
+ resetProbes(observable);
222
+ const el = document.createElement("lifecycle-probe-element");
223
+ document.body.appendChild(el);
224
+ await el.updateComplete;
225
+ expect(totalSubscribes()).toBe(1);
226
+ // Unmount, let finalization run (task boundary), then remount.
227
+ el.remove();
228
+ await flushMicrotasks();
229
+ expect(subscriberCount()).toBe(0);
230
+ document.body.appendChild(el);
231
+ await el.updateComplete;
232
+ // Slots were finalized while detached, so the remount re-subscribed.
233
+ expect(subscriberCount()).toBe(1);
234
+ expect(totalSubscribes()).toBe(2);
235
+ // And the edge primitive reconnected.
236
+ expect(connectEdges.filter(e => e === "connect").length).toBe(2);
237
+ });
238
+ it("a genuine unmount→remount to a DIFFERENT parent (across a task) re-initializes and re-subscribes", async () => {
239
+ const { observable, subscriberCount, totalSubscribes } = createTrackedObservable();
240
+ resetProbes(observable);
241
+ const parentA = document.createElement("div");
242
+ const parentB = document.createElement("div");
243
+ document.body.append(parentA, parentB);
244
+ const el = document.createElement("lifecycle-probe-element");
245
+ parentA.appendChild(el);
246
+ await el.updateComplete;
247
+ expect(totalSubscribes()).toBe(1);
248
+ // Unmount from A, let finalization run, then remount under a DIFFERENT parent.
249
+ // The finalization gate reads `isConnected` (not "did it reconnect to the same
250
+ // node"), so a remount anywhere is handled uniformly.
251
+ el.remove();
252
+ await flushMicrotasks();
253
+ expect(subscriberCount()).toBe(0);
254
+ parentB.appendChild(el);
255
+ await el.updateComplete;
256
+ expect(subscriberCount()).toBe(1);
257
+ expect(totalSubscribes()).toBe(2);
258
+ expect(connectEdges.filter(e => e === "connect").length).toBe(2);
259
+ });
260
+ it("plain useEffect cleanup does NOT run on a MOVE, and runs (deferred) on a genuine unmount", async () => {
261
+ const { observable } = createTrackedObservable();
262
+ resetProbes(observable);
263
+ const parentA = document.createElement("div");
264
+ const parentB = document.createElement("div");
265
+ document.body.append(parentA, parentB);
266
+ const el = document.createElement("lifecycle-probe-element");
267
+ parentA.appendChild(el);
268
+ await el.updateComplete;
269
+ expect(effectSetups).toBe(1);
270
+ expect(effectCleanups).toBe(0);
271
+ // MOVE: the effect slot survives untouched — no cleanup, no re-setup.
272
+ parentB.appendChild(el);
273
+ await flushMicrotasks();
274
+ await el.updateComplete;
275
+ expect(effectSetups).toBe(1);
276
+ expect(effectCleanups).toBe(0);
277
+ // UNMOUNT: cleanup runs on the finalization microtask, not synchronously.
278
+ el.remove();
279
+ expect(effectCleanups).toBe(0);
280
+ await flushMicrotasks();
281
+ expect(effectCleanups).toBe(1);
282
+ });
283
+ it("a subscription that has not yet emitted heals on a post-MOVE emit (preserved-subscription data contract)", async () => {
284
+ // Data-layer contract: the move preserves the single mount-time subscription, so
285
+ // a source that emits only LATER still reaches the element. This guards against a
286
+ // regression that tears subscriptions down on move (which would strand a source
287
+ // that does not replay-on-subscribe — the root of the studio "renders nothing").
288
+ let deliver;
289
+ const stalled = observer => {
290
+ deliver = observer; // note: does NOT emit synchronously on subscribe
291
+ return () => {
292
+ deliver = undefined;
293
+ };
294
+ };
295
+ resetProbes(stalled);
296
+ const parentA = document.createElement("div");
297
+ const parentB = document.createElement("div");
298
+ document.body.append(parentA, parentB);
299
+ const el = document.createElement("lifecycle-probe-element");
300
+ parentA.appendChild(el);
301
+ await el.updateComplete;
302
+ expect(el.shadowRoot?.textContent).toContain("41:"); // value undefined, not yet emitted
303
+ // Move BEFORE the source emits, then emit.
304
+ parentB.appendChild(el);
305
+ await flushMicrotasks();
306
+ deliver?.(99);
307
+ await el.updateComplete;
308
+ // The preserved subscription delivered the post-move emit → element healed.
309
+ expect(el.shadowRoot?.textContent).toContain("99");
310
+ });
311
+ });
312
+ //# sourceMappingURL=hooks-lifecycle.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks-lifecycle.integration.test.js","sourceRoot":"","sources":["../../../src/hooks/component/hooks-lifecycle.integration.test.ts"],"names":[],"mappings":"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"}
@@ -1,2 +1,19 @@
1
1
  import type { EffectCallback } from "./use-effect.js";
2
+ /**
3
+ * Run `callback` on every DOM connect edge and its returned teardown on every
4
+ * disconnect edge — the connectedness primitive for `window` / `document`
5
+ * listeners, portals, and drag sessions that must attach and detach
6
+ * *synchronously* as the host enters and leaves the DOM.
7
+ *
8
+ * The hooks controller (see `installHooksController`) dispatches the
9
+ * `"connected"` / `"disconnected"` events synchronously on every raw edge,
10
+ * including a DOM move, so a move tears the setup down and re-attaches it
11
+ * deterministically.
12
+ *
13
+ * Setup runs exactly ONCE per connect. The teardown handle lives in a ref that
14
+ * persists across renders, so ordinary re-renders (which re-run this hook body)
15
+ * neither re-invoke setup nor leak — the setup/teardown pair is driven only from
16
+ * the edge, never from the render pass. The callback is read live through a ref,
17
+ * so callers may pass a fresh closure on every render.
18
+ */
2
19
  export declare function useConnected(callback: EffectCallback, dependencies?: unknown[]): void;
@@ -1,25 +1,53 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import { useEffect } from "./use-effect.js";
3
+ import { useRef } from "./use-ref.js";
3
4
  import { Component_stack } from "./component/stack.js";
5
+ /**
6
+ * Run `callback` on every DOM connect edge and its returned teardown on every
7
+ * disconnect edge — the connectedness primitive for `window` / `document`
8
+ * listeners, portals, and drag sessions that must attach and detach
9
+ * *synchronously* as the host enters and leaves the DOM.
10
+ *
11
+ * The hooks controller (see `installHooksController`) dispatches the
12
+ * `"connected"` / `"disconnected"` events synchronously on every raw edge,
13
+ * including a DOM move, so a move tears the setup down and re-attaches it
14
+ * deterministically.
15
+ *
16
+ * Setup runs exactly ONCE per connect. The teardown handle lives in a ref that
17
+ * persists across renders, so ordinary re-renders (which re-run this hook body)
18
+ * neither re-invoke setup nor leak — the setup/teardown pair is driven only from
19
+ * the edge, never from the render pass. The callback is read live through a ref,
20
+ * so callers may pass a fresh closure on every render.
21
+ */
4
22
  export function useConnected(callback, dependencies) {
5
23
  const component = Component_stack.active();
6
- let disconnect;
7
- function onConnect() {
8
- if (!disconnect) {
9
- disconnect = callback();
10
- }
11
- }
12
- function onDisconnect() {
13
- if (disconnect) {
14
- disconnect?.();
15
- disconnect = undefined;
16
- }
17
- }
18
- // TODO
19
- if (component.isConnected) {
20
- onConnect();
21
- }
24
+ // Live-read the latest callback so a fresh closure each render is honored
25
+ // without re-registering the edge listeners.
26
+ const callbackRef = useRef(callback);
27
+ callbackRef.current = callback;
28
+ // Persistent teardown handle: the cleanup returned by `callback` while
29
+ // connected, or `undefined` while disconnected. A ref (not a per-render
30
+ // local) so the "already connected" guard holds across renders — this is
31
+ // what makes setup idempotent per connect instead of per render.
32
+ const state = useRef({ disconnect: undefined });
22
33
  useEffect(() => {
34
+ const onConnect = () => {
35
+ if (!state.current.disconnect) {
36
+ state.current.disconnect = callbackRef.current();
37
+ }
38
+ };
39
+ const onDisconnect = () => {
40
+ if (state.current.disconnect) {
41
+ state.current.disconnect();
42
+ state.current.disconnect = undefined;
43
+ }
44
+ };
45
+ // On first mount the controller dispatches "connected" during
46
+ // `addController`, before this effect attaches its listener, so run setup
47
+ // now if the host is already connected.
48
+ if (component.isConnected) {
49
+ onConnect();
50
+ }
23
51
  component.addEventListener("connected", onConnect);
24
52
  component.addEventListener("disconnected", onDisconnect);
25
53
  return () => {
@@ -1 +1 @@
1
- {"version":3,"file":"use-connected.js","sourceRoot":"","sources":["../../src/hooks/use-connected.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAGvD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,UAAU,YAAY,CAAC,QAAwB,EAAE,YAAwB;IAC3E,MAAM,SAAS,GAAG,eAAe,CAAC,MAAM,EAAE,CAAC;IAE3C,IAAI,UAA+B,CAAC;IACpC,SAAS,SAAS;QACd,IAAI,CAAC,UAAU,EAAE,CAAC;YACd,UAAU,GAAG,QAAQ,EAAE,CAAC;QAC5B,CAAC;IACL,CAAC;IAED,SAAS,YAAY;QACjB,IAAI,UAAU,EAAE,CAAC;YACb,UAAU,EAAE,EAAE,CAAC;YACf,UAAU,GAAG,SAAS,CAAC;QAC3B,CAAC;IACL,CAAC;IAED,QAAQ;IACR,IAAI,SAAS,CAAC,WAAW,EAAE,CAAC;QACxB,SAAS,EAAE,CAAC;IAChB,CAAC;IAED,SAAS,CAAC,GAAG,EAAE;QACX,SAAS,CAAC,gBAAgB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;QACnD,SAAS,CAAC,gBAAgB,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QAEzD,OAAO,GAAG,EAAE;YACR,SAAS,CAAC,mBAAmB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;YACtD,SAAS,CAAC,mBAAmB,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;YAE5D,YAAY,EAAE,CAAC;QACnB,CAAC,CAAA;IAEL,CAAC,EAAE,YAAY,CAAC,CAAC;AACrB,CAAC"}
1
+ {"version":3,"file":"use-connected.js","sourceRoot":"","sources":["../../src/hooks/use-connected.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAGvD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,YAAY,CAAC,QAAwB,EAAE,YAAwB;IAC3E,MAAM,SAAS,GAAG,eAAe,CAAC,MAAM,EAAE,CAAC;IAE3C,0EAA0E;IAC1E,6CAA6C;IAC7C,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IACrC,WAAW,CAAC,OAAO,GAAG,QAAQ,CAAC;IAE/B,uEAAuE;IACvE,wEAAwE;IACxE,yEAAyE;IACzE,iEAAiE;IACjE,MAAM,KAAK,GAAG,MAAM,CAAsC,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC,CAAC;IAErF,SAAS,CAAC,GAAG,EAAE;QACX,MAAM,SAAS,GAAG,GAAG,EAAE;YACnB,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;gBAC5B,KAAK,CAAC,OAAO,CAAC,UAAU,GAAG,WAAW,CAAC,OAAO,EAAE,CAAC;YACrD,CAAC;QACL,CAAC,CAAC;QACF,MAAM,YAAY,GAAG,GAAG,EAAE;YACtB,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;gBAC3B,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;gBAC3B,KAAK,CAAC,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;YACzC,CAAC;QACL,CAAC,CAAC;QAEF,8DAA8D;QAC9D,0EAA0E;QAC1E,wCAAwC;QACxC,IAAI,SAAS,CAAC,WAAW,EAAE,CAAC;YACxB,SAAS,EAAE,CAAC;QAChB,CAAC;QACD,SAAS,CAAC,gBAAgB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;QACnD,SAAS,CAAC,gBAAgB,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QAEzD,OAAO,GAAG,EAAE;YACR,SAAS,CAAC,mBAAmB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;YACtD,SAAS,CAAC,mBAAmB,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;YAC5D,YAAY,EAAE,CAAC;QACnB,CAAC,CAAC;IACN,CAAC,EAAE,YAAY,CAAC,CAAC;AACrB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-lit",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
4
4
  "description": "Adobe data Lit bindings - hooks, elements, decorators",
5
5
  "type": "module",
6
6
  "private": false,
@@ -12,7 +12,7 @@
12
12
  "access": "public"
13
13
  },
14
14
  "dependencies": {
15
- "@adobe/data": "0.10.14"
15
+ "@adobe/data": "0.10.16"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "lit": "^3.3.1"
@@ -131,12 +131,15 @@ describe("hook disposal on real DOM disconnect", () => {
131
131
  // The element is still mounted and its subscription is intact...
132
132
  expect(el.isConnected).toBe(true);
133
133
  expect(subscriberCount()).toBe(1);
134
- // ...and, crucially, the move neither disposed nor re-created it. Before the
135
- // deferred-disposal fix, a move disposed and re-subscribed (2 subs / 1 unsub)
136
- // and forced an extra re-render — the source of the studio-page lag, churn,
137
- // and transient empty lists on project switch (FFP-111900).
134
+ // ...and, crucially, the move neither disposed nor re-created the subscription:
135
+ // no re-subscribe, no producer re-run. That is the perf win (before this, a move
136
+ // disposed and re-subscribed — 2 subs / 1 unsub — the source of the studio-page
137
+ // lag, churn, and transient empty lists on project switch, FFP-111900).
138
138
  expect(totalUnsubscribes()).toBe(0);
139
139
  expect(totalSubscribes()).toBe(1);
140
- expect(updatesDuringMove).toBe(0);
140
+ // The reconnect DOES force exactly one re-render — a cheap Lit diff over the
141
+ // preserved subscription (no re-subscribe), required so an element that reads
142
+ // mutable non-hook state in render refreshes after a move.
143
+ expect(updatesDuringMove).toBe(1);
141
144
  });
142
145
  });
@@ -7,8 +7,10 @@ import { installHooksController } from "./hooks-controller.js";
7
7
  /**
8
8
  * Minimal stand-in for a Lit host: an EventTarget that captures the controller
9
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.
10
+ * directly (a real Lit element would need a DOM). `connect()` / `disconnect()`
11
+ * flip `isConnected` BEFORE dispatching, mirroring the DOM (the callback runs
12
+ * after connectedness has already changed) — the controller's deferred
13
+ * finalization reads `isConnected` to tell a move from a genuine unmount.
12
14
  */
13
15
  class FakeHost extends EventTarget implements Component {
14
16
  isConnected = true;
@@ -21,20 +23,21 @@ class FakeHost extends EventTarget implements Component {
21
23
  addController(controller: ReactiveController) {
22
24
  this.controllers.push(controller);
23
25
  }
24
- // Simulate Lit dispatching lifecycle to every registered controller.
25
26
  connect() {
27
+ this.isConnected = true;
26
28
  for (const c of this.controllers) c.hostConnected?.();
27
29
  }
28
30
  disconnect() {
31
+ this.isConnected = false;
29
32
  for (const c of this.controllers) c.hostDisconnected?.();
30
33
  }
31
34
  }
32
35
 
33
- /** Resolves after the current microtask checkpoint, so deferred teardown can run. */
36
+ /** Resolves after the current microtask checkpoint, so deferred finalization can run. */
34
37
  const flushMicrotasks = (): Promise<void> => new Promise(resolve => queueMicrotask(resolve));
35
38
 
36
39
  describe("installHooksController", () => {
37
- it("disposes every effect hook and resets the cursor on a genuine disconnect", async () => {
40
+ it("finalizes every effect hook and resets the cursor on a genuine disconnect", async () => {
38
41
  const host = new FakeHost();
39
42
  const disposeA = vi.fn();
40
43
  const disposeB = vi.fn();
@@ -48,8 +51,8 @@ describe("installHooksController", () => {
48
51
 
49
52
  installHooksController(host);
50
53
  host.disconnect();
51
- // Teardown is deferred one microtask (so a move can cancel it); with no
52
- // reconnect this is a real unmount, so it runs on the next checkpoint.
54
+ // Finalization is deferred one microtask (so a move can cancel it); with no
55
+ // reconnect the host is still disconnected, so it runs on the next checkpoint.
53
56
  await flushMicrotasks();
54
57
 
55
58
  expect(disposeA).toHaveBeenCalledTimes(1);
@@ -58,53 +61,103 @@ describe("installHooksController", () => {
58
61
  expect(host.hookIndex).toBe(0);
59
62
  });
60
63
 
61
- it("a synchronous disconnect→reconnect (a MOVE) cancels teardown and forces no re-render", async () => {
64
+ it("fires the connect / disconnect EDGE synchronously — no microtask needed", () => {
65
+ const host = new FakeHost();
66
+ const onConnected = vi.fn();
67
+ const onDisconnected = vi.fn();
68
+ host.addEventListener("connected", onConnected);
69
+ host.addEventListener("disconnected", onDisconnected);
70
+
71
+ installHooksController(host);
72
+ host.connect();
73
+ expect(onConnected).toHaveBeenCalledTimes(1);
74
+
75
+ host.disconnect();
76
+ // No flush: the edge is synchronous so connectedness hooks (useConnected)
77
+ // tear down inline. Under the old defer-everything controller this event
78
+ // only fired on a later microtask.
79
+ expect(onDisconnected).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it("across a MOVE: fires both edges synchronously, PRESERVES the slot, and re-renders on reconnect", async () => {
62
83
  const host = new FakeHost();
63
84
  const dispose = vi.fn();
64
85
  host.hooks = [{ dispose, dependencies: [] }];
65
86
  host.hookIndex = 1;
87
+ const edges: string[] = [];
88
+ host.addEventListener("disconnected", () => edges.push("disconnect"));
89
+ host.addEventListener("connected", () => edges.push("connect"));
66
90
 
67
91
  installHooksController(host);
68
- host.connect(); // first mount
92
+ host.connect(); // initial mount
69
93
  host.requestUpdate.mockClear();
94
+ edges.length = 0;
70
95
 
71
- // Disconnect immediately followed by reconnect, same task — a DOM move.
96
+ // Move: disconnect immediately followed by reconnect, same task.
72
97
  host.disconnect();
73
98
  host.connect();
74
- await flushMicrotasks();
99
+ expect(edges).toEqual(["disconnect", "connect"]); // both edges, synchronous, in order
75
100
 
76
- // Nothing was torn down and no re-render was forced: the hooks survived.
101
+ await flushMicrotasks();
102
+ // The slot SURVIVED the move — not disposed, not re-created...
77
103
  expect(dispose).not.toHaveBeenCalled();
78
104
  expect(host.hooks).toHaveLength(1);
79
- expect(host.requestUpdate).not.toHaveBeenCalled();
105
+ // ...but the reconnect DID force one re-render. This is correctness, not churn:
106
+ // the preserved slot is not re-subscribed, so it is a cheap Lit diff — required
107
+ // so an element reading mutable non-hook state in render refreshes after a move.
108
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
80
109
  });
81
110
 
82
- it("dispatches connected / disconnected events across the lifecycle", async () => {
111
+ it("a rapid disconnect→connect→disconnect bounce finalizes exactly once (net state wins)", async () => {
83
112
  const host = new FakeHost();
84
- const onConnected = vi.fn();
85
- const onDisconnected = vi.fn();
86
- host.addEventListener("connected", onConnected);
87
- host.addEventListener("disconnected", onDisconnected);
113
+ const dispose = vi.fn();
114
+ host.hooks = [{ dispose, dependencies: [] }];
88
115
 
89
116
  installHooksController(host);
117
+ // Bounce within one task: only one finalization microtask is queued, and it
118
+ // reads the final isConnected (false) → finalizes once, not per disconnect.
119
+ host.disconnect();
90
120
  host.connect();
91
- expect(onConnected).toHaveBeenCalledTimes(1);
92
-
93
121
  host.disconnect();
94
122
  await flushMicrotasks();
95
- expect(onDisconnected).toHaveBeenCalledTimes(1);
123
+
124
+ expect(dispose).toHaveBeenCalledTimes(1);
125
+ expect(host.hooks).toEqual([]);
96
126
  });
97
127
 
98
- it("forces a re-render only on a genuine RE-connect, not the first connect", async () => {
128
+ it("gates finalization on isConnected — a slot that reconnects before the microtask survives", async () => {
99
129
  const host = new FakeHost();
130
+ const dispose = vi.fn();
131
+ host.hooks = [{ dispose, dependencies: [] }];
132
+
100
133
  installHooksController(host);
134
+ host.disconnect(); // schedules finalization; isConnected now false
135
+ host.connect(); // reconnects in the same task; isConnected back to true
136
+ await flushMicrotasks();
101
137
 
102
- host.connect(); // first mount
138
+ expect(dispose).not.toHaveBeenCalled();
139
+ expect(host.hooks).toHaveLength(1);
140
+ });
141
+
142
+ it("forces a re-render on EVERY reconnect — a move and a genuine remount — but not the first connect", async () => {
143
+ const host = new FakeHost();
144
+ installHooksController(host);
145
+
146
+ host.connect(); // first mount — Lit renders it anyway, no forced update
103
147
  expect(host.requestUpdate).not.toHaveBeenCalled();
104
148
 
149
+ // A MOVE (reconnect with slots preserved) forces a re-render.
105
150
  host.disconnect();
106
- await flushMicrotasks(); // genuine unmount: teardown runs
107
- host.connect(); // reconnect re-initializes the torn-down hooks
151
+ host.connect();
152
+ await flushMicrotasks(); // finalization skipped (reconnected in the same task)
153
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
154
+
155
+ // A genuine unmount+remount (slots finalized) also forces a re-render, which
156
+ // re-initializes and re-subscribes the cleared slots.
157
+ host.requestUpdate.mockClear();
158
+ host.disconnect();
159
+ await flushMicrotasks(); // genuine unmount: finalization runs
160
+ host.connect();
108
161
  expect(host.requestUpdate).toHaveBeenCalledTimes(1);
109
162
  });
110
163