@zakkster/lite-observe
v1.0.2
Published
Zero-GC reactive bridge for the DOM observer APIs. ResizeObserver, IntersectionObserver, MutationObserver, matchMedia, and Page Visibility collapsed to fine-grained lite-signal signals. Shared registry: N components observing the same element pay one obse
Maintainers
Readme
@zakkster/lite-observe
The DOM observer APIs as fine-grained signals. N components observing the same element pay one observer cost.
ResizeObserver, IntersectionObserver, MutationObserver, matchMedia, and Page Visibility, each surfaced as a lite-signal signal (or a small handle of signals). A shared internal registry deduplicates the underlying observer per element (or per options key, or per query string) so a list of 200 reactive widgets observing the same scroll container costs one underlying IntersectionObserver, not 200.
import { observeResize, observeIntersection, observeMedia, documentVisible } from '@zakkster/lite-observe';
import { effect } from '@zakkster/lite-signal';
const { width, height, dispose } = observeResize(panel);
effect(() => render(width(), height())); // wakes only when the panel resizes
const isMobile = observeMedia('(max-width: 768px)');
effect(() => layoutFor(isMobile())); // wakes only on the breakpoint crossing
effect(() => { if (!documentVisible()) pauseExpensiveLoop(); });Contents
- Why | Install | Quick start | Architecture
- API reference
- Design notes | Benchmarks
- Edge cases & guarantees
- Roadmap | License
Why
Most reactive UI code wires DOM observers per component. A virtual list with 200 rows, each reading (min-width: 768px), mounts 200 MediaQueryList listeners. A scroll-based reveal with 200 items mounts 200 IntersectionObserver instances. The browser allows it; that does not make it cheap. Every observer is a retained allocation plus a listener slot the browser pages through on every event.
lite-observe flips the model: one observer per page (or per distinct configuration), refcounted per element. Components ask for what they want, get a signal, dispose the handle on unmount. The library handles the bookkeeping. Reads are equality-gated through lite-signal so a consumer tracking width does not wake when height moves.
Install
npm install @zakkster/lite-observe @zakkster/lite-signal@zakkster/lite-signal is a peer dependency.
Quick start
import {
observeResize,
observeIntersection,
observeMutation,
observeMutationSelector,
observeMedia,
documentVisible
} from '@zakkster/lite-observe';
import { effect } from '@zakkster/lite-signal';
// ResizeObserver -- width and height as separate signals
const r = observeResize(el);
effect(() => console.log('width', r.width())); // only fires when width moves
// IntersectionObserver -- visibility + ratio, options shared by identity
const i = observeIntersection(el, { rootMargin: '50px' });
effect(() => { if (i.isIntersecting()) load(); });
// MutationObserver -- tick-driven, peek records when you need them
const m = observeMutation(list, { childList: true });
effect(() => { m.tick(); reindex(m.peekRecords()); });
// Selector-filtered mutations -- matching adds/removes only
const opts = observeMutationSelector(list, '.option', { childList: true, subtree: true });
effect(() => { for (const el of opts.added()) wire(el); });
// matchMedia -- one signal per query, cached globally
const dark = observeMedia('(prefers-color-scheme: dark)');
effect(() => applyTheme(dark() ? 'dark' : 'light'));
// Page Visibility -- a module-level signal, lazy listener
effect(() => { if (!documentVisible()) raf.stopFrames(); });
// Dispose handles on unmount
r.dispose(); i.dispose(); m.dispose();Architecture
The whole library is one idea applied five times: one browser observer, refcounted, fanning out to fine-grained signals. Resize shown below; Intersection (keyed by options tuple), Mutation (keyed by element + options), and Media (keyed by query string) are the same shape with a different key.
flowchart TB
subgraph consumers["N consumers, one element"]
C1["component A<br/>reads width"]
C2["component B<br/>reads height"]
C3["component C<br/>reads width"]
end
C1 -->|"observeResize(el)"| REG
C2 -->|"observeResize(el)"| REG
C3 -->|"observeResize(el)"| REG
subgraph lib["lite-observe registry"]
REG["slots: Map<Element, slot>"]
REG --> SLOT["slot for el<br/>refCount: 3"]
SLOT --> SW["width: Signal<number>"]
SLOT --> SH["height: Signal<number>"]
end
REG -->|"first observe only"| RO["one ResizeObserver<br/>(page singleton)"]
RO -->|"browser fires entries[]"| DISP["dispatch()<br/>indexed loop, zero-alloc"]
DISP -->|"width.set(rect.width)"| SW
DISP -->|"height.set(rect.height)"| SH
SW -.->|"Object.is gate"| C1
SW -.->|"Object.is gate"| C3
SH -.->|"Object.is gate"| C2Three components observe the same element; the registry allocates one slot (refCount: 3) and the browser sees one observe() call. When the browser delivers an entries[] batch, dispatch() walks it with an indexed loop allocating nothing, writing each signal. lite-signal's Object.is equality gate then halts propagation at unchanged sources -- so a height-only change wakes component B alone, never A or C. The slot is dropped (and, when the last slot goes, the singleton disconnected) on the last dispose().
API reference
observeResize(element, options?) -> ResizeHandle
interface ResizeOptions {
box?: 'content' | 'border'; // default 'content'
}
interface ResizeHandle {
readonly width: Signal<number>; // CSS pixels (see box)
readonly height: Signal<number>; // CSS pixels (see box)
dispose(): void; // idempotent
}Width and height are independent fine-grained signals. N consumers of the same element share one underlying ResizeObserver.observe() call; the element is unobserved when the last handle is disposed.
options.box selects which box the width / height signals surface: 'content' (default) mirrors entry.contentRect; 'border' mirrors entry.borderBoxSize (includes padding + border, matching getBoundingClientRect()). Mixed-box consumers of the same element still share a single underlying observation -- one ResizeObserver fires both, and each handle reads the pair it asked for. On legacy runtimes without borderBoxSize, a border-box reader sees content-box values until the next layout.
observeIntersection(element, options?) -> IntersectionHandle
interface IntersectionHandle {
readonly isIntersecting: Signal<boolean>;
readonly ratio: Signal<number>; // 0..1, raw IntersectionObserverEntry.intersectionRatio
dispose(): void;
}Equivalent options share one underlying observer. Equivalence is (root by reference, rootMargin by string, threshold by value/order). Different options spawn distinct observers, per spec.
observeMutation(element, options) -> MutationHandle
interface MutationHandle {
readonly tick: Signal<number>; // monotonic counter, +1 per batch
peekRecords(): ReadonlyArray<MutationRecord>; // latest batch, no copy
dispose(): void;
}Mutations are events, not state: surfacing the latest MutationRecord[] as a signal would either force an array allocation per batch (broken zero-GC) or break consumer expectations about value identity. Instead the library exposes a monotonic tick signal that increments per delivered batch, plus a peek-only accessor for the most recent batch. React to tick, read the records inside the body.
Consumers asking for the same element with the same option set share one observer; differing options spawn distinct observers.
observeMutationSelector(root, selector, options?) -> MutationSelectorHandle
interface MutationSelectorHandle {
readonly added: Signal<readonly Element[]>; // matched, added this batch
readonly removed: Signal<readonly Element[]>; // matched, removed this batch
readonly tick: Signal<number>; // mirrors the inner observeMutation tick
peekRecords(): readonly MutationRecord[];
dispose(): void;
}Selector-filtered wrapper over observeMutation for the common "tell me when elements matching X are added or removed under this root" pattern (combobox option lists, infinite-scroll containers, autocomplete panels). Defaults to { childList: true, subtree: true }.
With subtree: true, the walk also runs querySelectorAll(selector) against added / removed subtrees, so a matching element that arrives as a deep descendant of an added container -- not as its own addedNodes entry -- is still surfaced. This is the case most hand-rolled record walks get wrong.
Allocation note. added / removed arrays are allocated fresh per batch that has at least one matching mutation, so consumers may safely retain a reference for later inspection. Batches with zero matches return the same frozen empty array (no allocation). This is the one deliberate departure from strict library-side zero-GC, made for consumer-ergonomics; the underlying observeMutation dispatch it builds on stays zero-alloc.
observeMedia(query) -> Signal<boolean>
Returns a boolean signal that reflects the live match state of the given media query. The same query string returns the same signal node across calls. The underlying change listener is attached lazily on first read (via lite-signal's observer-lifecycle hook) and detached when no consumer is reading -- an imported-but-unread query costs only the signal node and a Map entry.
documentVisible: Signal<boolean>
Module-level signal. True iff document.visibilityState === 'visible'. Listener attached lazily on first read, detached on last unsubscribe.
Design notes
Fine-grained, not coarse. A coarse-grained API would return a single rect signal carrying { width, height }. That makes any read wake on any change, defeating the point. The fine-grained shape leans on lite-signal's Object.is equality gate to halt propagation at unchanged sources.
Tick instead of records. observeMutation returns a tick signal rather than a records signal because (a) mutation records are transient by spec, (b) emitting a fresh array per batch would force allocations on a hot DOM path, and (c) consumers typically need either a coarse "something changed" wakeup or full record inspection, never partial. The tick + peek shape gives both with zero allocation library-side.
Lazy listeners, not eager. observeMedia and documentVisible use lite-signal's observeObservers to attach the underlying DOM listener only while at least one consumer is subscribed. Idle signals cost no listener, just one signal node.
SSR safe. Every module guards on typeof checks; in Node without the relevant DOM API, signals are created and stay at their initial values. Importing the package never throws.
Zero-GC steady state. Library-side allocations happen at registration; the callback path uses indexed for loops, no iterators, no closures created per callback. Browser-allocated entries (the ResizeObserverEntry[], the MutationRecord[]) we cannot avoid; we add nothing to them.
Benchmarks
The bench drives each subsystem's dispatch path synthetically (browsers don't run in Node) with one live subscriber per signal so writes actually propagate, and measures heap growth and minor-GC (scavenge) count under load. The gate fails the build if any path shows a single scavenge or more than 1 byte/call. That threshold being zero is the point: one accidental allocation regresses the gate.
Run: node --expose-gc bench/lite-observe.bench.js (Node 22.22, V8 12.4):
| path | ns/call | B/call | scavenges |
| --- | --- | --- | --- |
| observeResize.dispatch (64 elements) | ~17900 | 0.00 | 0 |
| observeIntersection.dispatch (64 elements) | ~17100 | 0.00 | 0 |
| observeMutation.dispatch (3 records) | ~116 | 0.00 | 0 |
| observeMedia.change (1 listener) | ~124 | 0.00 | 0 |
The headline figure is B/call and scavenges, not nanoseconds. Zero bytes and zero scavenges across every dispatch path is the verifiable claim. The ns/call numbers are machine-dependent and indicative only -- and for resize / intersection they cover a full batch of 64 elements (two signals each, plus a subscriber per signal firing), so the per-element cost is roughly total / 64. observeMutationSelector is intentionally absent from the gate: it allocates result arrays on matching batches by design (see its API note).
Edge cases & guarantees
- Dispose is idempotent. Calling
dispose()twice is safe and does nothing on the second call. - Last-dispose tears down. When the refcount on an element-slot hits zero, the element is unobserved. When an
IntersectionObserver's slot map empties, the observer is disconnected and dropped from the cache. The same applies to theResizeObserversingleton: when the last slot is disposed, the singleton is disconnected and nulled. The nextobserveResize()cheaply rebuilds it -- the one-allocation cost is paid only on the first observe after a fully-idle period, never per-call. - Untracked entries are silently ignored. If a browser delivers an entry for an element not currently in the slot map (e.g. dispose raced with the callback), the entry is skipped.
- The first emission is async. Both
ResizeObserverandIntersectionObserverdeliver their initial observation in a future task. Signals read as0/falseuntil then. - Test isolation utilities.
_resetResize,_resetIntersection,_resetMutation,_resetMedia,_forceVisibilitySyncare exported with a leading underscore. They are not part of the stable public API and exist for test harnesses. - ResizeObserver loop limits and synchronous downstream writes. The browser dispatches
ResizeObserverentries to its callback synchronously; we write width / height signals from inside that callback. If a consumer effect on those signals then synchronously writes layout-affecting styles (anything that changes element box size, not justtransform), the browser will throw "ResizeObserver loop limit exceeded" because the same observer would need to re-fire in the same frame. The standard mitigation is to defer DOM writes one frame viarafEffectfrom@zakkster/lite-raf, or to usetransform-only updates which compose without triggering layout.@zakkster/lite-floating's own update loop defers viarequestAnimationFramefor this reason; consumers writing their own RO-driven effects should do the same. observeMediacache lifecycle. Each unique query string is memoised in aMapkeyed by the string itself. The memo is evicted when the signal's last observer detaches (via lite-signal'sobserveObservers0-to-1 transition we already use for lazy listener attachment); the cache size is bounded to "queries currently being observed". A consumer who holds a strong reference to the signal across an unobserved period continues to work correctly -- the closure bindsmqland the change handler, and re-subscribing re-attaches the listener via the sameobserveObserverslifecycle. New callers asking for the same query during such a held-but-unobserved period get a fresh signal+MQL pair (briefly wasteful, never wrong). The pathological case of dynamic query strings ((min-width: ${changingWidth}px)) is fully addressed by the eviction.- Orphaned-handle cleanup (FinalizationRegistry). Explicit
dispose()is the primary path; this is the safety net for consumers who drop the handle without disposing it. Each handle fromobserveResize/observeIntersection/observeMutationis registered with a sharedFinalizationRegistry. When the runtime garbage-collects the handle, the registry fires our inner cleanup, which disconnects (or unobserves) the underlying browser observer the same way an explicit dispose would. The held value passed to the registry is the bare cleanup closure -- it captures slot / element / Map references but never the handle itself, which is what makes finalisation possible.- Non-deterministic timing. Finalisation runs "eventually" on the runtime's schedule, not on a guaranteed deadline. On a quiet page that may be milliseconds; on a heavily pressured page it may be measurably later. If you have a lifecycle hook (a framework unmount, a route teardown, an explicit close), call
dispose()-- you'll save the GC a trip and free the underlying observer immediately. The registry is for the cases where you don't. - Double-fire safety. Calling explicit
dispose()unregisters the finalizer before running the cleanup, so a subsequent GC pass on the same handle does nothing. The inner cleanup's owndisposedguard is a belt-and-braces second layer. - What it does not solve. A consumer who holds the handle in long-lived state and intends to retain the observation continues to do so -- nothing about FinalizationRegistry changes that contract. It only catches the "I dropped my reference and forgot to dispose" footgun.
- Non-deterministic timing. Finalisation runs "eventually" on the runtime's schedule, not on a guaranteed deadline. On a quiet page that may be milliseconds; on a heavily pressured page it may be measurably later. If you have a lifecycle hook (a framework unmount, a route teardown, an explicit close), call
- MutationObserver targets and explicit retention. The DOM spec requires
MutationObserver(and structurally, the other observers) to retain references to their observed nodes internally sodisconnect()knows what to deregister. As long as you hold the handle and have not disposed, the target stays alive even if you've removed it from the DOM tree. This is correct behaviour, not a leak -- the observer is still attached, so the node is still relevant. Dispose to release.
Roadmap
Items deferred from 1.0.0, grouped by tier. Marks intent, not promises; honest scoping happens at each version cut.
1.1.x -- additive surface
- Real-browser finalization smoke. A page that creates handles, encourages GC via allocation pressure, and asserts on
_resizeSlotCount()/_intersectionBucketCount()/_mutationObserverCount()going to zero. Current finalization tests rely on V8's--expose-gc; this would catch Safari / Firefox divergence in real-world timing characteristics. - Adaptive
IntersectionObserverthreshold. Exposehandle.setThreshold(thresholds)so consumers can refine the threshold list without recreating the observer. observeMutationSelectorperformance refinement. Current implementation allocates fresh arrays per batch with matches. For high-frequency mutation streams (live-updating leaderboards, log tails) where the allocation shows up, an opt-inpooled: truemode would reuse arrays across batches with a documented "do not retain" contract.- Attribute-filtered
observeMutationSelector. Currently onlychildListmutations are filtered. An option to also surface attribute mutations matching the selector (e.g. "tell me when any.optionbecomesaria-selected="true"") would close a real ergonomics gap.
1.2.x -- ergonomics and developer experience
onShow/onHideconvenience overdocumentVisible. Thin wrappers that compose withlite-signal'seffectto schedule one-shot callbacks on transition. Strictly additive; the signal remains the primitive.- Bench harness regression gates in CI. Threshold checks that fail if
B/calldrifts above zero or ifns/callregresses by more than 25% across versions. The bench numbers we report should be auditable, not pinky-promise. observeResizelogical-axis option (box: 'logical'). SurfaceinlineSize/blockSizedirectly instead of width / height, for consumers building writing-mode-aware layouts. Strictly additive; the existing'content'/'border'options stay the default.
2.0.x -- breaking-change tier
- Additional observer kinds.
PerformanceObserver,ReportingObserver, possiblyBroadcastChannelif the signal-bridge fit is clean. Each adds public surface and is breaking-change-adjacent if any conflict with existing exports. - WeakRef-based
observeMediacache (opt-in). Current cache evicts on disconnect; consumers holding signals across an unobserved period get a fresh signal+MQL pair if they re-request the same query during that window. Briefly wasteful, never wrong. WeakRef would preserve identity at the cost of slightly delayed eviction. Opt-inobserveMedia(query, { weakCache: true }). Marked 2.x because if it becomes the default it changes observable behaviour. - Synchronous-dispatch contract formalisation. Currently we assume browser observer callbacks do not re-enter -- the browser's own queueing handles it. Worth a stress test that proves it under adversarial conditions (dispatch fires while a consumer is mid-iteration through signals from a different element), and either documenting the contract or hardening against re-entry.
Not planned
- A coarse
rectsignal onobserveResize. The fine-grainedwidth/heightsplit is the design; collapsing them to a single rect would defeat the Object.is propagation gate. If you want arect-shaped object, compose it from the two signals in acomputed. peekRecordsreturning a copy. Identity reuse across ticks is the contract; copying would force allocation on the hot path. Consumers needing snapshot semantics should iterate the array inside the effect that observed the tick change.- Replacing
tick + peekwith a records-shaped signal. Considered and rejected during the 0.1.x design phase. Mutation records are transient by spec; a records-shaped signal would either allocate per batch or break identity semantics. The current shape gives reactive wakeups and full record access without either.
Browser & runtime support
All five bridged APIs (ResizeObserver, IntersectionObserver, MutationObserver, matchMedia, Page Visibility) are baseline in every evergreen browser. observeMedia falls back to the legacy addListener / removeListener on Safari < 14. The FinalizationRegistry orphan-cleanup net is used where available (all evergreen browsers, Node 14+) and is a no-op pass-through where it is not -- explicit dispose() works everywhere regardless. SSR-safe: under Node without the DOM globals, every entry point creates signals that stay at their initial values and importing the package never throws. Requires Node >= 18 for the test/bench tooling.
Testing
- Unit (Node, fast).
npm testruns thenode:testsuites against fake observer globals installed before the SUT loads. Covers shared-observer dedup, refcount teardown, per-field equality gating, theboxoption, selector filtering (including the deep-descendant subtree case), media cache eviction, and SSR no-throw. - Memory (
--expose-gc).npm run test:gcadditionally exercises theFinalizationRegistryorphan-cleanup tests, which skip gracefully without the flag. - Allocation gate.
npm run benchfails if any dispatch path shows a scavenge or > 1 byte/call (see Benchmarks). - Real-browser smoke.
npm run test:browserservestest/browser/smoke.html, which runs the same assertions against real browser observers (real layout, real scroll, realMutationObservermicrotask timing).window.__SMOKE_RESULTS__is set for scraping.
npm scripts
| script | what it does |
| --- | --- |
| test | node --test test/*.test.js -- unit suite against fakes |
| test:gc | as above, with --expose-gc for the finalization tests |
| test:browser | serve the browser smoke page |
| demo | serve the interactive demo (demo/index.html) |
| bench | node --expose-gc bench/lite-observe.bench.js -- allocation gate |
| verify | npm test && npm run bench |
Ecosystem
lite-observe is part of the @zakkster/lite-* family of zero-GC, zero-dependency ESM micro-libraries built on @zakkster/lite-signal:
@zakkster/lite-signal-- the reactive core (peer dependency).@zakkster/lite-floating-- anchored-positioning primitives; its RO-driven update loop defers viarequestAnimationFrame, the pattern recommended under Edge cases.@zakkster/lite-signal-dom,@zakkster/lite-router,@zakkster/lite-persist, and others compose the same signal model.
License
MIT (c) Zahary Shinikchiev.
