@zakkster/lite-observe 1.0.2 → 1.0.3
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 +45 -0
- package/README.md +2 -0
- package/llms.txt +6 -1
- 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 +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.3 -- Batched paired writes + peer range bump
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Peer range bumped to `^1.5.0` (B-3).** The source is written against the
|
|
8
|
+
modern callable surface and the synchronous flush semantics the batch fix
|
|
9
|
+
depends on; the old `^1.2.2` advertised a line that predates both the pooled
|
|
10
|
+
registry and that flush behaviour. README and `llms.txt` now state that
|
|
11
|
+
effects observing a lite-observe handle flush synchronously on the observer
|
|
12
|
+
callback.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Paired signal writes are now batched (B-1).** Each observer delivery
|
|
17
|
+
writes a logically-atomic pair -- `width` + `height` (Resize),
|
|
18
|
+
`isIntersecting` + `ratio` (Intersection), `added` + `removed`
|
|
19
|
+
(MutationSelector) -- as two independent `set()` calls. Because lite-signal
|
|
20
|
+
(>= 1.4) flushes effects synchronously in the writing call stack (unlike the
|
|
21
|
+
microtask-deferred libraries that coalesced these invisibly), a consumer
|
|
22
|
+
effect reading both halves ran twice per observation, the first run seeing
|
|
23
|
+
the new value paired with a stale one. Every pair is now wrapped in
|
|
24
|
+
lite-signal `batch()` so the effect wakes once per delivery on a consistent
|
|
25
|
+
state. Resize and Intersection batch the whole delivery, so N observed
|
|
26
|
+
elements in one callback flush once, not 2N times.
|
|
27
|
+
- **Zero-alloc preserved.** The batching uses the hoisted stable-function +
|
|
28
|
+
module/handle-scope slot pattern (`pendingEntries` / `pendingBucket` /
|
|
29
|
+
`pendA` / `pendR` parked for a stable `drainResize` / `drainIntersection` /
|
|
30
|
+
`commitPair`); no per-fire closure is minted. Slots are captured and nulled
|
|
31
|
+
before the drain loop so a throwing consumer effect cannot retain the
|
|
32
|
+
browser-owned entries array. Allocation gate unchanged: 0 B/call, 0
|
|
33
|
+
scavenges on all dispatch paths.
|
|
34
|
+
- **observe\* handles now dispose their lite-signal nodes on final teardown.**
|
|
35
|
+
On the last dispose of a refcounted slot (Resize width/height + border pair,
|
|
36
|
+
Intersection `isIntersecting` + `ratio`, Mutation `tick`) -- and
|
|
37
|
+
unconditionally for MutationSelector's per-handle `added` / `removed` -- the
|
|
38
|
+
slot's `signal()` nodes are now released via lite-signal `dispose()`. Prior
|
|
39
|
+
to this, each observe/dispose cycle tore down the observer and Map slot but
|
|
40
|
+
leaked the pooled signal nodes, permanently retaining them (+4 Resize, +2
|
|
41
|
+
Intersection, +1 Mutation, +2 MutationSelector per cycle) and throwing
|
|
42
|
+
`CapacityError` under a capped registry. Disposal happens only when the last
|
|
43
|
+
consumer leaves, so shared consumers are unaffected; a retained handle's read
|
|
44
|
+
returns undefined and `set()` is inert (gen guard), keeping it
|
|
45
|
+
fail-closed-safe. Mutation also nulls its retained browser records
|
|
46
|
+
(`lastRecords = EMPTY`) so the final batch is not pinned by the dead slot.
|
|
47
|
+
|
|
3
48
|
## 1.0.1 -- Internal: delegate to @zakkster/lite-cleanup
|
|
4
49
|
|
|
5
50
|
### 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
|
|
|
@@ -232,6 +233,7 @@ The headline figure is **B/call and scavenges, not nanoseconds.** Zero bytes and
|
|
|
232
233
|
- **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
234
|
- **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
235
|
- **The first emission is async.** Both `ResizeObserver` and `IntersectionObserver` deliver their initial observation in a future task. Signals read as `0` / `false` until then.
|
|
236
|
+
- **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
237
|
- **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
238
|
- **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
239
|
- **`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
|
@@ -73,6 +73,11 @@ registry: N consumers of the same element share one underlying observer.
|
|
|
73
73
|
- Indexed for-loops in callbacks, no iterator allocation. Closures
|
|
74
74
|
pre-allocated at registration, not per callback. Library-side allocations
|
|
75
75
|
occur only at registration time.
|
|
76
|
+
- Effects observing a handle flush SYNCHRONOUSLY on the observer callback
|
|
77
|
+
(unlike microtask-deferred signal libraries). Each delivery's paired writes
|
|
78
|
+
(width+height, isIntersecting+ratio, added+removed) are wrapped in one
|
|
79
|
+
batch(), so a consumer effect reading both halves wakes once per delivery on
|
|
80
|
+
a consistent state, never on a torn intermediate.
|
|
76
81
|
- Orphan-handle cleanup via shared FinalizationRegistry. Handles from
|
|
77
82
|
observeResize / observeIntersection / observeMutation are registered;
|
|
78
83
|
dropping the handle without dispose() triggers cleanup on the next GC
|
|
@@ -83,4 +88,4 @@ registry: N consumers of the same element share one underlying observer.
|
|
|
83
88
|
|
|
84
89
|
## Peer dependency
|
|
85
90
|
|
|
86
|
-
@zakkster/lite-signal ^1.
|
|
91
|
+
@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.0.3",
|
|
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();
|
|
@@ -41,7 +46,11 @@ const slots = new Map();
|
|
|
41
46
|
*
|
|
42
47
|
* @param {ResizeObserverEntry[]} entries
|
|
43
48
|
*/
|
|
44
|
-
function
|
|
49
|
+
function drainResize() {
|
|
50
|
+
// Capture and null the slot BEFORE the loop: if a consumer effect throws
|
|
51
|
+
// mid-drain, we must not retain the browser-owned entries array.
|
|
52
|
+
const entries = pendingEntries;
|
|
53
|
+
pendingEntries = null;
|
|
45
54
|
for (let i = 0; i < entries.length; i++) {
|
|
46
55
|
const entry = entries[i];
|
|
47
56
|
const slot = slots.get(entry.target);
|
|
@@ -71,6 +80,14 @@ function dispatch(entries) {
|
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
|
|
83
|
+
// Batch the whole delivery so N observed elements flush once, not 2N times.
|
|
84
|
+
// `drainResize` is a stable module-level reference; parking `entries` in the
|
|
85
|
+
// module slot keeps this callback closure-free per fire (zero alloc).
|
|
86
|
+
function dispatch(entries) {
|
|
87
|
+
pendingEntries = entries;
|
|
88
|
+
batch(drainResize);
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
function ensureObserver() {
|
|
75
92
|
if (observerInstance !== null) return observerInstance;
|
|
76
93
|
if (typeof ResizeObserver === 'undefined') return null;
|
|
@@ -144,6 +161,13 @@ export function observeResize(element, options) {
|
|
|
144
161
|
observerInstance.disconnect();
|
|
145
162
|
observerInstance = null;
|
|
146
163
|
}
|
|
164
|
+
// Last consumer left: free the slot's pooled signal nodes so an
|
|
165
|
+
// 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);
|
|
147
171
|
}
|
|
148
172
|
}
|
|
149
173
|
|