@adobe/data-lit 0.10.14 → 0.10.15

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.
@@ -17,59 +17,58 @@ function isReactiveHost(host: Component): host is ReactiveHost {
17
17
  }
18
18
 
19
19
  /**
20
- * Dispose every effect-style hook slot and reset the hook cursor so the next
21
- * render re-initializes hooks from scratch. Only slots that expose a
22
- * `dispose` function (effects, subscriptions) are torn down; value slots
23
- * (state, memo, ref) are simply dropped.
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 disposeHooks(host: Component): void {
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 real disconnect edge.
37
+ * Give a hook host a correct lifecycle by separating two concerns that the DOM
38
+ * connect/disconnect edge conflates.
43
39
  *
44
- * The hook model stores every effect's cleanup in `host.hooks[i].dispose`,
45
- * but {@link useEffect} only invokes it when dependencies change — never when
46
- * the element leaves the DOM. Left alone, every `useObservable` /
47
- * `useObservableValues` / `useEffect` subscription leaks on unmount and keeps
48
- * firing `requestUpdate` on a detached element. `useConnected` is likewise
49
- * inert because nothing dispatches the `"connected"` / `"disconnected"`
50
- * events it listens for.
40
+ * A hook host has two kinds of slot:
51
41
  *
52
- * This installs a single Lit {@link ReactiveController} — the one hook-friendly
53
- * path to a genuine `hostDisconnected` — that:
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.
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
- * ## Why disposal is deferred
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
- * 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.
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
+ * slots survive untouched — no re-subscribe, no state reset, no forced
67
+ * re-render. A genuine unmount is still disconnected, so it finalizes,
68
+ * preserving the unmount-leak fix.
69
+ *
70
+ * On a reconnect after a genuine finalization, the slots are empty, so the
71
+ * controller forces one `requestUpdate` to re-initialize them.
73
72
  *
74
73
  * It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
75
74
  * which runs after `connectedCallback`, so `addController` fires `hostConnected`
@@ -87,42 +86,45 @@ export function installHooksController(host: Component): void {
87
86
  // guards against a re-entrant render re-installing a duplicate controller.
88
87
  host[HOOKS_CONTROLLER] = true;
89
88
 
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;
89
+ // The slots were finalized (disposed + cleared) while the host was detached,
90
+ // so the next connect must re-render to re-initialize and re-subscribe them.
91
+ let finalized = false;
92
+ // A finalization microtask is already queued; don't queue a second.
93
+ let finalizationQueued = false;
94
+
94
95
  const controller: ReactiveController = {
95
96
  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
- }
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.
97
+ // Fire the connect edge synchronously on EVERY connect (mount / move /
98
+ // remount) so edge hooks (useConnected) run their setup deterministically.
107
99
  host.dispatchEvent(new Event("connected"));
108
- if (connectedOnce) {
109
- // Reconnect: the previous disconnect disposed and cleared every
110
- // hook slot, so re-render to re-run hooks and re-subscribe.
100
+ if (finalized) {
101
+ // The host was fully finalized while detached (a genuine unmount): its
102
+ // slots are empty, so re-render to re-initialize and re-subscribe them.
103
+ finalized = false;
111
104
  host.requestUpdate();
112
105
  }
113
- connectedOnce = true;
114
106
  },
115
107
  hostDisconnected() {
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;
108
+ // Fire the disconnect edge synchronously on EVERY disconnect (unmount /
109
+ // move) so edge hooks tear down synchronously.
110
+ host.dispatchEvent(new Event("disconnected"));
111
+ // Defer slot finalization behind an isConnected re-check: a DOM move has
112
+ // reconnected by the time the microtask runs, so its value/subscription
113
+ // slots survive untouched; only a genuine unmount (still detached) is
114
+ // finalized. This is what keeps a move from churning subscriptions while
115
+ // still disposing them on a real unmount.
116
+ if (finalizationQueued) {
117
+ return;
118
+ }
119
+ finalizationQueued = true;
119
120
  queueMicrotask(() => {
120
- if (!pendingDisposal) {
121
+ finalizationQueued = false;
122
+ if (host.isConnected) {
123
+ // Moved / reconnected within the task — keep every slot alive.
121
124
  return;
122
125
  }
123
- pendingDisposal = false;
124
- host.dispatchEvent(new Event("disconnected"));
125
- disposeHooks(host);
126
+ finalizeHooks(host);
127
+ finalized = true;
126
128
  });
127
129
  },
128
130
  };
@@ -0,0 +1,279 @@
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
+ function resetProbes(obs: Observe<number>): void {
100
+ connectEdges = [];
101
+ disconnectEdges = [];
102
+ openResources = 0;
103
+ effectSetups = 0;
104
+ effectCleanups = 0;
105
+ renderCount = 0;
106
+ observableUnderTest = obs;
107
+ }
108
+
109
+ describe("two-slot hook lifecycle — real DOM", () => {
110
+ it("useConnected fires its disconnect edge SYNCHRONOUSLY on a genuine unmount", async () => {
111
+ const { observable } = createTrackedObservable();
112
+ resetProbes(observable);
113
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
114
+ document.body.appendChild(el);
115
+ await el.updateComplete;
116
+ expect(connectEdges).toEqual(["connect"]);
117
+ expect(disconnectEdges).toEqual([]);
118
+
119
+ el.remove();
120
+ // The edge is synchronous: no microtask flush before this assertion.
121
+ expect(disconnectEdges).toEqual(["disconnect"]);
122
+ });
123
+
124
+ it("useConnected runs setup ONCE across ordinary re-renders — no per-render leak", async () => {
125
+ const { observable } = createTrackedObservable();
126
+ resetProbes(observable);
127
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
128
+ document.body.appendChild(el);
129
+ await el.updateComplete;
130
+ expect(connectEdges).toEqual(["connect"]);
131
+ expect(openResources).toBe(1);
132
+
133
+ // Ordinary re-renders (no connect/disconnect edge) re-run the hook body but
134
+ // must NOT re-invoke setup — the teardown handle persists across renders.
135
+ el.requestUpdate();
136
+ await el.updateComplete;
137
+ el.requestUpdate();
138
+ await el.updateComplete;
139
+ expect(connectEdges).toEqual(["connect"]);
140
+ expect(openResources).toBe(1);
141
+
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
+
148
+ it("useConnected churns disconnect→connect SYNCHRONOUSLY on a MOVE (re-parent)", async () => {
149
+ const { observable } = createTrackedObservable();
150
+ resetProbes(observable);
151
+ const parentA = document.createElement("div");
152
+ const parentB = document.createElement("div");
153
+ document.body.append(parentA, parentB);
154
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
155
+ parentA.appendChild(el);
156
+ await el.updateComplete;
157
+ expect(connectEdges).toEqual(["connect"]);
158
+
159
+ // MOVE: disconnectedCallback then connectedCallback fire synchronously.
160
+ parentB.appendChild(el);
161
+ expect(el.isConnected).toBe(true);
162
+ // The edge primitive tore down and re-attached, synchronously and in order.
163
+ expect(disconnectEdges).toEqual(["disconnect"]);
164
+ expect(connectEdges).toEqual(["connect", "connect"]);
165
+ });
166
+
167
+ it("value + subscription slots SURVIVE a MOVE (state kept, no re-subscribe, no forced render)", async () => {
168
+ const { observable, subscriberCount, totalSubscribes, totalUnsubscribes } = createTrackedObservable();
169
+ resetProbes(observable);
170
+ const parentA = document.createElement("div");
171
+ const parentB = document.createElement("div");
172
+ document.body.append(parentA, parentB);
173
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
174
+ parentA.appendChild(el);
175
+ await el.updateComplete;
176
+ expect(totalSubscribes()).toBe(1);
177
+ const rendersBeforeMove = renderCount;
178
+
179
+ parentB.appendChild(el);
180
+ await flushMicrotasks();
181
+ await el.updateComplete;
182
+
183
+ // Subscription untouched; useState value preserved; no re-render forced.
184
+ expect(subscriberCount()).toBe(1);
185
+ expect(totalSubscribes()).toBe(1);
186
+ expect(totalUnsubscribes()).toBe(0);
187
+ expect(renderCount).toBe(rendersBeforeMove);
188
+ });
189
+
190
+ it("finalizes on a genuine unmount — subscription disposed, no leak", async () => {
191
+ const { observable, subscriberCount, totalUnsubscribes } = createTrackedObservable();
192
+ resetProbes(observable);
193
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
194
+ document.body.appendChild(el);
195
+ await el.updateComplete;
196
+ expect(subscriberCount()).toBe(1);
197
+
198
+ el.remove();
199
+ await flushMicrotasks();
200
+
201
+ expect(subscriberCount()).toBe(0);
202
+ expect(totalUnsubscribes()).toBe(1);
203
+ });
204
+
205
+ it("a genuine unmount→remount (across a task) re-initializes and re-subscribes the slots", async () => {
206
+ const { observable, subscriberCount, totalSubscribes } = createTrackedObservable();
207
+ resetProbes(observable);
208
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
209
+ document.body.appendChild(el);
210
+ await el.updateComplete;
211
+ expect(totalSubscribes()).toBe(1);
212
+
213
+ // Unmount, let finalization run (task boundary), then remount.
214
+ el.remove();
215
+ await flushMicrotasks();
216
+ expect(subscriberCount()).toBe(0);
217
+
218
+ document.body.appendChild(el);
219
+ await el.updateComplete;
220
+
221
+ // Slots were finalized while detached, so the remount re-subscribed.
222
+ expect(subscriberCount()).toBe(1);
223
+ expect(totalSubscribes()).toBe(2);
224
+ // And the edge primitive reconnected.
225
+ expect(connectEdges.filter(e => e === "connect").length).toBe(2);
226
+ });
227
+
228
+ it("a genuine unmount→remount to a DIFFERENT parent (across a task) re-initializes and re-subscribes", async () => {
229
+ const { observable, subscriberCount, totalSubscribes } = createTrackedObservable();
230
+ resetProbes(observable);
231
+ const parentA = document.createElement("div");
232
+ const parentB = document.createElement("div");
233
+ document.body.append(parentA, parentB);
234
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
235
+ parentA.appendChild(el);
236
+ await el.updateComplete;
237
+ expect(totalSubscribes()).toBe(1);
238
+
239
+ // Unmount from A, let finalization run, then remount under a DIFFERENT parent.
240
+ // The finalization gate reads `isConnected` (not "did it reconnect to the same
241
+ // node"), so a remount anywhere is handled uniformly.
242
+ el.remove();
243
+ await flushMicrotasks();
244
+ expect(subscriberCount()).toBe(0);
245
+
246
+ parentB.appendChild(el);
247
+ await el.updateComplete;
248
+
249
+ expect(subscriberCount()).toBe(1);
250
+ expect(totalSubscribes()).toBe(2);
251
+ expect(connectEdges.filter(e => e === "connect").length).toBe(2);
252
+ });
253
+
254
+ it("plain useEffect cleanup does NOT run on a MOVE, and runs (deferred) on a genuine unmount", async () => {
255
+ const { observable } = createTrackedObservable();
256
+ resetProbes(observable);
257
+ const parentA = document.createElement("div");
258
+ const parentB = document.createElement("div");
259
+ document.body.append(parentA, parentB);
260
+ const el = document.createElement("lifecycle-probe-element") as LifecycleProbeElement;
261
+ parentA.appendChild(el);
262
+ await el.updateComplete;
263
+ expect(effectSetups).toBe(1);
264
+ expect(effectCleanups).toBe(0);
265
+
266
+ // MOVE: the effect slot survives untouched — no cleanup, no re-setup.
267
+ parentB.appendChild(el);
268
+ await flushMicrotasks();
269
+ await el.updateComplete;
270
+ expect(effectSetups).toBe(1);
271
+ expect(effectCleanups).toBe(0);
272
+
273
+ // UNMOUNT: cleanup runs on the finalization microtask, not synchronously.
274
+ el.remove();
275
+ expect(effectCleanups).toBe(0);
276
+ await flushMicrotasks();
277
+ expect(effectCleanups).toBe(1);
278
+ });
279
+ });
@@ -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
- let disconnect: (() => void) | void;
11
- function onConnect() {
12
- if (!disconnect) {
13
- disconnect = callback();
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
- // TODO
25
- if (component.isConnected) {
26
- onConnect();
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
+ }