@zakkster/lite-observe 1.0.3 → 1.1.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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 -- Lazy resize box-pair allocation + pooled-node census docs
4
+
5
+ ### Changed
6
+
7
+ - **`observeResize` allocates only the box pair the first caller asks for
8
+ (B-2).** Previously every `observeResize` slot minted four signal nodes
9
+ (content width/height + border width/height) regardless of the `box`
10
+ requested. It now mints only the requested pair -- 2 nodes in the common
11
+ case -- and lazily adds the other pair only when a mixed-box consumer asks
12
+ for it on the same element (then 4). Shared-slot refcounting and mixed-box
13
+ cooperation are unchanged: a content consumer and a border consumer on one
14
+ element still share a single underlying `ResizeObserver` observation and
15
+ each reads its own pair. `drainResize` null-guards each pair write and stays
16
+ zero-allocation; `dispose` frees only the pairs that were minted. The slot
17
+ literal keeps all four fields present at birth (initialized `null`) so the
18
+ hot-path hidden class stays stable. Backward-compatible: the handle surface
19
+ and the shared-observation contract are unchanged; only the pooled-node
20
+ footprint of a single-box consumer drops from 4 to 2.
21
+
22
+ ### Documentation
23
+
24
+ - **Pooled-node cost documented per entry point (B-2).** README and `llms.txt`
25
+ now state the steady-state (while-observed) pooled signal-node cost next to
26
+ every entry point: `observeResize` 2 (4 mixed-box), `observeIntersection` 2
27
+ per (element, options-key), `observeMutation` 1, `observeMutationSelector` 4
28
+ (added + removed + inner tick + effect), `observeMedia` 1 per query string,
29
+ `documentVisible` 1 module-scope. All are freed on final dispose. A new
30
+ "Pooled-registry census" note in Design notes explains that a consumer
31
+ sizing a capped registry via `createRegistry({ maxNodes, prealloc,
32
+ onCapacityExceeded })` must sum these counts over concurrently observed
33
+ elements/keys/queries; guessing low throws `CapacityError` from inside the
34
+ library.
35
+ - **`observeMedia` documented as the reactive replacement for one-shot
36
+ `matchMedia` reads.** A README line notes that
37
+ `observeMedia('(prefers-reduced-motion: reduce)')` (and similar) is the
38
+ reactive answer to hand-rolled `matchMedia` reads that never update when the
39
+ OS setting changes mid-session.
40
+
3
41
  ## 1.0.3 -- Batched paired writes + peer range bump
4
42
 
5
43
  ### Changed
package/README.md CHANGED
@@ -148,6 +148,8 @@ Width and height are independent fine-grained signals. N consumers of the same e
148
148
 
149
149
  `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.
150
150
 
151
+ Pooled signal nodes: 2 while observed (the requested box pair; 4 if both content- and border-box consumers observe the same element). Freed on last dispose.
152
+
151
153
  ### `observeIntersection(element, options?)` -> `IntersectionHandle`
152
154
 
153
155
  ```ts
