fix(data-lit): dispose hooks on element disconnect - #194
Conversation
Hooks stored every effect cleanup in host.hooks[i].dispose, but useEffect only invoked it on dependency change — never on unmount. So every useObservable / useObservableValues / useEffect subscription leaked when a Lit element left the DOM and kept firing requestUpdate on a detached host. useConnected was likewise inert: nothing dispatched the "connected" / "disconnected" events it listened for. No base element ever overrode disconnectedCallback, so there was no disconnect edge at all — this was never implemented, not a regression. Install a single Lit ReactiveController from the withHooks render wrapper (the seam all three opt-in patterns share: base-class attachDecorator, the @withHooks method decorator, and manual attachDecorator on plain LitElements). On disconnect it dispatches "disconnected" and disposes + clears every hook slot; on reconnect it dispatches "connected" and forces a re-render so hooks re-initialize and re-subscribe (full unmount semantics). Idempotent per host; non-Lit hosts are skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for this @kunalkindra — this is a real, well-diagnosed leak and the fix targets exactly the right seam ( Maintainer decisions
BlockersNone. Major
Minor
Nits
Net: solid fix, no blockers. Land the docs note (major #2), add the DOM regression test (major #1, in-PR or fast follow-up), and the minors/nits are polish. 👍 |
Problem
Hooks from
@adobe/data-lit(useEffect,useObservable,useObservableValues,useUpdated,useConnected, …) were never disposed when a Lit element unmounts. Every effect's cleanup was stored inhost.hooks[i].dispose, butuseEffectonly invokes it on a dependency change — never on disconnect. As a result:useObservable/useObservableValues/useEffectsubscription leaks when the element leaves the DOM, and keeps callingrequestUpdateon a detached host.useConnectedis inert: it listens for"connected"/"disconnected"events that nothing in the library ever dispatched.This was never implemented (not a regression): no base element (
ApplicationElement,DatabaseElement) ever overrodedisconnectedCallback, so there was no disconnect edge at all. The only historicaldisconnectedCallback(ServiceApplication) disposed the whole service, not per-component hooks, and was removed long ago.Fix
Install a single Lit
ReactiveControllerfrom thewithHooksrender wrapper — the one seam all three opt-in patterns funnel through:attachDecorator(this, 'render', withHooks)(ApplicationElement/DatabaseElement, incl. downstream forks),@withHooksmethod decorator onrender(),attachDecoratoron plainLitElements.The controller:
hostDisconnected→ dispatches"disconnected", then disposes and clears every hook slot (skipping value slots likeuseState/useRef), resetting the cursor.hostConnected→ dispatches"connected"; on re-connect only, forces arequestUpdate()so hooks re-initialize and re-subscribe.It's idempotent per host (double-wrapped renders install once) and feature-detects
addController, so non-Lit hosts are safely skipped.Zero consumer changes — enabling hooks now automatically enables cleanup. This also revives
useConnectedfor free, and lets downstream repos retire hand-rolledReactiveControllerworkarounds for the missing disconnect edge.Behavioral note
Disconnect is now treated as unmount (React-identical): on a DOM move (disconnect→reconnect of the same element),
useState/useMemoreset and effects re-run, rather than persisting.Testing
hooks-controller.test.ts(5 tests): disposes all effect hooks + resets cursor on disconnect (value/ref slots skipped, no crash); dispatchesconnected/disconnected;requestUpdatefires only on re-connect; idempotent install; non-reactive host is a no-op.pnpm test(10/10),pnpm typecheck,pnpm buildall pass for@adobe/data-lit.Also bumps the publishable surface to 0.10.8 via
pnpm bump.🤖 Generated with Claude Code