@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 +312 -0
- package/LICENSE.txt +21 -0
- package/README.md +305 -0
- package/index.d.ts +219 -0
- package/index.js +25 -0
- package/llms.txt +86 -0
- package/package.json +71 -0
- package/src/Intersection.js +169 -0
- package/src/Media.js +95 -0
- package/src/Mutation.js +153 -0
- package/src/MutationSelector.js +136 -0
- package/src/Resize.js +183 -0
- package/src/Visibility.js +40 -0
- package/src/_finalize.js +111 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-observe)
|
|
4
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
|
+

|
|
6
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-observe)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-observe)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-observe)
|
|
9
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
10
|
+

|
|
11
|
+
[](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<Element, slot>"]
|
|
114
|
+
REG --> SLOT["slot for el<br/>refCount: 3"]
|
|
115
|
+
SLOT --> SW["width: Signal<number>"]
|
|
116
|
+
SLOT --> SH["height: Signal<number>"]
|
|
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.
|