@zakkster/lite-observe 1.0.2 → 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,88 @@
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
+
41
+ ## 1.0.3 -- Batched paired writes + peer range bump
42
+
43
+ ### Changed
44
+
45
+ - **Peer range bumped to `^1.5.0` (B-3).** The source is written against the
46
+ modern callable surface and the synchronous flush semantics the batch fix
47
+ depends on; the old `^1.2.2` advertised a line that predates both the pooled
48
+ registry and that flush behaviour. README and `llms.txt` now state that
49
+ effects observing a lite-observe handle flush synchronously on the observer
50
+ callback.
51
+
52
+ ### Fixed
53
+
54
+ - **Paired signal writes are now batched (B-1).** Each observer delivery
55
+ writes a logically-atomic pair -- `width` + `height` (Resize),
56
+ `isIntersecting` + `ratio` (Intersection), `added` + `removed`
57
+ (MutationSelector) -- as two independent `set()` calls. Because lite-signal
58
+ (>= 1.4) flushes effects synchronously in the writing call stack (unlike the
59
+ microtask-deferred libraries that coalesced these invisibly), a consumer
60
+ effect reading both halves ran twice per observation, the first run seeing
61
+ the new value paired with a stale one. Every pair is now wrapped in
62
+ lite-signal `batch()` so the effect wakes once per delivery on a consistent
63
+ state. Resize and Intersection batch the whole delivery, so N observed
64
+ elements in one callback flush once, not 2N times.
65
+ - **Zero-alloc preserved.** The batching uses the hoisted stable-function +
66
+ module/handle-scope slot pattern (`pendingEntries` / `pendingBucket` /
67
+ `pendA` / `pendR` parked for a stable `drainResize` / `drainIntersection` /
68
+ `commitPair`); no per-fire closure is minted. Slots are captured and nulled
69
+ before the drain loop so a throwing consumer effect cannot retain the
70
+ browser-owned entries array. Allocation gate unchanged: 0 B/call, 0
71
+ scavenges on all dispatch paths.
72
+ - **observe\* handles now dispose their lite-signal nodes on final teardown.**
73
+ On the last dispose of a refcounted slot (Resize width/height + border pair,
74
+ Intersection `isIntersecting` + `ratio`, Mutation `tick`) -- and
75
+ unconditionally for MutationSelector's per-handle `added` / `removed` -- the
76
+ slot's `signal()` nodes are now released via lite-signal `dispose()`. Prior
77
+ to this, each observe/dispose cycle tore down the observer and Map slot but
78
+ leaked the pooled signal nodes, permanently retaining them (+4 Resize, +2
79
+ Intersection, +1 Mutation, +2 MutationSelector per cycle) and throwing
80
+ `CapacityError` under a capped registry. Disposal happens only when the last
81
+ consumer leaves, so shared consumers are unaffected; a retained handle's read
82
+ returns undefined and `set()` is inert (gen guard), keeping it
83
+ fail-closed-safe. Mutation also nulls its retained browser records
84
+ (`lastRecords = EMPTY`) so the final batch is not pinned by the dead slot.
85
+
3
86
  ## 1.0.1 -- Internal: delegate to @zakkster/lite-cleanup
4
87
 
