@zakkster/lite-observe 1.0.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,312 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 -- Production cut
4
+
5
+ The 0.2.x line proved out FinalizationRegistry-based orphan cleanup; the
6
+ 0.3.x demo work proved out the library against real consumer patterns;
7
+ 0.5.x-0.7.x added the cross-cutting features that lite-headless needs;
8
+ 0.9.x consolidated documentation and conventions. This release declares
9
+ the public surface stable.
10
+
11
+ - **Pre-publish correctness pass (blocking fixes).**
12
+ - **Runtime imports in `test/*.test.js`, `bench/lite-observe.bench.js`,
13
+ and `demo/demo.js` corrected from `../index.d.ts` to `../index.js`.**
14
+ The declaration file has no runtime exports; under Node >= 22.18
15
+ (type-stripping on by default) it threw `ERR_INVALID_TYPESCRIPT_SYNTAX`,
16
+ so the entire `node:test` suite, the bench gate, and the in-browser
17
+ demo could not load. `test/browser/smoke.html` was already correct.
18
+ (Shipped `index.js` / `src/*` were never affected; tests/bench/demo
19
+ are dev-only and not in published `files`.)
20
+ - **`publishConfig.access: public` added** so the scoped package
21
+ actually publishes public.
22
+ - **Documentation completeness.** `observeMutationSelector` and the
23
+ `observeResize` `box` option were shipped and tested but missing from
24
+ the README API reference and `llms.txt`; both now documented. Added an
25
+ Architecture (Mermaid) diagram and a Benchmarks section with the
26
+ measured gate numbers, matching the lite-signal README blueprint.
27
+ `index.d.ts` JSDoc for `observeResize` was attached to the wrong symbol
28
+ (`ResizeOptions` instead of the function); corrected. `llms.txt` peer
29
+ range aligned to `^1.2.2`.
30
+ - **Test coverage added.** SSR / no-DOM-global suite (the documented
31
+ "SSR-safe" contract, previously untested), the legacy `matchMedia`
32
+ `addListener`/`removeListener` branch, the removed-subtree selector
33
+ walk, and `_forceVisibilitySync`. Suite: 59 passing + 6 gc-gated
34
+ (65 under `npm run test:gc`), bench gate green (0 scavenges, 0 B/call).
35
+ - **Stable public surface.** `observeResize`, `observeIntersection`,
36
+ `observeMutation`, `observeMutationSelector`, `observeMedia`,
37
+ `documentVisible`, `attachFinalizer`. Signatures and return shapes
38
+ are frozen for the 1.x line. The leading-underscore diagnostics
39
+ (`_resetResize`, `_resizeSlotCount`, etc.) remain unstable by name
40
+ and are not part of the semver guarantee.
41
+ - **`_hasFinalizationRegistry` diagnostic exported** for test
42
+ introspection -- consumers writing wrapping libraries can check
43
+ whether orphan cleanup will be effective on the host runtime.
44
+ - **README "Edge cases & guarantees" rewritten** against the v1.0
45
+ behaviour. The v0.2-placeholder paragraph that hedged on
46
+ FinalizationRegistry has been replaced with the actual contract:
47
+ double-fire safety, non-deterministic timing, what the registry
48
+ catches and what it doesn't.
49
+ - **`llms.txt` regenerated** with the orphan-cleanup design note so
50
+ downstream LLM-assisted consumers have the contract in context.
51
+
52
+ ## 0.9.3 -- Demo scene for `observeMutationSelector`
53
+
54
+ - Sixth scene added to the bundled demo: "Selector Filter". A
55
+ subtree with mixed children (matching options, non-matching headers,
56
+ nested wrappers with deep matches) demonstrates the selector filter
57
+ surfacing only the relevant elements via the `added` / `removed`
58
+ signals. Buttons drive each mutation kind individually so the
59
+ selector-vs-record-walk distinction is observable.
60
+ - Cumulative match counters in the corner overlays make the matching/
61
+ non-matching split obvious during a mutation storm.
62
+
63
+ ## 0.9.2 -- Demo dogfooding audit
64
+
65
+ - Every per-frame UI write in the demo audited against the
66
+ "persistent-DOM-node mutation" pattern from the lite-floating README.
67
+ Every `textContent = X` in a hot path replaced with `textNode.data = X`
68
+ on a node hoisted at scene setup; every dynamic stream (records,
69
+ visibility timeline, media tiles) backed by a pre-allocated DOM pool
70
+ whose children are mutated rather than recreated.
71
+ - Confirmed: the demo allocates zero DOM nodes after mount on the
72
+ steady-state interaction paths. The browser-allocated `MutationRecord`
73
+ arrays and `getBoundingClientRect` results are the only unavoidable
74
+ allocations on the boundary.
75
+
76
+ ## 0.9.1 -- Demo conventions pass
77
+
78
+ - `oklch()` palette applied across the demo with hex fallback via the
79
+ redeclare pattern (`:root { --green: #5fe39f; } @supports (color:
80
+ oklch(0 0 0)) { :root { --green: oklch(82.5% 0.152 157.9); } }`).
81
+ Each oklch value converted from the canonical hex via the precise
82
+ sRGB -> linear -> OKLab -> oklch path, not eyeballed.
83
+ - All `:hover` rules collected into a single `@media (hover: hover)`
84
+ block at the bottom of the stylesheet so touch devices that stick
85
+ hover after a tap skip the block entirely.
86
+ - `100dvh` on the app root so mobile browser chrome doesn't clip the
87
+ bottom panel row.
88
+ - `:not([aria-current="true"]):hover` on the tab nav so the hover
89
+ preview doesn't fight the selected-state styling.
90
+ - ASCII sweep across the full package -- no U+00B7 separators or em-dashes or arrow glyphs slipped into source per project convention.
91
+
92
+ ## 0.9.0 -- Interactive demo, first cut
93
+
94
+ - Five-scene interactive demo (`demo/index.html`) covering each
95
+ observer kind: Resize Atlas, Intersection Scanner, Mutation Stream,
96
+ Media Probe, Visibility Pulse. Full-viewport tabbed layout matching
97
+ the `@zakkster/lite-floating` demo's oscilloscope visual identity.
98
+ - Resize scene: drag-to-resize via custom pointer handle plus
99
+ programmatic sliders. Width and height signals stream into corner
100
+ overlays and panel tiles independently (proves the Object.is gating
101
+ on each signal).
102
+ - Intersection scene: scrollable area with a target between fillers,
103
+ threshold every 5% so `ratio` drifts smoothly through transitions.
104
+ - Mutation scene: subtree list with five mutation buttons (single
105
+ add / remove / toggle / batch-five / clear) and a rolling records
106
+ stream showing the last six batches with type breakdown.
107
+ - Media scene: ten probe queries (color-scheme, hover, pointer,
108
+ prefers-reduced-motion, breakpoints, orientation) shown as tiles
109
+ with active highlight.
110
+ - Visibility scene: pulsing glyph, 32-bar timeline, transition log,
111
+ hidden-duration accumulator.
112
+ - `npm run demo` script added. Serves at port 8767 to avoid colliding
113
+ with `npm run test:browser`.
114
+
115
+ ## 0.7.0 -- Public `attachFinalizer`
116
+
117
+ - The internal orphan-cleanup helper promoted to a public export.
118
+ Downstream consumers building their own observation primitives (a
119
+ `ScrollObserver` over `addEventListener('scroll')`, a
120
+ `PointerCaptureObserver`, an `AbortSignal`-driven listener wrapper)
121
+ can now use the same FinalizationRegistry safety net the built-in
122
+ observers have.
123
+ - JSDoc rewritten for public consumption with a worked example
124
+ building a `ScrollObserver` primitive end-to-end. Contract spelled
125
+ out: inner cleanup must not capture the handle, must be idempotent,
126
+ errors are swallowed by the registry.
127
+ - TypeScript declarations updated; the public signature is generic
128
+ over the handle type, preserving the consumer's handle shape through
129
+ the registration call.
130
+
131
+ ## 0.6.0 -- Border-box dimensions for `observeResize`
132
+
133
+ - New `options.box` parameter on `observeResize`: `'content'` (default,
134
+ matches existing behaviour) or `'border'` (surfaces `borderBoxSize`
135
+ including padding and border, matching `getBoundingClientRect()`).
136
+ - Each slot now carries both content and border signal pairs. The
137
+ dispatch callback writes all four every fire; the Object.is gate
138
+ prevents wakeups for consumers reading only one pair. Mixed-box
139
+ consumers on the same element transparently share one observation:
140
+ a single ResizeObserver instance fires both pairs, each handle
141
+ surfaces the pair it asked for.
142
+ - Legacy-browser fallback: if `entry.borderBoxSize` is absent (older
143
+ Safari before v15.4), the border signals mirror contentRect. The
144
+ affected consumer sees content dimensions until the runtime catches
145
+ up; never undefined, never zero, never wrong-shape.
146
+ - Four new tests covering default-is-content, border surfaces
147
+ borderBoxSize, mixed-box single-observation, legacy fallback.
148
+
149
+ ## 0.5.0 -- `observeMutationSelector` helper
150
+
151
+ - New helper module `src/MutationSelector.js` exporting
152
+ `observeMutationSelector(root, selector, options)`. Wraps the
153
+ existing `observeMutation` with library-side selector filtering.
154
+ Returns `{ added: Signal<Element[]>, removed: Signal<Element[]>,
155
+ tick, peekRecords, dispose }`.
156
+ - When `options.subtree` is true (default), the inner walker uses
157
+ `querySelectorAll` against each added/removed top-level node to
158
+ catch matching descendants -- the case where a matching element
159
+ arrives as a deep descendant of an added container rather than as
160
+ its own `addedNodes` entry. This is the most common bug class in
161
+ hand-rolled selector filtering, and the helper makes it unmissable.
162
+ - Allocation profile: fresh arrays per batch with matches; the frozen
163
+ empty array (stable identity) for batches with no matches. Consumer
164
+ ergonomics win out over strict zero-alloc here -- arrays the
165
+ consumer might retain for inspection must be theirs, not the
166
+ library's.
167
+ - Ten tests covering: matching adds / removes / mixed; subtree-vs-flat
168
+ walk; top-level + nested in same batch; empty-batch identity reuse;
169
+ dispose semantics; tick mirroring; non-childList records ignored.
170
+
171
+ ## 0.4.0 -- Bench gate revalidated against FR-enabled dispatch
172
+
173
+ - All four dispatch paths confirmed 0 B/call, 0 scavenges after the
174
+ FinalizationRegistry wiring landed. Registration cost is paid once
175
+ at observe time, not per dispatched event; the hot paths are
176
+ untouched.
177
+ - Bench thresholds documented in `bench/lite-observe.bench.js` to make
178
+ future regressions obvious.
179
+
180
+ ## 0.3.0 -- Finalization test suite
181
+
182
+ - Nine new tests in `test/finalize.test.js` covering the orphan-cleanup
183
+ paths:
184
+ - Resize: orphan handle triggers cleanup via the registry; explicit
185
+ dispose still works after the `attachFinalizer` wrap; explicit
186
+ dispose then orphan-settle does not double-fire; refcount-shared
187
+ slot stays alive while one consumer holds its handle.
188
+ - Intersection: orphan cleanup, explicit dispose tears down.
189
+ - Mutation: orphan cleanup, explicit dispose disconnects, explicit
190
+ dispose plus orphan settle is safe.
191
+ - All FR-requiring tests guard on `typeof gc === 'function'` so they
192
+ skip gracefully under `npm test` and run under `npm run test:gc`
193
+ (which adds `--expose-gc`).
194
+ - Settle helper loops six `gc()` + `setImmediate` cycles to encourage
195
+ V8 to drain the finalisation queue. Documented as the knob to turn
196
+ if a heavily loaded CI flakes.
197
+
198
+ ## 0.2.1 -- Mutation finalizer wiring
199
+
200
+ - `observeMutation` returns through `attachFinalizer` so orphaned
201
+ handles trigger `observer.disconnect()` via GC. The Mutation case is
202
+ the highest-value because the spec-mandated retention of observed
203
+ nodes is structural -- a forgotten dispose keeps DOM nodes alive
204
+ until process exit. The registry catches the common forgot-to-
205
+ dispose path.
206
+ - Caveat documented in the source header: this doesn't defeat the
207
+ retention while the handle is held intentionally. The contract is
208
+ "drop the handle, eventually the observer disconnects" -- nothing
209
+ more, nothing less.
210
+
211
+ ## 0.2.0 -- FinalizationRegistry helper, Resize + Intersection wired
212
+
213
+ - New module `src/_finalize.js` exporting `attachFinalizer(handle,
214
+ innerCleanup)`. Single shared `FinalizationRegistry` across modules so
215
+ the runtime processes one queue, not three.
216
+ - The held value passed to the registry is the bare inner-cleanup
217
+ closure -- it captures slot / element / Map references but never the
218
+ handle itself. This is what makes finalisation possible; the
219
+ alternative (passing the handle's own `dispose`) would prevent the
220
+ handle from being collected.
221
+ - `observeResize` and `observeIntersection` return through
222
+ `attachFinalizer`. Explicit `dispose()` unregisters first so a
223
+ subsequent finalisation does not double-fire.
224
+ - Defensive: the inner cleanup is wrapped in `try / catch` in the
225
+ registry callback so one failing finalizer cannot poison the queue
226
+ for others pending.
227
+ - Bench gate: unchanged. Registration is one-time at observe; the
228
+ dispatch hot path is identical to 0.1.x.
229
+
230
+ ## 0.1.2 -- Cache eviction + GC honesty
231
+
232
+ - **Media cache eviction.** `observeMedia`'s memo `Map` now evicts entries
233
+ when the signal's last observer detaches, via the `observeObservers`
234
+ lifecycle already in place for lazy listener attachment. Cache size is
235
+ bounded to "queries currently being observed" rather than "queries ever
236
+ requested" -- the unbounded-growth hazard for dynamic query strings is
237
+ closed.
238
+ - Consumers holding a strong reference to the signal across an unobserved
239
+ period continue to work: re-subscribing fires `onConnect` and
240
+ re-attaches the listener through the same closure machinery.
241
+ - A new caller asking for an evicted query gets a fresh signal+MQL pair.
242
+ Brief duplicate work in that transient, never incorrect.
243
+ - Last turn's pushback against this change was wrong: I claimed it would
244
+ leave consumers with stale signals; tracing through the closure
245
+ lifecycle, it doesn't. Apologies for the misdirection.
246
+ - **Mutation strong-ref leak removed.** The previous `liveElements`
247
+ `Set<Element>` defeated the `WeakMap` -- every observed element was
248
+ retained until manual cleanup. Replaced with a `Set<MutationObserver>`
249
+ that tracks the lightweight library objects.
250
+ - Caveat: this is a *code-quality* improvement, not a GC fix. The DOM
251
+ spec requires `MutationObserver` to retain references to its observed
252
+ nodes (WebKit's `HashSet<Ref<Node>>`, Chromium's
253
+ `HeapVector<Member<Node>>`), so the leak in the forgot-to-dispose case
254
+ persists -- just through spec-defined channels rather than our own
255
+ Set. Engines can optimise / finalize observer chains; they couldn't
256
+ optimise our Set.
257
+ - Diagnostic renamed: `_mutationLiveElementCount` -> `_mutationObserverCount`.
258
+ The old name implied an element count; what we track now is observers.
259
+ - **README**: documents both the Media eviction behaviour (including the
260
+ hold-while-unobserved case) and the spec-defined MutationObserver target
261
+ retention as a known forgot-to-dispose hazard.
262
+ - **Browser smoke suite added.** `npm run test:browser` spawns a zero-
263
+ dependency Node static server (`test/browser/serve.js`) that serves
264
+ the package root; opening `http://localhost:8765/test/browser/smoke.html`
265
+ in any browser runs the suite against real ResizeObserver,
266
+ IntersectionObserver, MutationObserver, matchMedia, and
267
+ document.visibilityState APIs. Results render in the page and are also
268
+ set on `window.__SMOKE_RESULTS__` for programmatic scraping by a future
269
+ Playwright/Puppeteer wrapper. Smoke files are dev-only (not in published
270
+ `files`).
271
+ - **Zero-GC bench gate added.** `npm run bench` exercises all four
272
+ dispatch paths (resize, intersection, mutation, media-change) under
273
+ 100k iterations across 5 runs, with `--expose-gc` and PerformanceObserver
274
+ scavenge counting. Current measurements on Node 22:
275
+ - `observeResize.dispatch (64 els)`: ~13 us/call, 0 B/call, 0 scavenges
276
+ - `observeIntersection.dispatch (64 els)`: ~16 us/call, 0 B/call, 0 scavenges
277
+ - `observeMutation.dispatch (3 records)`: 130 ns/call, 0 B/call, 0 scavenges
278
+ - `observeMedia.change (1 listener)`: 117 ns/call, 0 B/call, 0 scavenges
279
+
280
+ Gate is strict: a single scavenge or > 1 B/call in any run fails the
281
+ build. `npm run verify` runs tests then bench. Bench files are dev-only
282
+ (not in published `files`).
283
+
284
+ ## 0.1.1 -- Hardening patches
285
+
286
+ - **Resize: symmetric singleton tear-down.** When the last refcount on the last
287
+ observed element hits zero, the singleton `ResizeObserver` is disconnected
288
+ and nulled. The next `observeResize()` call cheaply rebuilds it. Brings the
289
+ Resize subsystem in line with Intersection's empty-bucket tear-down; long-
290
+ lived pages that mount and unmount popovers no longer hold an observer
291
+ reference indefinitely while idle.
292
+ - **README: ResizeObserver loop-limit warning.** Documented the failure mode
293
+ where a consumer effect on size signals synchronously writes layout-
294
+ affecting styles, with the standard mitigation (defer via `rafEffect` from
295
+ `@zakkster/lite-raf`, or use `transform`-only updates). The library cannot
296
+ enforce this from the inside; consumers writing custom RO-driven effects
297
+ are responsible.
298
+ - **README: `observeMedia` cache-growth limitation.** Documented that
299
+ consumers generating dynamic query strings (`(min-width: ${width}px)` tied
300
+ to a continuously-changing width) will see the memo Map grow. The fix that
301
+ preserves API correctness needs `WeakRef` and is planned for v0.2.0; for
302
+ v0.1.x the recommendation is to avoid dynamic query strings.
303
+
304
+ ## 0.1.0 -- Initial release
305
+
306
+ - `observeResize(el)` -> `{ width, height, dispose }`. Singleton `ResizeObserver`, refcounted per element.
307
+ - `observeIntersection(el, options?)` -> `{ isIntersecting, ratio, dispose }`. Observers keyed by `(root, rootMargin, threshold)`; refcounted per element within an options bucket.
308
+ - `observeMutation(el, options)` -> `{ tick, peekRecords, dispose }`. Monotonic counter signal plus peek-only record accessor. Observers keyed by `(element, optionsKey)`.
309
+ - `observeMedia(query)` -> `Signal<boolean>`. One signal per query string, cached globally. Listener attached lazily via lite-signal `observeObservers`.
310
+ - `documentVisible: Signal<boolean>`. Module-level page-visibility signal, lazy listener.
311
+ - Test-isolation utilities (underscore-prefixed) for resetting each subsystem.
312
+ - 28 tests passing under `node:test`. Zero runtime dependencies; peer-dep on `@zakkster/lite-signal` ^1.2.0.
package/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # @zakkster/lite-observe
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-observe.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-observe)
4
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
5
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-observe?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-observe)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-observe?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-observe)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-observe?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-observe)
9
+ [![lite-signal peer](https://img.shields.io/badge/peer-lite--signal-blue?style=for-the-badge)](https://github.com/PeshoVurtoleta/lite-signal)
10
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
12
+
13
+ **The DOM observer APIs as fine-grained signals. N components observing the same element pay one observer cost.**
14
+
15
+ `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.
16
+
17
+ ```js
18
+ import { observeResize, observeIntersection, observeMedia, documentVisible } from '@zakkster/lite-observe';
19
+ import { effect } from '@zakkster/lite-signal';
20
+
21
+ const { width, height, dispose } = observeResize(panel);
22
+ effect(() => render(width(), height())); // wakes only when the panel resizes
23
+
24
+ const isMobile = observeMedia('(max-width: 768px)');
25
+ effect(() => layoutFor(isMobile())); // wakes only on the breakpoint crossing
26
+
27
+ effect(() => { if (!documentVisible()) pauseExpensiveLoop(); });
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Contents
33
+
34
+ - [Why](#why) | [Install](#install) | [Quick start](#quick-start) | [Architecture](#architecture)
35
+ - [API reference](#api-reference)
36
+ - [Design notes](#design-notes) | [Benchmarks](#benchmarks)
37
+ - [Edge cases & guarantees](#edge-cases--guarantees)
38
+ - [Roadmap](#roadmap) | [License](#license)
39
+
40
+ ---
41
+
42
+ ## Why
43
+
44
+ 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.
45
+
46
+ `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.
47
+
48
+ ## Install
49
+
50
+ ```sh
51
+ npm install @zakkster/lite-observe @zakkster/lite-signal
52
+ ```
53
+
54
+ `@zakkster/lite-signal` is a peer dependency.
55
+
56
+ ## Quick start
57
+
58
+ ```js
59
+ import {
60
+ observeResize,
61
+ observeIntersection,
62
+ observeMutation,
63
+ observeMutationSelector,
64
+ observeMedia,
65
+ documentVisible
66
+ } from '@zakkster/lite-observe';
67
+ import { effect } from '@zakkster/lite-signal';
68
+
69
+ // ResizeObserver -- width and height as separate signals
70
+ const r = observeResize(el);
71
+ effect(() => console.log('width', r.width())); // only fires when width moves
72
+
73
+ // IntersectionObserver -- visibility + ratio, options shared by identity
74
+ const i = observeIntersection(el, { rootMargin: '50px' });
75
+ effect(() => { if (i.isIntersecting()) load(); });
76
+
77
+ // MutationObserver -- tick-driven, peek records when you need them
78
+ const m = observeMutation(list, { childList: true });
79
+ effect(() => { m.tick(); reindex(m.peekRecords()); });
80
+
81
+ // Selector-filtered mutations -- matching adds/removes only
82
+ const opts = observeMutationSelector(list, '.option', { childList: true, subtree: true });
83
+ effect(() => { for (const el of opts.added()) wire(el); });
84
+
85
+ // matchMedia -- one signal per query, cached globally
86
+ const dark = observeMedia('(prefers-color-scheme: dark)');
87
+ effect(() => applyTheme(dark() ? 'dark' : 'light'));
88
+
89
+ // Page Visibility -- a module-level signal, lazy listener
90
+ effect(() => { if (!documentVisible()) raf.stopFrames(); });
91
+
92
+ // Dispose handles on unmount
93
+ r.dispose(); i.dispose(); m.dispose();
94
+ ```
95
+
96
+ ## Architecture
97
+
98
+ 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.
99
+
100
+ ```mermaid
101
+ flowchart TB
102
+ subgraph consumers["N consumers, one element"]
103
+ C1["component A<br/>reads width"]
104
+ C2["component B<br/>reads height"]
105
+ C3["component C<br/>reads width"]
106
+ end
107
+
108
+ C1 -->|"observeResize(el)"| REG
109
+ C2 -->|"observeResize(el)"| REG
110
+ C3 -->|"observeResize(el)"| REG
111
+
112
+ subgraph lib["lite-observe registry"]
113
+ REG["slots: Map&lt;Element, slot&gt;"]
114
+ REG --> SLOT["slot for el<br/>refCount: 3"]
115
+ SLOT --> SW["width: Signal&lt;number&gt;"]
116
+ SLOT --> SH["height: Signal&lt;number&gt;"]
117
+ end
118
+
119
+ REG -->|"first observe only"| RO["one ResizeObserver<br/>(page singleton)"]
120
+ RO -->|"browser fires entries[]"| DISP["dispatch()<br/>indexed loop, zero-alloc"]
121
+ DISP -->|"width.set(rect.width)"| SW
122
+ DISP -->|"height.set(rect.height)"| SH
123
+
124
+ SW -.->|"Object.is gate"| C1
125
+ SW -.->|"Object.is gate"| C3
126
+ SH -.->|"Object.is gate"| C2
127
+ ```
128
+
129
+ 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()`.
130
+
131
+ ## API reference
132
+
133
+ ### `observeResize(element, options?)` -> `ResizeHandle`
134
+
135
+ ```ts
136
+ interface ResizeOptions {
137
+ box?: 'content' | 'border'; // default 'content'
138
+ }
139
+ interface ResizeHandle {
140
+ readonly width: Signal<number>; // CSS pixels (see box)
141
+ readonly height: Signal<number>; // CSS pixels (see box)
142
+ dispose(): void; // idempotent
143
+ }
144
+ ```
145
+
146
+ 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.
147
+
148
+ `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.
149
+
150
+ ### `observeIntersection(element, options?)` -> `IntersectionHandle`
151
+
152
+ ```ts
153
+ interface IntersectionHandle {
154
+ readonly isIntersecting: Signal<boolean>;
155
+ readonly ratio: Signal<number>; // 0..1, raw IntersectionObserverEntry.intersectionRatio
156
+ dispose(): void;
157
+ }
158
+ ```
159
+
160
+ 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.
161
+
162
+ ### `observeMutation(element, options)` -> `MutationHandle`
163
+
164
+ ```ts
165
+ interface MutationHandle {
166
+ readonly tick: Signal<number>; // monotonic counter, +1 per batch
167
+ peekRecords(): ReadonlyArray<MutationRecord>; // latest batch, no copy
168
+ dispose(): void;
169
+ }
170
+ ```
171
+
172
+ 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.
173
+
174
+ Consumers asking for the same element with the same option set share one observer; differing options spawn distinct observers.
175
+
176
+ ### `observeMutationSelector(root, selector, options?)` -> `MutationSelectorHandle`
177
+
178
+ ```ts
179
+ interface MutationSelectorHandle {
180
+ readonly added: Signal<readonly Element[]>; // matched, added this batch
181
+ readonly removed: Signal<readonly Element[]>; // matched, removed this batch
182
+ readonly tick: Signal<number>; // mirrors the inner observeMutation tick
183
+ peekRecords(): readonly MutationRecord[];
184
+ dispose(): void;
185
+ }
186
+ ```
187
+
188
+ 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 }`.
189
+
190
+ 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.
191
+
192
+ **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.
193
+
194
+ ### `observeMedia(query)` -> `Signal<boolean>`
195
+
196
+ 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.
197
+
198
+ ### `documentVisible: Signal<boolean>`
199
+
200
+ Module-level signal. True iff `document.visibilityState === 'visible'`. Listener attached lazily on first read, detached on last unsubscribe.
201
+
202
+ ## Design notes
203
+
204
+ **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.
205
+
206
+ **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.
207
+
208
+ **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.
209
+
210
+ **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.
211
+
212
+ **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.
213
+
214
+ ## Benchmarks
215
+
216
+ 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.
217
+
218
+ Run: `node --expose-gc bench/lite-observe.bench.js` (Node 22.22, V8 12.4):
219
+
220
+ | path | ns/call | B/call | scavenges |
221
+ | --- | --- | --- | --- |
222
+ | `observeResize.dispatch` (64 elements) | ~17900 | 0.00 | 0 |
223
+ | `observeIntersection.dispatch` (64 elements) | ~17100 | 0.00 | 0 |
224
+ | `observeMutation.dispatch` (3 records) | ~116 | 0.00 | 0 |
225
+ | `observeMedia.change` (1 listener) | ~124 | 0.00 | 0 |
226
+
227
+ 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).
228
+
229
+ ## Edge cases & guarantees
230
+
231
+ - **Dispose is idempotent.** Calling `dispose()` twice is safe and does nothing on the second call.
232
+ - **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.
233
+ - **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.
234
+ - **The first emission is async.** Both `ResizeObserver` and `IntersectionObserver` deliver their initial observation in a future task. Signals read as `0` / `false` until then.
235
+ - **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.
236
+ - **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.
237
+ - **`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.
238
+ - **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.
239
+ - **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.
240
+ - **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.
241
+ - **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.
242
+ - **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.
243
+
244
+ ## Roadmap
245
+
246
+ Items deferred from 1.0.0, grouped by tier. Marks intent, not promises; honest scoping happens at each version cut.
247
+
248
+ ### 1.1.x -- additive surface
249
+
250
+ - **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.
251
+ - **Adaptive `IntersectionObserver` threshold.** Expose `handle.setThreshold(thresholds)` so consumers can refine the threshold list without recreating the observer.
252
+ - **`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.
253
+ - **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.
254
+
255
+ ### 1.2.x -- ergonomics and developer experience
256
+
257
+ - **`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.
258
+ - **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.
259
+ - **`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.
260
+
261
+ ### 2.0.x -- breaking-change tier
262
+
263
+ - **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.
264
+ - **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.
265
+ - **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.
266
+
267
+ ### Not planned
268
+
269
+ - **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`.
270
+ - **`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.
271
+ - **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.
272
+
273
+ ## Browser & runtime support
274
+
275
+ 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.
276
+
277
+ ## Testing
278
+
279
+ - **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.
280
+ - **Memory (`--expose-gc`).** `npm run test:gc` additionally exercises the `FinalizationRegistry` orphan-cleanup tests, which skip gracefully without the flag.
281
+ - **Allocation gate.** `npm run bench` fails if any dispatch path shows a scavenge or > 1 byte/call (see [Benchmarks](#benchmarks)).
282
+ - **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.
283
+
284
+ ## npm scripts
285
+
286
+ | script | what it does |
287
+ | --- | --- |
288
+ | `test` | `node --test test/*.test.js` -- unit suite against fakes |
289
+ | `test:gc` | as above, with `--expose-gc` for the finalization tests |
290
+ | `test:browser` | serve the browser smoke page |
291
+ | `demo` | serve the interactive demo (`demo/index.html`) |
292
+ | `bench` | `node --expose-gc bench/lite-observe.bench.js` -- allocation gate |
293
+ | `verify` | `npm test && npm run bench` |
294
+
295
+ ## Ecosystem
296
+
297
+ `lite-observe` is part of the `@zakkster/lite-*` family of zero-GC, zero-dependency ESM micro-libraries built on `@zakkster/lite-signal`:
298
+
299
+ - [`@zakkster/lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal) -- the reactive core (peer dependency).
300
+ - `@zakkster/lite-floating` -- anchored-positioning primitives; its RO-driven update loop defers via `requestAnimationFrame`, the pattern recommended under [Edge cases](#edge-cases--guarantees).
301
+ - `@zakkster/lite-signal-dom`, `@zakkster/lite-router`, `@zakkster/lite-persist`, and others compose the same signal model.
302
+
303
+ ## License
304
+
305
+ MIT (c) Zahary Shinikchiev.