@adobe/data-lit 0.10.11 → 0.10.14

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.
@@ -12,11 +12,25 @@ import type { Component } from "./component.js";
12
12
  *
13
13
  * This installs a single Lit {@link ReactiveController} — the one hook-friendly
14
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).
15
+ * - on disconnect, *schedules* (on a microtask) dispatching `"disconnected"`,
16
+ * disposing every hook slot, and clearing the cursor (full unmount semantics);
17
+ * - on connect, if a teardown is still pending it CANCELS it (the disconnect
18
+ * was half of a DOM move — hooks are intact, so nothing re-subscribes and no
19
+ * re-render is forced); otherwise it dispatches `"connected"` and, on a true
20
+ * reconnect, forces a re-render so the torn-down hooks re-initialize.
21
+ *
22
+ * ## Why disposal is deferred
23
+ *
24
+ * The DOM fires `disconnectedCallback` then `connectedCallback` **synchronously,
25
+ * in the same task,** whenever a connected element is merely *moved* — a Lit
26
+ * `repeat` reorder, a list re-parenting, a drag-reorder. Disposing inline would
27
+ * tear down and rebuild every subscription (re-running uncached producers) plus
28
+ * force a re-render on every such move, with the hook state momentarily reset —
29
+ * the studio-page lag, refresh churn, and transient empty lists on project
30
+ * switch (FFP-111900). Deferring teardown one microtask lets a synchronous
31
+ * reconnect distinguish a move (cancel) from a genuine unmount (let it run), so
32
+ * a move is a no-op and only a real unmount disposes. A late reconnect (a
33
+ * different task) is treated as unmount+remount, which is correct.
20
34
  *
21
35
  * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
22
36
  * which runs after `connectedCallback`, so `addController` fires `hostConnected`
@@ -14,14 +14,18 @@ import { useObservable } from "../use-observable.js";
14
14
  * is torn down when the element leaves the DOM (instead of leaking and firing
15
15
  * `requestUpdate` on a detached element).
16
16
  */
17
- /** An Observe whose live subscriber count the test can inspect. */
17
+ /** An Observe whose live subscriber count and cumulative churn the test can inspect. */
18
18
  function createTrackedObservable() {
19
19
  const subscribers = new Set();
20
20
  let current = 0;
21
+ let subscribes = 0;
22
+ let unsubscribes = 0;
21
23
  const observable = observer => {
24
+ subscribes++;
22
25
  subscribers.add(observer);
23
26
  observer(current);
24
27
  return () => {
28
+ unsubscribes++;
25
29
  subscribers.delete(observer);
26
30
  };
27
31
  };
@@ -33,8 +37,12 @@ function createTrackedObservable() {
33
37
  observer(value);
34
38
  },
35
39
  subscriberCount: () => subscribers.size,
40
+ totalSubscribes: () => subscribes,
41
+ totalUnsubscribes: () => unsubscribes,
36
42
  };
37
43
  }
