Skip to content

dom-lifecycle

Drop-in custom element. Place inside any element (or wrap children) to be notified when the surrounding subtree connects, disconnects, moves, or is adopted into another document. Built on the platform’s own custom-element callbacks — no MutationObserver, no global registry. Useful when a JSX ref callback fires too early — e.g. resolving getContext, measuring layout, attaching observers that need a connected ancestor.

Position-tracking callbacks (onConnect, onDisconnect, onMove) receive the lifecycle element itself. Read self.parentElement for the surrounding element, self.firstElementChild / self.children for wrapped content, or self.getRootNode() to walk through a shadow root. self is always non-null — even when the lifecycle element is the direct child of a ShadowRoot (where parentElement is null).

Render-inert by default: display: contents removes its layout box so it doesn’t affect the parent’s layout, and role="none" strips its implicit a11y role. Children passed inside it participate in layout and a11y as if the wrapper weren’t there. Caveat: structural CSS selectors (:empty, :first-child, :nth-child) still see the element in the DOM tree.

Usage

import "elements-kit/utilities/dom-lifecycle";
function FocusOnMount() {
return (
<div>
<dom-lifecycle
onConnect={(el) => (el.parentElement as HTMLElement | null)?.focus()}
/>
</div>
);
}

Wrap children — read the wrapped subtree through self.firstElementChild:

<section>
<dom-lifecycle onConnect={(el) => measure(el.firstElementChild)}>
<h1>Title</h1>
<p>Body</p>
</dom-lifecycle>
</section>

Wrap children to consume context

Call getContext(self, …) inside onConnect — the walk goes from the wrapper up through its ancestors, so any outer provider resolves. Expose the result as a signal so wrapped children read it without each one running its own lookup.

import { signal } from "elements-kit/signals";
import { getContext } from "elements-kit/utilities/context";
import "elements-kit/utilities/dom-lifecycle";
const THEME = Symbol("theme");
function ThemedSection() {
const theme = signal<string | undefined>(undefined);
return (
<dom-lifecycle onConnect={(el) => theme(getContext<string>(el, THEME))}>
<h1 data-theme={() => theme() ?? "default"}>Title</h1>
<p data-theme={() => theme() ?? "default"}>Body</p>
</dom-lifecycle>
);
}

The wrapper is transparent in the ancestor walk, so wrapped children may also call getContext directly on themselves and reach the same outer provider. Use onConnect when you want to read once at mount and fan the value out to multiple children via a single signal.

Observing descendant mutations

<dom-lifecycle> only fires on its own (re)connection — it does not observe descendant mutations (children being added, removed, or replaced while the wrapper stays mounted). For per-child mount/unmount inside its subtree, either nest a <dom-lifecycle> per child or use createMutationObserver on el inside onConnect:

import { createMutationObserver } from "elements-kit/utilities/mutation-observer";
<dom-lifecycle
onConnect={(el) => {
createMutationObserver(el, { childList: true }, (records) => {
for (const r of records) {
// r.addedNodes, r.removedNodes
}
});
}}
>
<For each={items}>{(item) => <li>{item}</li>}</For>
</dom-lifecycle>

Callbacks

CallbackMirrorsArgumentFires on
onConnectconnectedCallbackself: DomLifecycleElementevery connection — self.parentElement is the surrounding element, self.firstElementChild is the wrapped content
onDisconnectdisconnectedCallbackself: DomLifecycleElementevery disconnection — self.parentElement is null per spec; capture the parent inside onConnect if you need it on disconnect
onMoveconnectedMoveCallbackself: DomLifecycleElementmove via Node.moveBefore() (in browsers without that API, the disconnect+connect pair fires instead)
onAdoptedadoptedCallback(oldDocument, newDocument)when the element is adopted into a new document
<div>
<dom-lifecycle
onConnect={(el) => {
const io = new IntersectionObserver((entries) => {});
if (el.parentElement) io.observe(el.parentElement);
}}
onDisconnect={(el) => {
// pair cleanup with the resource you opened in onConnect
// el.parentElement is null here per spec
}}
onMove={(el) => {
// fires instead of disconnect+connect when moveBefore() is used
}}
/>
</div>

onConnect / onDisconnect re-fire on every (re)connection (item moves in <For>, portal moves between parents). The user removes the element themselves; it does not self-remove. To make a callback one-shot, set the property to null after the first fire.

class DomLifecycleElement extends HTMLElement {
onConnect: ((self: DomLifecycleElement) => void) | null;
onDisconnect: ((self: DomLifecycleElement) => void) | null;
onMove: ((self: DomLifecycleElement) => void) | null;
onAdopted: ((oldDocument: Document, newDocument: Document) => void) | null;
}

Works inside open and closed shadow roots, after cloneNode(true), after innerHTML upgrade, and under strict CSP — same guarantees the platform gives any custom element.