@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 +83 -0
- package/README.md +18 -0
- package/llms.txt +14 -2
- package/package.json +2 -2
- package/src/Intersection.js +27 -5
- package/src/Mutation.js +5 -1
- package/src/MutationSelector.js +21 -3
- package/src/Resize.js +65 -24
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
|
[](https://www.npmjs.com/package/@zakkster/lite-observe)
|
|
8
8
|
[](https://www.npmjs.com/package/@zakkster/lite-observe)
|
|
9
9
|
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
10
|
+

|
|
10
11
|

|
|
11
12
|
[](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.
|
|
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
|
|
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.
|
|
56
|
+
"@zakkster/lite-signal": "^1.5.0"
|
|
57
57
|
},
|
|
58
58
|
"publishConfig": {
|
|
59
59
|
"access": "public"
|
package/src/Intersection.js
CHANGED
|
@@ -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
|
-
*
|
|
67
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/MutationSelector.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
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.
|
|
36
|
-
* height and border width/height);
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
117
|
-
contentHeight:
|
|
118
|
-
borderWidth:
|
|
119
|
-
borderHeight:
|
|
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
|
|