44
+ /** Resolves after the current microtask checkpoint, so deferred teardown can run. */
45
+ const flushMicrotasks = () => new Promise(resolve => queueMicrotask(resolve));
38
46
  class HookProbeElement extends LitElement {
39
47
  constructor() {
40
48
  super();
@@ -63,6 +71,9 @@ describe("hook disposal on real DOM disconnect", () => {
63
71
  originalRequestUpdate(...args);
64
72
  };
65
73
  el.remove();
74
+ // Teardown is deferred one microtask (so a re-parent MOVE can cancel it —
75
+ // see the move test below); a genuine unmount has no reconnect, so it runs.
76
+ await flushMicrotasks();
66
77
  // Disconnect ran the effect cleanup: the subscription is gone.
67
78
  expect(subscriberCount()).toBe(0);
68
79
  // A later emit reaches no leaked subscriber, so the detached element is
@@ -70,5 +81,39 @@ describe("hook disposal on real DOM disconnect", () => {
70
81
  emit(99);
71
82
  expect(updatesAfterRemove).toBe(0);
72
83
  });
84
+ it("does NOT tear down and rebuild subscriptions when the element is merely MOVED", async () => {
85
+ const { observable, subscriberCount, totalSubscribes, totalUnsubscribes } = createTrackedObservable();
86
+ const parentA = document.createElement("div");
87
+ const parentB = document.createElement("div");
88
+ document.body.appendChild(parentA);
89
+ document.body.appendChild(parentB);
90
+ const el = document.createElement("hook-probe-element");
91
+ el.observable = observable;
92
+ parentA.appendChild(el);
93
+ await el.updateComplete;
94
+ expect(subscriberCount()).toBe(1);
95
+ expect(totalSubscribes()).toBe(1);
96
+ let updatesDuringMove = 0;
97
+ const originalRequestUpdate = el.requestUpdate.bind(el);
98
+ el.requestUpdate = (...args) => {
99
+ updatesDuringMove++;
100
+ originalRequestUpdate(...args);
101
+ };
102
+ // MOVE: appendChild to another already-connected parent fires
103
+ // disconnectedCallback then connectedCallback synchronously, in one task.
104
+ parentB.appendChild(el);
105
+ await flushMicrotasks();
106
+ await el.updateComplete;
107
+ // The element is still mounted and its subscription is intact...
108
+ expect(el.isConnected).toBe(true);
109
+ expect(subscriberCount()).toBe(1);
110
+ // ...and, crucially, the move neither disposed nor re-created it. Before the
111
+ // deferred-disposal fix, a move disposed and re-subscribed (2 subs / 1 unsub)
112
+ // and forced an extra re-render — the source of the studio-page lag, churn,
113
+ // and transient empty lists on project switch (FFP-111900).
114
+ expect(totalUnsubscribes()).toBe(0);
115
+ expect(totalSubscribes()).toBe(1);
116
+ expect(updatesDuringMove).toBe(0);
117
+ });
73
118
  });
74
119
  //# sourceMappingURL=hooks-controller.integration.test.js.map
@@ -1 +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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"}
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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"}
@@ -42,11 +42,25 @@ function disposeHooks(host) {
42
42
  *
43
43
  * This installs a single Lit {@link ReactiveController} — the one hook-friendly
44
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).
45
+ * - on disconnect, *schedules* (on a microtask) dispatching `"disconnected"`,
46
+ * disposing every hook slot, and clearing the cursor (full unmount semantics);
47
+ * - on connect, if a teardown is still pending it CANCELS it (the disconnect
48
+ * was half of a DOM move — hooks are intact, so nothing re-subscribes and no
49
+ * re-render is forced); otherwise it dispatches `"connected"` and, on a true
50
+ * reconnect, forces a re-render so the torn-down hooks re-initialize.
51
+ *
52
+ * ## Why disposal is deferred
53
+ *
54
+ * The DOM fires `disconnectedCallback` then `connectedCallback` **synchronously,
55
+ * in the same task,** whenever a connected element is merely *moved* — a Lit
56
+ * `repeat` reorder, a list re-parenting, a drag-reorder. Disposing inline would
57
+ * tear down and rebuild every subscription (re-running uncached producers) plus
58
+ * force a re-render on every such move, with the hook state momentarily reset —
59
+ * the studio-page lag, refresh churn, and transient empty lists on project
60
+ * switch (FFP-111900). Deferring teardown one microtask lets a synchronous
61
+ * reconnect distinguish a move (cancel) from a genuine unmount (let it run), so
62
+ * a move is a no-op and only a real unmount disposes. A late reconnect (a
63
+ * different task) is treated as unmount+remount, which is correct.
50
64
  *
51
65
  * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
52
66
  * which runs after `connectedCallback`, so `addController` fires `hostConnected`
@@ -64,8 +78,18 @@ export function installHooksController(host) {
64
78
  // guards against a re-entrant render re-installing a duplicate controller.
65
79
  host[HOOKS_CONTROLLER] = true;
66
80
  let connectedOnce = false;
81
+ // True between a disconnect and its scheduled teardown. A reconnect that sees
82
+ // this flag set is the tail of a move and cancels the teardown.
83
+ let pendingDisposal = false;
67
84
  const controller = {
68
85
  hostConnected() {
86
+ if (pendingDisposal) {
87
+ // Tail of a MOVE: the just-fired disconnect scheduled a teardown that
88
+ // has not run yet. Cancel it — the hooks are untouched, so there is
89
+ // nothing to re-subscribe and no reason to force a re-render.
90
+ pendingDisposal = false;
91
+ return;
92
+ }
69
93
  // The "connected" / "disconnected" events are for EXTERNAL listeners only.
70
94
  // The internal useConnected does NOT depend on them: this fires before the
71
95
  // render body attaches any listener, so useConnected is driven by its direct
@@ -79,8 +103,17 @@ export function installHooksController(host) {
79
103
  connectedOnce = true;
80
104
  },
81
105
  hostDisconnected() {
82
- host.dispatchEvent(new Event("disconnected"));
83
- disposeHooks(host);
106
+ // Defer teardown one microtask so a synchronous reconnect (a DOM move)
107
+ // can cancel it above. Only a genuine unmount reaches the callback.
108
+ pendingDisposal = true;
109
+ queueMicrotask(() => {
110
+ if (!pendingDisposal) {
111
+ return;
112
+ }
113
+ pendingDisposal = false;
114
+ host.dispatchEvent(new Event("disconnected"));
115
+ disposeHooks(host);
116
+ });
84
117
  },
85
118
  };
86
119
  try {
@@ -1 +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"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;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,8EAA8E;IAC9E,gEAAgE;IAChE,IAAI,eAAe,GAAG,KAAK,CAAC;IAC5B,MAAM,UAAU,GAAuB;QACnC,aAAa;YACT,IAAI,eAAe,EAAE,CAAC;gBAClB,sEAAsE;gBACtE,oEAAoE;gBACpE,8DAA8D;gBAC9D,eAAe,GAAG,KAAK,CAAC;gBACxB,OAAO;YACX,CAAC;YACD,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,uEAAuE;YACvE,oEAAoE;YACpE,eAAe,GAAG,IAAI,CAAC;YACvB,cAAc,CAAC,GAAG,EAAE;gBAChB,IAAI,CAAC,eAAe,EAAE,CAAC;oBACnB,OAAO;gBACX,CAAC;gBACD,eAAe,GAAG,KAAK,CAAC;gBACxB,IAAI,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC;gBAC9C,YAAY,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC,CAAC,CAAC;QACP,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"}
@@ -30,8 +30,10 @@ class FakeHost extends EventTarget {
30
30
  c.hostDisconnected?.();
31
31
  }
32
32
  }
33
+ /** Resolves after the current microtask checkpoint, so deferred teardown can run. */
34
+ const flushMicrotasks = () => new Promise(resolve => queueMicrotask(resolve));
33
35
  describe("installHooksController", () => {
34
- it("disposes every effect hook and resets the cursor on disconnect", () => {
36
+ it("disposes every effect hook and resets the cursor on a genuine disconnect", async () => {
35
37
  const host = new FakeHost();
36
38
  const disposeA = vi.fn();
37
39
  const disposeB = vi.fn();
@@ -44,12 +46,32 @@ describe("installHooksController", () => {
44
46
  host.hookIndex = 4;
45
47
  installHooksController(host);
46
48
  host.disconnect();
49
+ // Teardown is deferred one microtask (so a move can cancel it); with no
50
+ // reconnect this is a real unmount, so it runs on the next checkpoint.
51
+ await flushMicrotasks();
47
52
  expect(disposeA).toHaveBeenCalledTimes(1);
48
53
  expect(disposeB).toHaveBeenCalledTimes(1);
49
54
  expect(host.hooks).toEqual([]);
50
55
  expect(host.hookIndex).toBe(0);
51
56
  });
52
- it("dispatches connected / disconnected events across the lifecycle", () => {
57
+ it("a synchronous disconnect→reconnect (a MOVE) cancels teardown and forces no re-render", async () => {
58
+ const host = new FakeHost();
59
+ const dispose = vi.fn();
60
+ host.hooks = [{ dispose, dependencies: [] }];
61
+ host.hookIndex = 1;
62
+ installHooksController(host);
63
+ host.connect(); // first mount
64
+ host.requestUpdate.mockClear();
65
+ // Disconnect immediately followed by reconnect, same task — a DOM move.
66
+ host.disconnect();
67
+ host.connect();
68
+ await flushMicrotasks();
69
+ // Nothing was torn down and no re-render was forced: the hooks survived.
70
+ expect(dispose).not.toHaveBeenCalled();
71
+ expect(host.hooks).toHaveLength(1);
72
+ expect(host.requestUpdate).not.toHaveBeenCalled();
73
+ });
74
+ it("dispatches connected / disconnected events across the lifecycle", async () => {
53
75
  const host = new FakeHost();
54
76
  const onConnected = vi.fn();
55
77
  const onDisconnected = vi.fn();
@@ -59,15 +81,17 @@ describe("installHooksController", () => {
59
81
  host.connect();
60
82
  expect(onConnected).toHaveBeenCalledTimes(1);
61
83
  host.disconnect();
84
+ await flushMicrotasks();
62
85
  expect(onDisconnected).toHaveBeenCalledTimes(1);
63
86
  });
64
- it("forces a re-render only on RE-connect, not the first connect", () => {
87
+ it("forces a re-render only on a genuine RE-connect, not the first connect", async () => {
65
88
  const host = new FakeHost();
66
89
  installHooksController(host);
67
90
  host.connect(); // first mount
68
91
  expect(host.requestUpdate).not.toHaveBeenCalled();
69
92
  host.disconnect();
70
- host.connect(); // reconnect
93
+ await flushMicrotasks(); // genuine unmount: teardown runs
94
+ host.connect(); // reconnect re-initializes the torn-down hooks
71
95
  expect(host.requestUpdate).toHaveBeenCalledTimes(1);
72
96
  });
73
97
  it("is idempotent — repeated installs register a single controller", () => {
@@ -1 +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
+ {"version":3,"file":"hooks-controller.test.js","sourceRoot":"","sources":["../../../src/hooks/component/hooks-controller.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-lit",
3
- "version": "0.10.11",
3
+ "version": "0.10.14",
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.11"
15
+ "@adobe/data": "0.10.14"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "lit": "^3.3.1"
@@ -17,18 +17,24 @@ import { useObservable } from "../use-observable.js";
17
17
  * `requestUpdate` on a detached element).
18
18
  */
19
19
 
20
- /** An Observe whose live subscriber count the test can inspect. */
20
+ /** An Observe whose live subscriber count and cumulative churn the test can inspect. */
21
21
  function createTrackedObservable(): {
22
22
  observable: Observe<number>;
23
23
  emit: (value: number) => void;
24
24
  subscriberCount: () => number;
25
+ totalSubscribes: () => number;
26
+ totalUnsubscribes: () => number;
25
27
  } {
26
28
  const subscribers = new Set<(value: number) => void>();
27
29
  let current = 0;
30
+ let subscribes = 0;
31
+ let unsubscribes = 0;
28
32
  const observable: Observe<number> = observer => {
33
+ subscribes++;
29
34
  subscribers.add(observer);
30
35
  observer(current);
31
36
  return () => {
37
+ unsubscribes++;
32
38
  subscribers.delete(observer);
33
39
  };
34
40
  };
@@ -39,9 +45,14 @@ function createTrackedObservable(): {
39
45
  for (const observer of subscribers) observer(value);
40
46
  },
41
47
  subscriberCount: () => subscribers.size,
48
+ totalSubscribes: () => subscribes,
49
+ totalUnsubscribes: () => unsubscribes,
42
50
  };
43
51
  }
44
52
 
53
+ /** Resolves after the current microtask checkpoint, so deferred teardown can run. */
54
+ const flushMicrotasks = (): Promise<void> => new Promise(resolve => queueMicrotask(resolve));
55
+
45
56
  class HookProbeElement extends LitElement {
46
57
  observable!: Observe<number>;
47
58
  constructor() {
@@ -76,6 +87,9 @@ describe("hook disposal on real DOM disconnect", () => {
76
87
  };
77
88
 
78
89
  el.remove();
90
+ // Teardown is deferred one microtask (so a re-parent MOVE can cancel it —
91
+ // see the move test below); a genuine unmount has no reconnect, so it runs.
92
+ await flushMicrotasks();
79
93
 
80
94
  // Disconnect ran the effect cleanup: the subscription is gone.
81
95
  expect(subscriberCount()).toBe(0);
@@ -85,4 +99,44 @@ describe("hook disposal on real DOM disconnect", () => {
85
99
  emit(99);
86
100
  expect(updatesAfterRemove).toBe(0);
87
101
  });
102
+
103
+ it("does NOT tear down and rebuild subscriptions when the element is merely MOVED", async () => {
104
+ const { observable, subscriberCount, totalSubscribes, totalUnsubscribes } = createTrackedObservable();
105
+
106
+ const parentA = document.createElement("div");
107
+ const parentB = document.createElement("div");
108
+ document.body.appendChild(parentA);
109
+ document.body.appendChild(parentB);
110
+
111
+ const el = document.createElement("hook-probe-element") as HookProbeElement;
112
+ el.observable = observable;
113
+ parentA.appendChild(el);
114
+ await el.updateComplete;
115
+ expect(subscriberCount()).toBe(1);
116
+ expect(totalSubscribes()).toBe(1);
117
+
118
+ let updatesDuringMove = 0;
119
+ const originalRequestUpdate = el.requestUpdate.bind(el);
120
+ el.requestUpdate = (...args: Parameters<HookProbeElement["requestUpdate"]>) => {
121
+ updatesDuringMove++;
122
+ originalRequestUpdate(...args);
123
+ };
124
+
125
+ // MOVE: appendChild to another already-connected parent fires
126
+ // disconnectedCallback then connectedCallback synchronously, in one task.
127
+ parentB.appendChild(el);
128
+ await flushMicrotasks();
129
+ await el.updateComplete;
130
+
131
+ // The element is still mounted and its subscription is intact...
132
+ expect(el.isConnected).toBe(true);
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).
138
+ expect(totalUnsubscribes()).toBe(0);
139
+ expect(totalSubscribes()).toBe(1);
140
+ expect(updatesDuringMove).toBe(0);
141
+ });
88
142
  });
@@ -30,8 +30,11 @@ class FakeHost extends EventTarget implements Component {
30
30
  }
31
31
  }
32
32
 
33
+ /** Resolves after the current microtask checkpoint, so deferred teardown can run. */
34
+ const flushMicrotasks = (): Promise<void> => new Promise(resolve => queueMicrotask(resolve));
35
+
33
36
  describe("installHooksController", () => {
34
- it("disposes every effect hook and resets the cursor on disconnect", () => {
37
+ it("disposes every effect hook and resets the cursor on a genuine disconnect", async () => {
35
38
  const host = new FakeHost();
36
39
  const disposeA = vi.fn();
37
40
  const disposeB = vi.fn();
@@ -45,6 +48,9 @@ describe("installHooksController", () => {
45
48
 
46
49
  installHooksController(host);
47
50
  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.
53
+ await flushMicrotasks();
48
54
 
49
55
  expect(disposeA).toHaveBeenCalledTimes(1);
50
56
  expect(disposeB).toHaveBeenCalledTimes(1);
@@ -52,7 +58,28 @@ describe("installHooksController", () => {
52
58
  expect(host.hookIndex).toBe(0);
53
59
  });
54
60
 
55
- it("dispatches connected / disconnected events across the lifecycle", () => {
61
+ it("a synchronous disconnect→reconnect (a MOVE) cancels teardown and forces no re-render", async () => {
62
+ const host = new FakeHost();
63
+ const dispose = vi.fn();
64
+ host.hooks = [{ dispose, dependencies: [] }];
65
+ host.hookIndex = 1;
66
+
67
+ installHooksController(host);
68
+ host.connect(); // first mount
69
+ host.requestUpdate.mockClear();
70
+
71
+ // Disconnect immediately followed by reconnect, same task — a DOM move.
72
+ host.disconnect();
73
+ host.connect();
74
+ await flushMicrotasks();
75
+
76
+ // Nothing was torn down and no re-render was forced: the hooks survived.
77
+ expect(dispose).not.toHaveBeenCalled();
78
+ expect(host.hooks).toHaveLength(1);
79
+ expect(host.requestUpdate).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("dispatches connected / disconnected events across the lifecycle", async () => {
56
83
  const host = new FakeHost();
57
84
  const onConnected = vi.fn();
58
85
  const onDisconnected = vi.fn();
@@ -64,10 +91,11 @@ describe("installHooksController", () => {
64
91
  expect(onConnected).toHaveBeenCalledTimes(1);
65
92
 
66
93
  host.disconnect();
94
+ await flushMicrotasks();
67
95
  expect(onDisconnected).toHaveBeenCalledTimes(1);
68
96
  });
69
97
 
70
- it("forces a re-render only on RE-connect, not the first connect", () => {
98
+ it("forces a re-render only on a genuine RE-connect, not the first connect", async () => {
71
99
  const host = new FakeHost();
72
100
  installHooksController(host);
73
101
 
@@ -75,7 +103,8 @@ describe("installHooksController", () => {
75
103
  expect(host.requestUpdate).not.toHaveBeenCalled();
76
104
 
77
105
  host.disconnect();
78
- host.connect(); // reconnect
106
+ await flushMicrotasks(); // genuine unmount: teardown runs
107
+ host.connect(); // reconnect re-initializes the torn-down hooks
79
108
  expect(host.requestUpdate).toHaveBeenCalledTimes(1);
80
109
  });
81
110
 
@@ -51,11 +51,25 @@ function disposeHooks(host: Component): void {
51
51
  *
52
52
  * This installs a single Lit {@link ReactiveController} — the one hook-friendly
53
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).
54
+ * - on disconnect, *schedules* (on a microtask) dispatching `"disconnected"`,
55
+ * disposing every hook slot, and clearing the cursor (full unmount semantics);
56
+ * - on connect, if a teardown is still pending it CANCELS it (the disconnect
57
+ * was half of a DOM move — hooks are intact, so nothing re-subscribes and no
58
+ * re-render is forced); otherwise it dispatches `"connected"` and, on a true
59
+ * reconnect, forces a re-render so the torn-down hooks re-initialize.
60
+ *
61
+ * ## Why disposal is deferred
62
+ *
63
+ * The DOM fires `disconnectedCallback` then `connectedCallback` **synchronously,
64
+ * in the same task,** whenever a connected element is merely *moved* — a Lit
65
+ * `repeat` reorder, a list re-parenting, a drag-reorder. Disposing inline would
66
+ * tear down and rebuild every subscription (re-running uncached producers) plus
67
+ * force a re-render on every such move, with the hook state momentarily reset —
68
+ * the studio-page lag, refresh churn, and transient empty lists on project
69
+ * switch (FFP-111900). Deferring teardown one microtask lets a synchronous
70
+ * reconnect distinguish a move (cancel) from a genuine unmount (let it run), so
71
+ * a move is a no-op and only a real unmount disposes. A late reconnect (a
72
+ * different task) is treated as unmount+remount, which is correct.
59
73
  *
60
74
  * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
61
75
  * which runs after `connectedCallback`, so `addController` fires `hostConnected`
@@ -74,8 +88,18 @@ export function installHooksController(host: Component): void {
74
88
  host[HOOKS_CONTROLLER] = true;
75
89
 
76
90
  let connectedOnce = false;
91
+ // True between a disconnect and its scheduled teardown. A reconnect that sees
92
+ // this flag set is the tail of a move and cancels the teardown.
93
+ let pendingDisposal = false;
77
94
  const controller: ReactiveController = {
78
95
  hostConnected() {
96
+ if (pendingDisposal) {
97
+ // Tail of a MOVE: the just-fired disconnect scheduled a teardown that
98
+ // has not run yet. Cancel it — the hooks are untouched, so there is
99
+ // nothing to re-subscribe and no reason to force a re-render.
100
+ pendingDisposal = false;
101
+ return;
102
+ }
79
103
  // The "connected" / "disconnected" events are for EXTERNAL listeners only.
80
104
  // The internal useConnected does NOT depend on them: this fires before the
81
105
  // render body attaches any listener, so useConnected is driven by its direct
@@ -89,8 +113,17 @@ export function installHooksController(host: Component): void {
89
113
  connectedOnce = true;
90
114
  },
91
115
  hostDisconnected() {
92
- host.dispatchEvent(new Event("disconnected"));
93
- disposeHooks(host);
116
+ // Defer teardown one microtask so a synchronous reconnect (a DOM move)
117
+ // can cancel it above. Only a genuine unmount reaches the callback.
118
+ pendingDisposal = true;
119
+ queueMicrotask(() => {
120
+ if (!pendingDisposal) {
121
+ return;
122
+ }
123
+ pendingDisposal = false;
124
+ host.dispatchEvent(new Event("disconnected"));
125
+ disposeHooks(host);
126
+ });
94
127
  },
95
128
  };
96
129
  try {