npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@zakkster/lite-observe

npm version sponsor Zero-GC npm bundle size npm downloads npm total downloads lite-signal peer TypeScript License: MIT

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

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&lt;Element, slot&gt;"]
        REG --> SLOT["slot for el<br/>refCount: 3"]
        SLOT --> SW["width: Signal&lt;number&gt;"]
        SLOT --> SH["height: Signal&lt;number&gt;"]
    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"| C2

Three 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 the ResizeObserver singleton: when the last slot is disposed, the singleton is disconnected and nulled. The next observeResize() 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 ResizeObserver and IntersectionObserver deliver their initial observation in a future task. Signals read as 0 / false until then.
  • Test isolation utilities. _resetResize, _resetIntersection, _resetMutation, _resetMedia, _forceVisibilitySync are 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 ResizeObserver entries 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 just transform), 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 via rafEffect from @zakkster/lite-raf, or to use transform-only updates which compose without triggering layout. @zakkster/lite-floating's own update loop defers via requestAnimationFrame for this reason; consumers writing their own RO-driven effects should do the same.
  • observeMedia cache lifecycle. Each unique query string is memoised in a Map keyed by the string itself. The memo is evicted when the signal's last observer detaches (via lite-signal's observeObservers 0-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 binds mql and the change handler, and re-subscribing re-attaches the listener via the same observeObservers lifecycle. 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 from observeResize / observeIntersection / observeMutation is registered with a shared FinalizationRegistry. 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 own disposed guard 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.
  • MutationObserver targets and explicit retention. The DOM spec requires MutationObserver (and structurally, the other observers) to retain references to their observed nodes internally so disconnect() 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 IntersectionObserver threshold. Expose handle.setThreshold(thresholds) so consumers can refine the threshold list without recreating the observer.
  • observeMutationSelector performance 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-in pooled: true mode would reuse arrays across batches with a documented "do not retain" contract.
  • Attribute-filtered observeMutationSelector. Currently only childList mutations are filtered. An option to also surface attribute mutations matching the selector (e.g. "tell me when any .option becomes aria-selected="true"") would close a real ergonomics gap.

1.2.x -- ergonomics and developer experience

  • onShow / onHide convenience over documentVisible. Thin wrappers that compose with lite-signal's effect to 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/call drifts above zero or if ns/call regresses by more than 25% across versions. The bench numbers we report should be auditable, not pinky-promise.
  • observeResize logical-axis option (box: 'logical'). Surface inlineSize / blockSize directly 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, possibly BroadcastChannel if the signal-bridge fit is clean. Each adds public surface and is breaking-change-adjacent if any conflict with existing exports.
  • WeakRef-based observeMedia cache (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-in observeMedia(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 rect signal on observeResize. The fine-grained width / height split is the design; collapsing them to a single rect would defeat the Object.is propagation gate. If you want a rect-shaped object, compose it from the two signals in a computed.
  • peekRecords returning 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 + peek with 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 test runs the node:test suites against fake observer globals installed before the SUT loads. Covers shared-observer dedup, refcount teardown, per-field equality gating, the box option, selector filtering (including the deep-descendant subtree case), media cache eviction, and SSR no-throw.
  • Memory (--expose-gc). npm run test:gc additionally exercises the FinalizationRegistry orphan-cleanup tests, which skip gracefully without the flag.
  • Allocation gate. npm run bench fails if any dispatch path shows a scavenge or > 1 byte/call (see Benchmarks).
  • Real-browser smoke. npm run test:browser serves test/browser/smoke.html, which runs the same assertions against real browser observers (real layout, real scroll, real MutationObserver microtask 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 via requestAnimationFrame, 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.