5
88
  ### Changed
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
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
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
9
  [![lite-signal peer](https://img.shields.io/badge/peer-lite--signal-blue?style=for-the-badge)](https://github.com/PeshoVurtoleta/lite-signal)
10
+ ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
10
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
12
13
 
@@ -147,6 +148,8 @@ Width and height are independent fine-grained signals. N consumers of the same e
147
148
 
148
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.
149
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
+
150
153
  ### `observeIntersection(element, options?)` -> `IntersectionHandle`
151
154
 
152
155
  ```ts
@@ -159,6 +162,8 @@ interface IntersectionHandle {
159
162
 
160
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.
161
164
 
165
+ Pooled signal nodes: 2 per (element, options-key) while observed (`isIntersecting` + `ratio`). Freed on last dispose.
166
+
162
167
  ### `observeMutation(element, options)` -> `MutationHandle`
163
168
 
164
169
  ```ts
@@ -173,6 +178,8 @@ Mutations are events, not state: surfacing the latest `MutationRecord[]` as a si
173
178
 
174
179
  Consumers asking for the same element with the same option set share one observer; differing options spawn distinct observers.
175
180
 
181
+ Pooled signal nodes: 1 while observed (the `tick`). Freed on last dispose.
182
+
176
183
  ### `observeMutationSelector(root, selector, options?)` -> `MutationSelectorHandle`
177
184
 
178
185
  ```ts
@@ -191,14 +198,22 @@ With `subtree: true`, the walk also runs `querySelectorAll(selector)` against ad
191
198
 
192
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.
193
200
 
201
+ Pooled signal nodes: 4 while observed (`added` + `removed`, plus the inner `observeMutation` tick and one effect). Freed on last dispose.
202
+
194
203
  ### `observeMedia(query)` -> `Signal<boolean>`
195
204
 
196
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.
197
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
+
198
211
  ### `documentVisible: Signal<boolean>`
199
212
 
200
213
  Module-level signal. True iff `document.visibilityState === 'visible'`. Listener attached lazily on first read, detached on last unsubscribe.
201
214
 
215
+ Pooled signal nodes: 1, module-scope (lives for the module's lifetime).
216
+
202
217
  ## Design notes
203
218
 
204
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.
@@ -211,6 +226,8 @@ Module-level signal. True iff `document.visibilityState === 'visible'`. Listener
211
226
 
212
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.
213
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
+
214
231
  ## Benchmarks
215
232
 
216
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.
@@ -232,6 +249,7 @@ The headline figure is **B/call and scavenges, not nanoseconds.** Zero bytes and
232
249
  - **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
250
  - **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
251
  - **The first emission is async.** Both `ResizeObserver` and `IntersectionObserver` deliver their initial observation in a future task. Signals read as `0` / `false` until then.
252
+ - **Effects flush synchronously on the observer callback.** Unlike microtask-deferred signal libraries, an effect reading a lite-observe handle re-runs synchronously within the observer's own callback stack (lite-signal flushes in the writing call stack). The paired writes each delivery makes -- width + height, isIntersecting + ratio, added + removed -- are wrapped in a single `batch()` so a consumer effect reading both halves wakes exactly once per delivery, on a consistent state, never on a torn intermediate.
235
253
  - **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
254
  - **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
255
  - **`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.
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
 
@@ -73,6 +80,11 @@ registry: N consumers of the same element share one underlying observer.
73
80
  - Indexed for-loops in callbacks, no iterator allocation. Closures
74
81
  pre-allocated at registration, not per callback. Library-side allocations
75
82
  occur only at registration time.
83
+ - Effects observing a handle flush SYNCHRONOUSLY on the observer callback
84
+ (unlike microtask-deferred signal libraries). Each delivery's paired writes
85
+ (width+height, isIntersecting+ratio, added+removed) are wrapped in one
86
+ batch(), so a consumer effect reading both halves wakes once per delivery on
87
+ a consistent state, never on a torn intermediate.
76
88
  - Orphan-handle cleanup via shared FinalizationRegistry. Handles from
77
89
  observeResize / observeIntersection / observeMutation are registered;
78
90
  dropping the handle without dispose() triggers cleanup on the next GC
@@ -83,4 +95,4 @@ registry: N consumers of the same element share one underlying observer.
83
95
 
84
96
  ## Peer dependency
85
97
 
86
- @zakkster/lite-signal ^1.2.2
98
+ @zakkster/lite-signal ^1.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-observe",
3
- "version": "1.0.2",
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",
@@ -53,7 +53,7 @@
53
53
  "@zakkster/lite-cleanup": "^1.0.0"
54
54
  },
55
55
  "peerDependencies": {
56
- "@zakkster/lite-signal": "^1.2.2"
56
+ "@zakkster/lite-signal": "^1.5.0"
57
57
  },
58
58
  "publishConfig": {
59
59
  "access": "public"
@@ -17,7 +17,7 @@
17
17
  * visibility do not wake on ratio changes when the boolean is unchanged.
18
18
  */
19
19
 
20
- import { signal } from '@zakkster/lite-signal';
20
+ import { signal, batch, dispose as disposeNode } from '@zakkster/lite-signal';
21
21
  import { attachFinalizer } from './_finalize.js';
22
22
 
23
23
  /**
@@ -59,14 +59,26 @@ function rootIdFor(root) {
59
59
  // optionsKey -> { observer, slots: Map<Element, slot>, options }
60
60
  const observersByKey = new Map();
61
61
 
62
+ // Delivery slots for the batched drain. The browser-owned `entries` array and
63
+ // the target bucket are parked here between the per-bucket observer callback
64
+ // and the stable `drainIntersection` reference, so the callback need not mint
65
+ // a per-fire closure to carry them into `batch`.
66
+ let pendingEntries = null;
67
+ let pendingBucket = null;
68
+
62
69
  /**
63
70
  * Dispatch path. `entries` and the per-callback `this` binding are
64
71
  * browser-allocated; we add nothing. Indexed for-loop, no iterator.
65
72
  *
66
- * @param {IntersectionObserverEntry[]} entries
67
- * @param {{ slots: Map<Element, any> }} bucket
73
+ * Batches the whole delivery so N observed elements flush once, not 2N times.
74
+ * Captures and nulls both slots BEFORE the loop: a consumer effect throwing
75
+ * mid-drain must not retain the browser-owned entries array or the bucket.
68
76
  */
69
- function dispatchIntersection(entries, bucket) {
77
+ function drainIntersection() {
78
+ const entries = pendingEntries;
79
+ const bucket = pendingBucket;
80
+ pendingEntries = null;
81
+ pendingBucket = null;
70
82
  const map = bucket.slots;
71
83
  for (let i = 0; i < entries.length; i++) {
72
84
  const entry = entries[i];
@@ -90,7 +102,13 @@ function ensureBucket(options) {
90
102
  }
91
103
  bucket = { observer: null, slots: new Map(), key };
92
104
  bucket.observer = new IntersectionObserver(
93
- function (entries) { dispatchIntersection(entries, bucket); },
105
+ // Cold closure: created once per bucket construction, not per delivery.
106
+ // It only parks the slots and hands the stable drain to `batch`.
107
+ function (entries) {
108
+ pendingEntries = entries;
109
+ pendingBucket = bucket;
110
+ batch(drainIntersection);
111
+ },
94
112
  options
95
113
  );
96
114
  observersByKey.set(key, bucket);
@@ -142,6 +160,10 @@ export function observeIntersection(element, options) {
142
160
  if (bucket.observer !== null) bucket.observer.disconnect();
143
161
  observersByKey.delete(bucket.key);
144
162
  }
163
+ // Last consumer left: free the slot's pooled signal nodes so an
164
+ // observe/dispose cycle does not permanently retain them.
165
+ disposeNode(slot.isIntersecting);
166
+ disposeNode(slot.ratio);
145
167
  }
146
168
  }
147
169
 
package/src/Mutation.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * the same options share; differing options get a distinct observer.
22
22
  */
23
23
 
24
- import { signal } from '@zakkster/lite-signal';
24
+ import { signal, dispose as disposeNode } from '@zakkster/lite-signal';
25
25
  import { attachFinalizer } from './_finalize.js';
26
26
 
27
27
  function optionsKey(options) {
@@ -130,6 +130,10 @@ export function observeMutation(element, options) {
130
130
  if (inner.size === 0) {
131
131
  slotsByElement.delete(element);
132
132
  }
133
+ // Last consumer left: free the slot's pooled tick node and drop
134
+ // the retained browser records so the final batch isn't pinned.
135
+ disposeNode(slot.tick);
136
+ slot.lastRecords = EMPTY;
133
137
  }
134
138
  }
135
139
 
@@ -30,7 +30,7 @@
30
30
  * the inner handle too.
31
31
  */
32
32
 
33
- import { signal, effect, dispose as disposeEffect } from '@zakkster/lite-signal';
33
+ import { signal, effect, batch, dispose as disposeEffect } from '@zakkster/lite-signal';
34
34
  import { observeMutation } from './Mutation.js';
35
35
 
36
36
  const EMPTY_ARR = Object.freeze([]);
@@ -62,6 +62,16 @@ export function observeMutationSelector(root, selector, options) {
62
62
  const addedSig = signal(EMPTY_ARR);
63
63
  const removedSig = signal(EMPTY_ARR);
64
64
 
65
+ // Delivery slots for the batched pair-write. Parking the arrays here lets
66
+ // `commitPair` stay a single stable handle-scope reference (created once,
67
+ // cold), so no per-tick closure is minted to carry them into `batch`.
68
+ let pendA = null;
69
+ let pendR = null;
70
+ function commitPair() {
71
+ addedSig.set(pendA !== null ? pendA : EMPTY_ARR);
72
+ removedSig.set(pendR !== null ? pendR : EMPTY_ARR);
73
+ }
74
+
65
75
  const inner = observeMutation(root, opts);
66
76
 
67
77
  // Effect: every tick of the inner handle, walk the latest batch and
@@ -114,8 +124,11 @@ export function observeMutationSelector(root, selector, options) {
114
124
  }
115
125
  }
116
126
 
117
- addedSig.set(added !== null ? added : EMPTY_ARR);
118
- removedSig.set(removed !== null ? removed : EMPTY_ARR);
127
+ // Batch the atomic added/removed pair so a consumer effect reading
128
+ // both wakes once on a consistent state, not twice on a torn one.
129
+ pendA = added;
130
+ pendR = removed;
131
+ batch(commitPair);
119
132
  });
120
133
 
121
134
  let disposed = false;
@@ -124,6 +137,11 @@ export function observeMutationSelector(root, selector, options) {
124
137
  disposed = true;
125
138
  disposeEffect(fxHandle);
126
139
  inner.dispose();
140
+ // Per-handle signals (created once per call, not refcounted): free
141
+ // their pooled nodes on teardown. lite-signal `dispose` frees a
142
+ // signal node the same way it frees an effect handle.
143
+ disposeEffect(addedSig);
144
+ disposeEffect(removedSig);
127
145
  }
128
146
 
129
147
  return {
package/src/Resize.js CHANGED
@@ -16,13 +16,18 @@
16
16
  * created and stay at zero; observe is a no-op.
17
17
  */
18
18
 
19
- import { signal } from '@zakkster/lite-signal';
19
+ import { signal, batch, dispose as disposeNode } from '@zakkster/lite-signal';
20
20
  import { attachFinalizer } from './_finalize.js';
21
21
 
22
22
  // One singleton observer per page. Lazy: only constructed on first observe(),
23
23
  // which keeps the module SSR-safe and lets tests install a mock before use.
24
24
  let observerInstance = null;
25
25
 
26
+ // Delivery slot for the batched drain. The browser-owned `entries` array is
27
+ // parked here between the observer callback and the stable `drainResize`
28
+ // reference so we never mint a per-fire closure to carry it into `batch`.
29
+ let pendingEntries = null;
30
+
26
31
  // Element -> slot. A slot survives so long as refCount > 0. When it hits 0,
27
32
  // the element is unobserved and the slot is dropped from the map.
28
33
  const slots = new Map();
@@ -32,26 +37,35 @@ const slots = new Map();
32
37
  *
33
38
  * `entries` is allocated by the browser; we cannot avoid that. We iterate
34
39
  * with an indexed for-loop (no iterator allocation) and look up each entry's
35
- * target in the slot map. Each slot carries four signals (content width/
36
- * height and border width/height); we write all four every fire. Setting
37
- * a signal to its current value is a no-op under lite-signal's Object.is
38
- * equality, so consumers reading only one pair do not wake when the other
39
- * pair moved -- and consumers reading only `width` do not wake when only
40
- * `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.
41
47
  *
42
48
  * @param {ResizeObserverEntry[]} entries
43
49
  */
44
- function dispatch(entries) {
50
+ function drainResize() {
51
+ // Capture and null the slot BEFORE the loop: if a consumer effect throws
52
+ // mid-drain, we must not retain the browser-owned entries array.
53
+ const entries = pendingEntries;
54
+ pendingEntries = null;
45
55
  for (let i = 0; i < entries.length; i++) {
46
56
  const entry = entries[i];
47
57
  const slot = slots.get(entry.target);
48
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;
49
62
  // Content box: the historical default. Matches the rect a consumer
50
63
  // would derive from `getBoundingClientRect` after subtracting
51
- // padding + border.
52
- const rect = entry.contentRect;
53
- slot.contentWidth.set(rect.width);
54
- 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
+ }
55
69
  // Border box: includes padding + border. Per spec the field is an
56
70
  // array (to support multi-column layout); we read the first entry.
57
71
  // Older browsers without borderBoxSize fall back to contentRect
@@ -59,18 +73,29 @@ function dispatch(entries) {
59
73
  // next layout. For zero-alloc cleanliness we just mirror content
60
74
  // here when borderBoxSize is absent; consumers who specifically
61
75
  // requested border-box on a legacy runtime will see content-box
62
- // values until the next layout pass.
63
- const bbs = entry.borderBoxSize;
64
- if (bbs && bbs.length > 0) {
65
- slot.borderWidth.set(bbs[0].inlineSize);
66
- slot.borderHeight.set(bbs[0].blockSize);
67
- } else {
68
- slot.borderWidth.set(rect.width);
69
- 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
+ }
70
87
  }
71
88
  }
72
89
  }
73
90
 
91
+ // Batch the whole delivery so N observed elements flush once, not 2N times.
92
+ // `drainResize` is a stable module-level reference; parking `entries` in the
93
+ // module slot keeps this callback closure-free per fire (zero alloc).
94
+ function dispatch(entries) {
95
+ pendingEntries = entries;
96
+ batch(drainResize);
97
+ }
98
+
74
99
  function ensureObserver() {
75
100
  if (observerInstance !== null) return observerInstance;
76
101
  if (typeof ResizeObserver === 'undefined') return null;
@@ -112,17 +137,27 @@ export function observeResize(element, options) {
112
137
  const box = (options && options.box === 'border') ? 'border' : 'content';
113
138
  let slot = slots.get(element);
114
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.
115
142
  slot = {
116
- contentWidth: signal(0),
117
- contentHeight: signal(0),
118
- borderWidth: signal(0),
119
- borderHeight: signal(0),
143
+ contentWidth: null,
144
+ contentHeight: null,
145
+ borderWidth: null,
146
+ borderHeight: null,
120
147
  refCount: 0
121
148
  };
122
149
  slots.set(element, slot);
123
150
  const obs = ensureObserver();
124
151
  if (obs !== null) obs.observe(element);
125
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
+ }
126
161
  slot.refCount++;
127
162
 
128
163
  let disposed = false;
@@ -144,6 +179,12 @@ export function observeResize(element, options) {
144
179
  observerInstance.disconnect();
145
180
  observerInstance = null;
146
181
  }
182
+ // Last consumer left: free the slot's pooled signal nodes so an
183
+ // observe/dispose cycle does not permanently retain them (which
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); }
147
188
  }
148
189
  }
149
190