@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.
- package/dist/hooks/component/hooks-controller.d.ts +37 -27
- package/dist/hooks/component/hooks-controller.integration.test.js +8 -5
- package/dist/hooks/component/hooks-controller.integration.test.js.map +1 -1
- package/dist/hooks/component/hooks-controller.js +73 -60
- package/dist/hooks/component/hooks-controller.js.map +1 -1
- package/dist/hooks/component/hooks-controller.test.js +69 -23
- package/dist/hooks/component/hooks-controller.test.js.map +1 -1
- package/dist/hooks/component/hooks-lifecycle.integration.test.d.ts +1 -0
- package/dist/hooks/component/hooks-lifecycle.integration.test.js +312 -0
- package/dist/hooks/component/hooks-lifecycle.integration.test.js.map +1 -0
- package/dist/hooks/use-connected.d.ts +17 -0
- package/dist/hooks/use-connected.js +44 -16
- package/dist/hooks/use-connected.js.map +1 -1
- package/package.json +2 -2
- package/src/hooks/component/hooks-controller.integration.test.ts +8 -5
- package/src/hooks/component/hooks-controller.test.ts +78 -25
- package/src/hooks/component/hooks-controller.ts +74 -60
- package/src/hooks/component/hooks-lifecycle.integration.test.ts +354 -0
- package/src/hooks/use-connected.ts +47 -21
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -17,59 +17,64 @@ function isReactiveHost(host: Component): host is ReactiveHost {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
* render re-initializes
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Finalize a host: dispose every hook slot's cleanup and clear the slots so the
|
|
21
|
+
* next render re-initializes from scratch. Only slots that expose a `dispose`
|
|
22
|
+
* function (effects, subscriptions) are torn down; value slots (state, memo,
|
|
23
|
+
* ref) are simply dropped.
|
|
24
24
|
*/
|
|
25
|
-
function
|
|
25
|
+
function finalizeHooks(host: Component): void {
|
|
26
26
|
const { hooks } = host;
|
|
27
27
|
if (hooks) {
|
|
28
28
|
for (const hook of hooks) {
|
|
29
29
|
(hook as { dispose?: () => void } | undefined)?.dispose?.();
|
|
30
30
|
}
|
|
31
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
32
|
host.hooks = [];
|
|
38
33
|
host.hookIndex = 0;
|
|
39
34
|
}
|
|
40
35
|
|
|
41
36
|
/**
|
|
42
|
-
* Give a hook host a
|
|
37
|
+
* Give a hook host a correct lifecycle by separating two concerns that the DOM
|
|
38
|
+
* connect/disconnect edge conflates.
|
|
43
39
|
*
|
|
44
|
-
*
|
|
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.
|
|
40
|
+
* A hook host has two kinds of slot:
|
|
51
41
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
42
|
+
* - **Value / subscription slots** — {@link useState}, {@link useRef},
|
|
43
|
+
* {@link useMemo}, and effect-backed subscriptions ({@link useEffect},
|
|
44
|
+
* {@link useObservable}, {@link useObservableValues}). These are *stateful*
|
|
45
|
+
* and *expensive to rebuild*. They must SURVIVE a DOM move and be finalized
|
|
46
|
+
* only when the element is genuinely gone.
|
|
47
|
+
* - **Connectedness edges** — {@link useConnected}, the primitive for window /
|
|
48
|
+
* document listeners, portals, drag sessions: things that track raw DOM
|
|
49
|
+
* connectedness and must attach/detach *synchronously* on every edge.
|
|
60
50
|
*
|
|
61
|
-
*
|
|
51
|
+
* The trap: the DOM fires `disconnectedCallback` then `connectedCallback`
|
|
52
|
+
* **synchronously, in the same task,** whenever a connected element is merely
|
|
53
|
+
* *moved* (a Lit `repeat` reorder, a list re-parenting, a drag-reorder). There
|
|
54
|
+
* is no synchronous signal that distinguishes a move from a genuine unmount, so
|
|
55
|
+
* this controller does not try to classify the edge. Instead it handles each
|
|
56
|
+
* concern with the policy that concern actually wants:
|
|
62
57
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
58
|
+
* - **Edges fire synchronously, every time.** `hostConnected` /
|
|
59
|
+
* `hostDisconnected` dispatch the `"connected"` / `"disconnected"` events
|
|
60
|
+
* inline on every raw edge, so {@link useConnected} tears down and re-attaches
|
|
61
|
+
* deterministically — including across a move (cheap, and exactly what a
|
|
62
|
+
* listener/portal wants).
|
|
63
|
+
* - **Slot finalization is deferred behind an `isConnected` re-check.** A
|
|
64
|
+
* disconnect schedules a microtask; when it runs it disposes the slots only
|
|
65
|
+
* if the host is STILL disconnected. A move has reconnected by then, so its
|
|
66
|
+
* subscription/value slots survive untouched — no re-subscribe, no state
|
|
67
|
+
* reset. A genuine unmount is still disconnected, so it finalizes, preserving
|
|
68
|
+
* the unmount-leak fix.
|
|
69
|
+
* - **Every RE-connect forces one `requestUpdate`.** The DOM does not re-render
|
|
70
|
+
* an element on a bare re-parent, so without this an element whose render
|
|
71
|
+
* reads mutable non-hook state (a plain field, an ancestor value) would show
|
|
72
|
+
* stale output after a move. The re-render is cheap: across a move the slots
|
|
73
|
+
* are preserved, so it does NOT re-subscribe or re-run producers — it is a Lit
|
|
74
|
+
* diff over unchanged hook state. Across a genuine remount the slots were
|
|
75
|
+
* finalized (cleared), so the same re-render re-initializes and re-subscribes
|
|
76
|
+
* them. (The perf win is not re-running producers on a move — never the cheap
|
|
77
|
+
* re-render.)
|
|
73
78
|
*
|
|
74
79
|
* It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
|
|
75
80
|
* which runs after `connectedCallback`, so `addController` fires `hostConnected`
|
|
@@ -87,42 +92,51 @@ export function installHooksController(host: Component): void {
|
|
|
87
92
|
// guards against a re-entrant render re-installing a duplicate controller.
|
|
88
93
|
host[HOOKS_CONTROLLER] = true;
|
|
89
94
|
|
|
95
|
+
// First connect is the initial mount (Lit renders it anyway); only a RE-connect
|
|
96
|
+
// needs a forced re-render.
|
|
90
97
|
let connectedOnce = false;
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
// A finalization microtask is already queued; don't queue a second.
|
|
99
|
+
let finalizationQueued = false;
|
|
100
|
+
|
|
94
101
|
const controller: ReactiveController = {
|
|
95
102
|
hostConnected() {
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
}
|
|
103
|
-
// The "connected" / "disconnected" events are for EXTERNAL listeners only.
|
|
104
|
-
// The internal useConnected does NOT depend on them: this fires before the
|
|
105
|
-
// render body attaches any listener, so useConnected is driven by its direct
|
|
106
|
-
// isConnected check plus useEffect cleanup instead.
|
|
103
|
+
// Fire the connect edge synchronously on EVERY connect (mount / move /
|
|
104
|
+
// remount) so edge hooks (useConnected) run their setup deterministically.
|
|
107
105
|
host.dispatchEvent(new Event("connected"));
|
|
108
106
|
if (connectedOnce) {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
107
|
+
// Re-render on EVERY reconnect — a move as well as a genuine remount.
|
|
108
|
+
// The subscription slots are PRESERVED across a move (finalization is
|
|
109
|
+
// gated on isConnected below), so this re-render does NOT re-subscribe
|
|
110
|
+
// or re-run producers — it is a cheap Lit diff. It is required for
|
|
111
|
+
// correctness: an element whose render reads mutable non-hook state
|
|
112
|
+
// (a plain field, an ancestor value) would otherwise show stale output
|
|
113
|
+
// after a move, since Lit does not re-render on a bare re-parent. On a
|
|
114
|
+
// genuine remount the slots were finalized (cleared), so this same
|
|
115
|
+
// re-render re-initializes and re-subscribes them.
|
|
111
116
|
host.requestUpdate();
|
|
112
117
|
}
|
|
113
118
|
connectedOnce = true;
|
|
114
119
|
},
|
|
115
120
|
hostDisconnected() {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
121
|
+
// Fire the disconnect edge synchronously on EVERY disconnect (unmount /
|
|
122
|
+
// move) so edge hooks tear down synchronously.
|
|
123
|
+
host.dispatchEvent(new Event("disconnected"));
|
|
124
|
+
// Defer slot finalization behind an isConnected re-check: a DOM move has
|
|
125
|
+
// reconnected by the time the microtask runs, so its value/subscription
|
|
126
|
+
// slots survive untouched; only a genuine unmount (still detached) is
|
|
127
|
+
// finalized. This is what keeps a move from churning subscriptions while
|
|
128
|
+
// still disposing them on a real unmount.
|
|
129
|
+
if (finalizationQueued) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
finalizationQueued = true;
|
|
119
133
|
queueMicrotask(() => {
|
|
120
|
-
|
|
134
|
+
finalizationQueued = false;
|
|
135
|
+
if (host.isConnected) {
|
|
136
|
+
// Moved / reconnected within the task — keep every slot alive.
|
|
121
137
|
return;
|
|
122
138
|
}
|
|
123
|
-
|
|
124
|
-
host.dispatchEvent(new Event("disconnected"));
|
|
125
|
-
disposeHooks(host);
|
|
139
|
+
finalizeHooks(host);
|
|
126
140
|
});
|
|
127
141
|
},
|
|
128
142
|
};
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import { LitElement, html } from "lit";
|
|
5
|
+
import type { Observe } from "@adobe/data/observe";
|
|
6
|
+
import { attachDecorator } from "../attach-decorator.js";
|
|
7
|
+
import { withHooks } from "../with-hooks.js";
|
|
8
|
+
import { useObservable } from "../use-observable.js";
|
|
9
|
+
import { useState } from "../use-state.js";
|
|
10
|
+
import { useConnected } from "../use-connected.js";
|
|
11
|
+
import { useEffect } from "../use-effect.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Real-DOM regression tests for the two-slot hook lifecycle:
|
|
15
|
+
*
|
|
16
|
+
* - Value/subscription slots (useState, useObservable) SURVIVE a DOM move and
|
|
17
|
+
* are finalized only on a genuine unmount (deferred `isConnected` re-check).
|
|
18
|
+
* - Connectedness edges (useConnected) fire SYNCHRONOUSLY on every raw
|
|
19
|
+
* connect/disconnect edge — including a move — so listener/portal/drag setup
|
|
20
|
+
* tears down and re-attaches deterministically, with no deferral.
|
|
21
|
+
*
|
|
22
|
+
* These pin the contract that neither the 0.10.11 nuke-on-disconnect nor the
|
|
23
|
+
* 0.10.14 defer-everything controller could satisfy simultaneously.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const flushMicrotasks = (): Promise<void> => new Promise(resolve => queueMicrotask(resolve));
|
|
27
|
+
|
|
28
|
+
/** An Observe whose subscribe/unsubscribe churn the test can inspect. */
|
|
29
|
+
function createTrackedObservable(initial = 0): {
|
|
30
|
+
observable: Observe<number>;
|
|
31
|
+
subscriberCount: () => number;
|
|
32
|
+
totalSubscribes: () => number;
|
|
33
|
+
totalUnsubscribes: () => number;
|
|
34
|
+
} {
|
|
35
|
+
const subscribers = new Set<(value: number) => void>();
|
|
36
|
+
let subscribes = 0;
|
|
37
|
+
let unsubscribes = 0;
|
|
38
|
+
const observable: Observe<number> = observer => {
|
|
39
|
+
subscribes++;
|
|
40
|
+
subscribers.add(observer);
|
|
41
|
+
observer(initial);
|
|
42
|
+
return () => {
|
|
43
|
+
unsubscribes++;
|
|
44
|
+
subscribers.delete(observer);
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
observable,
|
|
49
|
+
subscriberCount: () => subscribers.size,
|
|
50
|
+
totalSubscribes: () => subscribes,
|
|
51
|
+
totalUnsubscribes: () => unsubscribes,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Module-scoped probes the element writes into, so a test can read edge/render
|
|
56
|
+
// history without reaching into the element instance.
|
|
57
|
+
let connectEdges: string[] = [];
|
|
58
|
+
let disconnectEdges: string[] = [];
|
|
59
|
+
let openResources = 0;
|
|
60
|
+
let effectSetups = 0;
|
|
61
|
+
let effectCleanups = 0;
|
|
62
|
+
let renderCount = 0;
|
|
63
|
+
let observableUnderTest: Observe<number>;
|
|
64
|
+
|
|
65
|
+
class LifecycleProbeElement extends LitElement {
|
|
66
|
+
constructor() {
|
|
67
|
+
super();
|
|
68
|
+
attachDecorator(this, "render", withHooks);
|
|
69
|
+
}
|
|
70
|
+
render() {
|
|
71
|
+
renderCount++;
|
|
72
|
+
// Edge primitive: must fire synchronously on every connect/disconnect edge,
|
|
73
|
+
// and open exactly one resource per connect (balanced by its teardown).
|
|
74
|
+
useConnected(() => {
|
|
75
|
+
connectEdges.push("connect");
|
|
76
|
+
openResources++;
|
|
77
|
+
return () => {
|
|
78
|
+
disconnectEdges.push("disconnect");
|
|
79
|
+
openResources--;
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
// Plain effect slot: React-parity — cleanup on finalization/dep-change,
|
|
83
|
+
// never on a bare disconnect (so it survives a move too).
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
effectSetups++;
|
|
86
|
+
return () => {
|
|
87
|
+
effectCleanups++;
|
|
88
|
+
};
|
|
89
|
+
}, []);
|
|
90
|
+
// Value slot: must survive a move (retain its number across re-parenting).
|
|
91
|
+
const [n] = useState(() => 41);
|
|
92
|
+
// Subscription slot: must survive a move (no unsubscribe/re-subscribe).
|
|
93
|
+
const value = useObservable(observableUnderTest);
|
|
94
|
+
return html`<span>${n}:${value ?? ""}</span>`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
customElements.define("lifecycle-probe-element", LifecycleProbeElement);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Renders a plain mutable field (no hook, no requestUpdate on change). Used to prove
|
|
101
|
+
* that a reconnect forces a re-render so mutable non-hook state refreshes after a move.
|
|
102
|
+
*/
|
|
103
|
+
class MutableProbeElement extends LitElement {
|
|
104
|
+
flag = "INITIAL";
|
|
105
|
+
rendered = "";
|
|
106
|
+
constructor() {
|
|
107
|
+
super();
|
|
108
|
+
attachDecorator(this, "render", withHooks);
|
|
109
|
+
}
|
|
110
|
+
render() {
|
|
111
|
+
this.rendered = this.flag;
|
|
112
|
+
return html`<span>${this.flag}</span>`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
customElements.define("mutable-probe-element", MutableProbeElement);
|
|
116
|
+
|
|
117
|
+
function resetProbes(obs: Observe<number>): void {
|
|
118
|
+
connectEdges = [];
|
|
119
|
+
disconnectEdges = [];
|
|
120
|
+
openResources = 0;
|
|
121
|
+
effectSetups = 0;
|
|
122
|
+
effectCleanups = 0;
|
|
123
|
+
renderCount = 0;
|
|
124
|
+
observableUnderTest = obs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe("two-slot hook lifecycle — real DOM", () => {
|
|
128
|
+
it("useConnected fires its disconnect edge SYNCHRONOUSLY on a genuine unmount", async () => {
|
|
129
|
+
const { observable } = createTrackedObservable();
|
|
130
|
+
resetProbes(observable);
|
|
131
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
132
|
+
document.body.appendChild(el);
|
|
133
|
+
await el.updateComplete;
|
|
134
|
+
expect(connectEdges).toEqual(["connect"]);
|
|
135
|
+
expect(disconnectEdges).toEqual([]);
|
|
136
|
+
|
|
137
|
+
el.remove();
|
|
138
|
+
// The edge is synchronous: no microtask flush before this assertion.
|
|
139
|
+
expect(disconnectEdges).toEqual(["disconnect"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("useConnected runs setup ONCE across ordinary re-renders — no per-render leak", async () => {
|
|
143
|
+
const { observable } = createTrackedObservable();
|
|
144
|
+
resetProbes(observable);
|
|
145
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
146
|
+
document.body.appendChild(el);
|
|
147
|
+
await el.updateComplete;
|
|
148
|
+
expect(connectEdges).toEqual(["connect"]);
|
|
149
|
+
expect(openResources).toBe(1);
|
|
150
|
+
|
|
151
|
+
// Ordinary re-renders (no connect/disconnect edge) re-run the hook body but
|
|
152
|
+
// must NOT re-invoke setup — the teardown handle persists across renders.
|
|
153
|
+
el.requestUpdate();
|
|
154
|
+
await el.updateComplete;
|
|
155
|
+
el.requestUpdate();
|
|
156
|
+
await el.updateComplete;
|
|
157
|
+
expect(connectEdges).toEqual(["connect"]);
|
|
158
|
+
expect(openResources).toBe(1);
|
|
159
|
+
|
|
160
|
+
// Unmount tears down exactly the one open resource — nothing leaked.
|
|
161
|
+
el.remove();
|
|
162
|
+
expect(disconnectEdges).toEqual(["disconnect"]);
|
|
163
|
+
expect(openResources).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("useConnected churns disconnect→connect SYNCHRONOUSLY on a MOVE (re-parent)", async () => {
|
|
167
|
+
const { observable } = createTrackedObservable();
|
|
168
|
+
resetProbes(observable);
|
|
169
|
+
const parentA = document.createElement("div");
|
|
170
|
+
const parentB = document.createElement("div");
|
|
171
|
+
document.body.append(parentA, parentB);
|
|
172
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
173
|
+
parentA.appendChild(el);
|
|
174
|
+
await el.updateComplete;
|
|
175
|
+
expect(connectEdges).toEqual(["connect"]);
|
|
176
|
+
|
|
177
|
+
// MOVE: disconnectedCallback then connectedCallback fire synchronously.
|
|
178
|
+
parentB.appendChild(el);
|
|
179
|
+
expect(el.isConnected).toBe(true);
|
|
180
|
+
// The edge primitive tore down and re-attached, synchronously and in order.
|
|
181
|
+
expect(disconnectEdges).toEqual(["disconnect"]);
|
|
182
|
+
expect(connectEdges).toEqual(["connect", "connect"]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("value + subscription slots SURVIVE a MOVE (no re-subscribe), and the reconnect re-renders once", async () => {
|
|
186
|
+
const { observable, subscriberCount, totalSubscribes, totalUnsubscribes } = createTrackedObservable();
|
|
187
|
+
resetProbes(observable);
|
|
188
|
+
const parentA = document.createElement("div");
|
|
189
|
+
const parentB = document.createElement("div");
|
|
190
|
+
document.body.append(parentA, parentB);
|
|
191
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
192
|
+
parentA.appendChild(el);
|
|
193
|
+
await el.updateComplete;
|
|
194
|
+
expect(totalSubscribes()).toBe(1);
|
|
195
|
+
const rendersBeforeMove = renderCount;
|
|
196
|
+
|
|
197
|
+
parentB.appendChild(el);
|
|
198
|
+
await flushMicrotasks();
|
|
199
|
+
await el.updateComplete;
|
|
200
|
+
|
|
201
|
+
// The subscription is untouched — this is the perf win (no re-subscribe, no
|
|
202
|
+
// producer re-run, no state reset)...
|
|
203
|
+
expect(subscriberCount()).toBe(1);
|
|
204
|
+
expect(totalSubscribes()).toBe(1);
|
|
205
|
+
expect(totalUnsubscribes()).toBe(0);
|
|
206
|
+
// ...but the reconnect forces exactly one re-render (a cheap Lit diff over the
|
|
207
|
+
// preserved hook state) so an element reading mutable non-hook state refreshes.
|
|
208
|
+
expect(renderCount).toBe(rendersBeforeMove + 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("an element reading MUTABLE non-hook state refreshes across a MOVE (the reconnect re-render)", async () => {
|
|
212
|
+
// Regression: a move fires no DOM re-render, so without the controller's forced
|
|
213
|
+
// reconnect re-render an element whose render reads a plain mutable field shows
|
|
214
|
+
// stale output after a re-parent.
|
|
215
|
+
const { observable } = createTrackedObservable();
|
|
216
|
+
resetProbes(observable);
|
|
217
|
+
const parentA = document.createElement("div");
|
|
218
|
+
const parentB = document.createElement("div");
|
|
219
|
+
document.body.append(parentA, parentB);
|
|
220
|
+
const el = document.createElement("mutable-probe-element") as MutableProbeElement;
|
|
221
|
+
parentA.appendChild(el);
|
|
222
|
+
await el.updateComplete;
|
|
223
|
+
expect(el.rendered).toBe("INITIAL");
|
|
224
|
+
|
|
225
|
+
el.flag = "CHANGED"; // mutate a plain field (no hook, no requestUpdate)
|
|
226
|
+
parentB.appendChild(el); // MOVE
|
|
227
|
+
await flushMicrotasks();
|
|
228
|
+
await el.updateComplete;
|
|
229
|
+
|
|
230
|
+
expect(el.isConnected).toBe(true);
|
|
231
|
+
expect(el.rendered).toBe("CHANGED");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("finalizes on a genuine unmount — subscription disposed, no leak", async () => {
|
|
235
|
+
const { observable, subscriberCount, totalUnsubscribes } = createTrackedObservable();
|
|
236
|
+
resetProbes(observable);
|
|
237
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
238
|
+
document.body.appendChild(el);
|
|
239
|
+
await el.updateComplete;
|
|
240
|
+
expect(subscriberCount()).toBe(1);
|
|
241
|
+
|
|
242
|
+
el.remove();
|
|
243
|
+
await flushMicrotasks();
|
|
244
|
+
|
|
245
|
+
expect(subscriberCount()).toBe(0);
|
|
246
|
+
expect(totalUnsubscribes()).toBe(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("a genuine unmount→remount (across a task) re-initializes and re-subscribes the slots", async () => {
|
|
250
|
+
const { observable, subscriberCount, totalSubscribes } = createTrackedObservable();
|
|
251
|
+
resetProbes(observable);
|
|
252
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
253
|
+
document.body.appendChild(el);
|
|
254
|
+
await el.updateComplete;
|
|
255
|
+
expect(totalSubscribes()).toBe(1);
|
|
256
|
+
|
|
257
|
+
// Unmount, let finalization run (task boundary), then remount.
|
|
258
|
+
el.remove();
|
|
259
|
+
await flushMicrotasks();
|
|
260
|
+
expect(subscriberCount()).toBe(0);
|
|
261
|
+
|
|
262
|
+
document.body.appendChild(el);
|
|
263
|
+
await el.updateComplete;
|
|
264
|
+
|
|
265
|
+
// Slots were finalized while detached, so the remount re-subscribed.
|
|
266
|
+
expect(subscriberCount()).toBe(1);
|
|
267
|
+
expect(totalSubscribes()).toBe(2);
|
|
268
|
+
// And the edge primitive reconnected.
|
|
269
|
+
expect(connectEdges.filter(e => e === "connect").length).toBe(2);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("a genuine unmount→remount to a DIFFERENT parent (across a task) re-initializes and re-subscribes", async () => {
|
|
273
|
+
const { observable, subscriberCount, totalSubscribes } = createTrackedObservable();
|
|
274
|
+
resetProbes(observable);
|
|
275
|
+
const parentA = document.createElement("div");
|
|
276
|
+
const parentB = document.createElement("div");
|
|
277
|
+
document.body.append(parentA, parentB);
|
|
278
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
279
|
+
parentA.appendChild(el);
|
|
280
|
+
await el.updateComplete;
|
|
281
|
+
expect(totalSubscribes()).toBe(1);
|
|
282
|
+
|
|
283
|
+
// Unmount from A, let finalization run, then remount under a DIFFERENT parent.
|
|
284
|
+
// The finalization gate reads `isConnected` (not "did it reconnect to the same
|
|
285
|
+
// node"), so a remount anywhere is handled uniformly.
|
|
286
|
+
el.remove();
|
|
287
|
+
await flushMicrotasks();
|
|
288
|
+
expect(subscriberCount()).toBe(0);
|
|
289
|
+
|
|
290
|
+
parentB.appendChild(el);
|
|
291
|
+
await el.updateComplete;
|
|
292
|
+
|
|
293
|
+
expect(subscriberCount()).toBe(1);
|
|
294
|
+
expect(totalSubscribes()).toBe(2);
|
|
295
|
+
expect(connectEdges.filter(e => e === "connect").length).toBe(2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("plain useEffect cleanup does NOT run on a MOVE, and runs (deferred) on a genuine unmount", async () => {
|
|
299
|
+
const { observable } = createTrackedObservable();
|
|
300
|
+
resetProbes(observable);
|
|
301
|
+
const parentA = document.createElement("div");
|
|
302
|
+
const parentB = document.createElement("div");
|
|
303
|
+
document.body.append(parentA, parentB);
|
|
304
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
305
|
+
parentA.appendChild(el);
|
|
306
|
+
await el.updateComplete;
|
|
307
|
+
expect(effectSetups).toBe(1);
|
|
308
|
+
expect(effectCleanups).toBe(0);
|
|
309
|
+
|
|
310
|
+
// MOVE: the effect slot survives untouched — no cleanup, no re-setup.
|
|
311
|
+
parentB.appendChild(el);
|
|
312
|
+
await flushMicrotasks();
|
|
313
|
+
await el.updateComplete;
|
|
314
|
+
expect(effectSetups).toBe(1);
|
|
315
|
+
expect(effectCleanups).toBe(0);
|
|
316
|
+
|
|
317
|
+
// UNMOUNT: cleanup runs on the finalization microtask, not synchronously.
|
|
318
|
+
el.remove();
|
|
319
|
+
expect(effectCleanups).toBe(0);
|
|
320
|
+
await flushMicrotasks();
|
|
321
|
+
expect(effectCleanups).toBe(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("a subscription that has not yet emitted heals on a post-MOVE emit (preserved-subscription data contract)", async () => {
|
|
325
|
+
// Data-layer contract: the move preserves the single mount-time subscription, so
|
|
326
|
+
// a source that emits only LATER still reaches the element. This guards against a
|
|
327
|
+
// regression that tears subscriptions down on move (which would strand a source
|
|
328
|
+
// that does not replay-on-subscribe — the root of the studio "renders nothing").
|
|
329
|
+
let deliver: ((value: number) => void) | undefined;
|
|
330
|
+
const stalled: Observe<number> = observer => {
|
|
331
|
+
deliver = observer; // note: does NOT emit synchronously on subscribe
|
|
332
|
+
return () => {
|
|
333
|
+
deliver = undefined;
|
|
334
|
+
};
|
|
335
|
+
};
|
|
336
|
+
resetProbes(stalled);
|
|
337
|
+
const parentA = document.createElement("div");
|
|
338
|
+
const parentB = document.createElement("div");
|
|
339
|
+
document.body.append(parentA, parentB);
|
|
340
|
+
const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
|
|
341
|
+
parentA.appendChild(el);
|
|
342
|
+
await el.updateComplete;
|
|
343
|
+
expect(el.shadowRoot?.textContent).toContain("41:"); // value undefined, not yet emitted
|
|
344
|
+
|
|
345
|
+
// Move BEFORE the source emits, then emit.
|
|
346
|
+
parentB.appendChild(el);
|
|
347
|
+
await flushMicrotasks();
|
|
348
|
+
deliver?.(99);
|
|
349
|
+
await el.updateComplete;
|
|
350
|
+
|
|
351
|
+
// The preserved subscription delivered the post-move emit → element healed.
|
|
352
|
+
expect(el.shadowRoot?.textContent).toContain("99");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -2,40 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
import type { EffectCallback } from "./use-effect.js";
|
|
4
4
|
import { useEffect } from "./use-effect.js";
|
|
5
|
+
import { useRef } from "./use-ref.js";
|
|
5
6
|
import { Component_stack } from "./component/stack.js";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Run `callback` on every DOM connect edge and its returned teardown on every
|
|
10
|
+
* disconnect edge — the connectedness primitive for `window` / `document`
|
|
11
|
+
* listeners, portals, and drag sessions that must attach and detach
|
|
12
|
+
* *synchronously* as the host enters and leaves the DOM.
|
|
13
|
+
*
|
|
14
|
+
* The hooks controller (see `installHooksController`) dispatches the
|
|
15
|
+
* `"connected"` / `"disconnected"` events synchronously on every raw edge,
|
|
16
|
+
* including a DOM move, so a move tears the setup down and re-attaches it
|
|
17
|
+
* deterministically.
|
|
18
|
+
*
|
|
19
|
+
* Setup runs exactly ONCE per connect. The teardown handle lives in a ref that
|
|
20
|
+
* persists across renders, so ordinary re-renders (which re-run this hook body)
|
|
21
|
+
* neither re-invoke setup nor leak — the setup/teardown pair is driven only from
|
|
22
|
+
* the edge, never from the render pass. The callback is read live through a ref,
|
|
23
|
+
* so callers may pass a fresh closure on every render.
|
|
24
|
+
*/
|
|
7
25
|
export function useConnected(callback: EffectCallback, dependencies?: unknown[]) {
|
|
8
26
|
const component = Component_stack.active();
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function onDisconnect() {
|
|
18
|
-
if (disconnect) {
|
|
19
|
-
disconnect?.();
|
|
20
|
-
disconnect = undefined;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
28
|
+
// Live-read the latest callback so a fresh closure each render is honored
|
|
29
|
+
// without re-registering the edge listeners.
|
|
30
|
+
const callbackRef = useRef(callback);
|
|
31
|
+
callbackRef.current = callback;
|
|
23
32
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
// Persistent teardown handle: the cleanup returned by `callback` while
|
|
34
|
+
// connected, or `undefined` while disconnected. A ref (not a per-render
|
|
35
|
+
// local) so the "already connected" guard holds across renders — this is
|
|
36
|
+
// what makes setup idempotent per connect instead of per render.
|
|
37
|
+
const state = useRef<{ disconnect: (() => void) | void }>({ disconnect: undefined });
|
|
28
38
|
|
|
29
39
|
useEffect(() => {
|
|
40
|
+
const onConnect = () => {
|
|
41
|
+
if (!state.current.disconnect) {
|
|
42
|
+
state.current.disconnect = callbackRef.current();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const onDisconnect = () => {
|
|
46
|
+
if (state.current.disconnect) {
|
|
47
|
+
state.current.disconnect();
|
|
48
|
+
state.current.disconnect = undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// On first mount the controller dispatches "connected" during
|
|
53
|
+
// `addController`, before this effect attaches its listener, so run setup
|
|
54
|
+
// now if the host is already connected.
|
|
55
|
+
if (component.isConnected) {
|
|
56
|
+
onConnect();
|
|
57
|
+
}
|
|
30
58
|
component.addEventListener("connected", onConnect);
|
|
31
59
|
component.addEventListener("disconnected", onDisconnect);
|
|
32
60
|
|
|
33
61
|
return () => {
|
|
34
62
|
component.removeEventListener("connected", onConnect);
|
|
35
63
|
component.removeEventListener("disconnected", onDisconnect);
|
|
36
|
-
|
|
37
64
|
onDisconnect();
|
|
38
|
-
}
|
|
39
|
-
|
|
65
|
+
};
|
|
40
66
|
}, dependencies);
|
|
41
|
-
}
|
|
67
|
+
}
|