@adobe/data-lit 0.10.13 → 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.
- package/dist/hooks/component/hooks-controller.d.ts +19 -5
- package/dist/hooks/component/hooks-controller.integration.test.js +46 -1
- package/dist/hooks/component/hooks-controller.integration.test.js.map +1 -1
- package/dist/hooks/component/hooks-controller.js +40 -7
- package/dist/hooks/component/hooks-controller.js.map +1 -1
- package/dist/hooks/component/hooks-controller.test.js +28 -4
- package/dist/hooks/component/hooks-controller.test.js.map +1 -1
- package/package.json +2 -2
- package/src/hooks/component/hooks-controller.integration.test.ts +55 -1
- package/src/hooks/component/hooks-controller.test.ts +33 -4
- package/src/hooks/component/hooks-controller.ts +40 -7
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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":"AAAA,uDAAuD;AACvD,gCAAgC;AAChC,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC;AAEvC,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAErD;;;;;;;;GAQG;AAEH,
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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":"AAAA,uDAAuD;AACvD,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAGlD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAE/D;;;;;GAKG;AACH,MAAM,QAAS,SAAQ,WAAW;IAAlC;;QACI,gBAAW,GAAG,IAAI,CAAC;QACnB,cAAS,GAAG,CAAC,CAAC;QACd,UAAK,GAAU,EAAE,CAAC;QAClB,qBAAgB,GAAG,IAAI,GAAG,EAAc,CAAC;QACzC,kBAAa,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QACxB,gBAAW,GAAyB,EAAE,CAAC;IAY3C,CAAC;IAVG,aAAa,CAAC,UAA8B;QACxC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACtC,CAAC;IACD,qEAAqE;IACrE,OAAO;QACH,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,WAAW;YAAE,CAAC,CAAC,aAAa,EAAE,EAAE,CAAC;IAC1D,CAAC;IACD,UAAU;QACN,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,WAAW;YAAE,CAAC,CAAC,gBAAgB,EAAE,EAAE,CAAC;IAC7D,CAAC;CACJ;AAED,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,
|
|
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.
|
|
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.
|
|
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("
|
|
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
|
-
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
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 {
|