@@ -160,6 +162,8 @@ interface IntersectionHandle {
160
162
 
161
163
  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.
162
164
 
165
+ Pooled signal nodes: 2 per (element, options-key) while observed (`isIntersecting` + `ratio`). Freed on last dispose.
166
+
163
167
  ### `observeMutation(element, options)` -> `MutationHandle`
164
168
 
165
169
  ```ts
@@ -174,6 +178,8 @@ Mutations are events, not state: surfacing the latest `MutationRecord[]` as a si
174
178
 
175
179
  Consumers asking for the same element with the same option set share one observer; differing options spawn distinct observers.
176
180
 
181
+ Pooled signal nodes: 1 while observed (the `tick`). Freed on last dispose.
182
+
177
183
  ### `observeMutationSelector(root, selector, options?)` -> `MutationSelectorHandle`
178
184
 
179
185
  ```ts
@@ -192,14 +198,22 @@ With `subtree: true`, the walk also runs `querySelectorAll(selector)` against ad
192
198
 
193
199
  **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.
194
200
 
201
+ Pooled signal nodes: 4 while observed (`added` + `removed`, plus the inner `observeMutation` tick and one effect). Freed on last dispose.
202
+
195
203
  ### `observeMedia(query)` -> `Signal<boolean>`
196
204
 
197
205
  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.
198
206
 
207
+ `observeMedia(query)` is the reactive replacement for hand-rolled one-shot `matchMedia('(prefers-reduced-motion: reduce)')` reads, which never update when the OS setting changes mid-session -- likely the single most common reason to reach for this package.
208
+
209
+ Pooled signal nodes: 1, shared per query string. Freed on last dispose.
210
+
199
211
  ### `documentVisible: Signal<boolean>`
200
212
 
201
213
  Module-level signal. True iff `document.visibilityState === 'visible'`. Listener attached lazily on first read, detached on last unsubscribe.
202
214
 
215
+ Pooled signal nodes: 1, module-scope (lives for the module's lifetime).
216
+
203
217
  ## Design notes
204
218
 
205
219
  **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.
@@ -212,6 +226,8 @@ Module-level signal. True iff `document.visibilityState === 'visible'`. Listener
212
226
 
213
227
  **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.
214
228
 
229
+ **Pooled-registry census.** A consumer running `createRegistry({ maxNodes, prealloc, onCapacityExceeded })` (lite-signal 1.5.0) must size the pool from a census of every signal the app allocates. A library minting signals on the default registry is part of that census -- lite-observe is no exception. The per-entry-point counts above (2 per observed element for resize, up to 4 mixed-box; 2 per intersection key; 1 per mutation; 4 per mutation-selector; 1 per media query; 1 module-scope visibility) are the inputs to that sum: add the steady-state count for each entry point times the number of elements/keys/queries observed concurrently. These are steady-state (while-observed) costs, freed on final dispose, so the census is of peak concurrency, not lifetime turnover. Guessing low throws `CapacityError` at runtime from inside the library.
230
+
215
231
  ## Benchmarks
216
232
 
217
233
  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.
package/llms.txt CHANGED
@@ -13,15 +13,19 @@ registry: N consumers of the same element share one underlying observer.
13
13
  selects `'content'` (default, contentRect) or `'border'` (borderBoxSize,
14
14
  matches getBoundingClientRect). Mixed-box consumers of one element still
15
15
  share a single observation.
16
+ Pooled nodes: 2 while observed (the requested box pair; 4 if both content-
17
+ and border-box consumers observe the same element). Freed on last dispose.
16
18
 
17
19
  - `observeIntersection(el, options?)` -> `{ isIntersecting: Signal<boolean>, ratio: Signal<number>, dispose }`
18
20
  Observers keyed by (root, rootMargin, threshold). Equivalent options
19
21
  share one observer. Different options spawn distinct ones (per spec).
22
+ Pooled nodes: 2 per (element, options-key) while observed. Freed on last dispose.
20
23
 
21
24
  - `observeMutation(el, options)` -> `{ tick: Signal<number>, peekRecords(), dispose }`
22
25
  Tick is a monotonic counter; increments per delivered batch.
23
26
  peekRecords() returns the latest MutationRecord[] without tracking and
24
27
  without copying. One observer per (element, optionsKey).
28
+ Pooled nodes: 1 while observed (the tick). Freed on last dispose.
25
29
 
26
30
  - `observeMutationSelector(root, selector, options?)` -> `{ added: Signal<Element[]>, removed: Signal<Element[]>, tick: Signal<number>, peekRecords(), dispose }`
27
31
  Selector-filtered wrapper over observeMutation. Surfaces matching elements
@@ -31,14 +35,17 @@ registry: N consumers of the same element share one underlying observer.
31
35
  container are caught. added/removed allocate fresh arrays only on batches
32
36
  with a match (so consumers may retain them); zero-match batches reuse a
33
37
  frozen empty array. Composes the inner handle's FinalizationRegistry safety.
38
+ Pooled nodes: 4 while observed (added + removed, plus the inner
39
+ observeMutation tick and one effect). Freed on last dispose.
34
40
 
35
41
  - `observeMedia(query)` -> `Signal<boolean>`
36
42
  One signal per query string, cached. Listener attached lazily via
37
43
  lite-signal observeObservers; detached when no consumer is reading.
44
+ Pooled nodes: 1, shared per query string. Freed on last dispose.
38
45
 
39
46
  - `documentVisible: Signal<boolean>`
40
47
  Module-level. True iff document.visibilityState === 'visible'.
41
- Lazy listener.
48
+ Lazy listener. Pooled nodes: 1, module-scope.
42
49
 
43
50
  ## Orphan-safety utility (public)
44
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-observe",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
5
  "description": "Zero-GC reactive bridge for the DOM observer APIs. ResizeObserver, IntersectionObserver, MutationObserver, matchMedia, and Page Visibility collapsed to fine-grained lite-signal signals. Shared registry: N components observing the same element pay one observer cost.",
6
6
  "type": "module",
package/src/Resize.js CHANGED
@@ -37,12 +37,13 @@ const slots = new Map();
37
37
  *
38
38
  * `entries` is allocated by the browser; we cannot avoid that. We iterate
39
39
  * with an indexed for-loop (no iterator allocation) and look up each entry's
40
- * target in the slot map. Each slot carries four signals (content width/
41
- * height and border width/height); we write all four every fire. Setting
42
- * a signal to its current value is a no-op under lite-signal's Object.is
43
- * equality, so consumers reading only one pair do not wake when the other
44
- * pair moved -- and consumers reading only `width` do not wake when only
45
- * `height` moved.
40
+ * target in the slot map. A slot carries only the box pair(s) its consumers
41
+ * asked for (content width/height and/or border width/height); each pair is
42
+ * written only when present (null-guarded), so an element observed for
43
+ * content-box alone never mints or writes border-box nodes. Setting a signal
44
+ * to its current value is a no-op under lite-signal's Object.is equality, so
45
+ * consumers reading only one pair do not wake when the other pair moved -- and
46
+ * consumers reading only `width` do not wake when only `height` moved.
46
47
  *
47
48
  * @param {ResizeObserverEntry[]} entries
48
49
  */
@@ -55,12 +56,16 @@ function drainResize() {
55
56
  const entry = entries[i];
56
57
  const slot = slots.get(entry.target);
57
58
  if (slot === undefined) continue;
59
+ // rect is a field read, not an allocation. Hoisted here because the
60
+ // border fallback reads it too; content-box writes use it directly.
61
+ const rect = entry.contentRect;
58
62
  // Content box: the historical default. Matches the rect a consumer
59
63
  // would derive from `getBoundingClientRect` after subtracting
60
- // padding + border.
61
- const rect = entry.contentRect;
62
- slot.contentWidth.set(rect.width);
63
- slot.contentHeight.set(rect.height);
64
+ // padding + border. Written only if a content-box consumer minted it.
65
+ if (slot.contentWidth !== null) {
66
+ slot.contentWidth.set(rect.width);
67
+ slot.contentHeight.set(rect.height);
68
+ }
64
69
  // Border box: includes padding + border. Per spec the field is an
65
70
  // array (to support multi-column layout); we read the first entry.
66
71
  // Older browsers without borderBoxSize fall back to contentRect
@@ -68,14 +73,17 @@ function drainResize() {
68
73
  // next layout. For zero-alloc cleanliness we just mirror content
69
74
  // here when borderBoxSize is absent; consumers who specifically
70
75
  // requested border-box on a legacy runtime will see content-box
71
- // values until the next layout pass.
72
- const bbs = entry.borderBoxSize;
73
- if (bbs && bbs.length > 0) {
74
- slot.borderWidth.set(bbs[0].inlineSize);
75
- slot.borderHeight.set(bbs[0].blockSize);
76
- } else {
77
- slot.borderWidth.set(rect.width);
78
- slot.borderHeight.set(rect.height);
76
+ // values until the next layout pass. Written only if a border-box
77
+ // consumer minted the pair.
78
+ if (slot.borderWidth !== null) {
79
+ const bbs = entry.borderBoxSize;
80
+ if (bbs && bbs.length > 0) {
81
+ slot.borderWidth.set(bbs[0].inlineSize);
82
+ slot.borderHeight.set(bbs[0].blockSize);
83
+ } else {
84
+ slot.borderWidth.set(rect.width);
85
+ slot.borderHeight.set(rect.height);
86
+ }
79
87
  }
80
88
  }
81
89
  }
@@ -129,17 +137,27 @@ export function observeResize(element, options) {
129
137
  const box = (options && options.box === 'border') ? 'border' : 'content';
130
138
  let slot = slots.get(element);
131
139
  if (slot === undefined) {
140
+ // Keep all four fields present for hidden-class stability, but leave
141
+ // the unrequested pair null: we mint only the box a caller asks for.
132
142
  slot = {
133
- contentWidth: signal(0),
134
- contentHeight: signal(0),
135
- borderWidth: signal(0),
136
- borderHeight: signal(0),
143
+ contentWidth: null,
144
+ contentHeight: null,
145
+ borderWidth: null,
146
+ borderHeight: null,
137
147
  refCount: 0
138
148
  };
139
149
  slots.set(element, slot);
140
150
  const obs = ensureObserver();
141
151
  if (obs !== null) obs.observe(element);
142
152
  }
153
+ // Lazily mint the requested box pair if absent. Placed before refCount++
154
+ // so it also covers a later mixed-box caller joining an existing slot that
155
+ // only holds the other pair.
156
+ if (box === 'border') {
157
+ if (slot.borderWidth === null) { slot.borderWidth = signal(0); slot.borderHeight = signal(0); }
158
+ } else {
159
+ if (slot.contentWidth === null) { slot.contentWidth = signal(0); slot.contentHeight = signal(0); }
160
+ }
143
161
  slot.refCount++;
144
162
 
145
163
  let disposed = false;
@@ -163,11 +181,10 @@ export function observeResize(element, options) {
163
181
  }
164
182
  // Last consumer left: free the slot's pooled signal nodes so an
165
183
  // observe/dispose cycle does not permanently retain them (which
166
- // throws CapacityError under a capped registry).
167
- disposeNode(slot.contentWidth);
168
- disposeNode(slot.contentHeight);
169
- disposeNode(slot.borderWidth);
170
- disposeNode(slot.borderHeight);
184
+ // throws CapacityError under a capped registry). Only the pairs
185
+ // that were actually minted exist to free.
186
+ if (slot.contentWidth !== null) { disposeNode(slot.contentWidth); disposeNode(slot.contentHeight); }
187
+ if (slot.borderWidth !== null) { disposeNode(slot.borderWidth); disposeNode(slot.borderHeight); }
171
188
  }
172
189
  }
173
190