@zakkster/lite-observe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +312 -0
- package/LICENSE.txt +21 -0
- package/README.md +305 -0
- package/index.d.ts +219 -0
- package/index.js +25 -0
- package/llms.txt +86 -0
- package/package.json +71 -0
- package/src/Intersection.js +169 -0
- package/src/Media.js +95 -0
- package/src/Mutation.js +153 -0
- package/src/MutationSelector.js +136 -0
- package/src/Resize.js +183 -0
- package/src/Visibility.js +40 -0
- package/src/_finalize.js +111 -0
package/src/Mutation.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-observe -- MutationObserver bridge.
|
|
3
|
+
*
|
|
4
|
+
* MutationObserver is event-shaped, not state-shaped: records arrive in
|
|
5
|
+
* batches, are inherently transient (the browser doesn't retain them), and
|
|
6
|
+
* different consumers may want to inspect different aspects (added nodes,
|
|
7
|
+
* removed nodes, attribute changes). Modelling "the current mutation" as a
|
|
8
|
+
* signal is a category error.
|
|
9
|
+
*
|
|
10
|
+
* Instead we surface a monotonic `tick` signal that increments on every
|
|
11
|
+
* delivered batch and a peek-only accessor for the latest batch. Consumers
|
|
12
|
+
* reacting only need to subscribe to `tick`; consumers needing the records
|
|
13
|
+
* call `peekRecords()` inside their effect body. The records array is the
|
|
14
|
+
* browser-allocated MutationRecord[] -- no copy.
|
|
15
|
+
*
|
|
16
|
+
* Sharing model: MutationObserver instances accept distinct `.observe(el,
|
|
17
|
+
* opts)` calls, but the option set is keyed to the (observer, target) pair
|
|
18
|
+
* by the browser, and once observed you cannot change the options without
|
|
19
|
+
* disconnecting. Pragmatically we key sharing by (element, optionsKey) and
|
|
20
|
+
* use one observer per slot. Two consumers asking for the same element with
|
|
21
|
+
* the same options share; differing options get a distinct observer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { signal } from '@zakkster/lite-signal';
|
|
25
|
+
import { attachFinalizer } from './_finalize.js';
|
|
26
|
+
|
|
27
|
+
function optionsKey(options) {
|
|
28
|
+
// Stable serialisation of the option flags we accept. The set is small
|
|
29
|
+
// and finite, so direct concatenation beats JSON.stringify.
|
|
30
|
+
let k = '';
|
|
31
|
+
k += options.childList ? '1' : '0';
|
|
32
|
+
k += options.attributes ? '1' : '0';
|
|
33
|
+
k += options.characterData ? '1' : '0';
|
|
34
|
+
k += options.subtree ? '1' : '0';
|
|
35
|
+
k += options.attributeOldValue ? '1' : '0';
|
|
36
|
+
k += options.characterDataOldValue ? '1' : '0';
|
|
37
|
+
const filter = options.attributeFilter;
|
|
38
|
+
if (filter !== undefined) {
|
|
39
|
+
// Order matters semantically in the API; preserve consumer's order.
|
|
40
|
+
k += '|' + filter.join(',');
|
|
41
|
+
}
|
|
42
|
+
return k;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// element -> Map<optionsKey, slot>. WeakMap so detached elements can be GC'd
|
|
46
|
+
// when consumers properly dispose -- and, critically, when they DON'T, the
|
|
47
|
+
// engine's spec-defined observer-retains-target semantics are the only thing
|
|
48
|
+
// keeping the element alive, rather than our own ad-hoc bookkeeping.
|
|
49
|
+
const slotsByElement = new WeakMap();
|
|
50
|
+
|
|
51
|
+
// The previous design held a parallel `Set<Element>` to support deterministic
|
|
52
|
+
// enumeration in `_resetMutation` (WeakMap is not enumerable). That Set's
|
|
53
|
+
// strong references completely defeated the WeakMap's GC behaviour: every
|
|
54
|
+
// element ever observed lived until manual cleanup, even if the consumer had
|
|
55
|
+
// dropped the element from the DOM and forgotten to dispose.
|
|
56
|
+
//
|
|
57
|
+
// The current design tracks the lightweight library objects (the
|
|
58
|
+
// MutationObserver instances) instead. This isn't a full fix for the
|
|
59
|
+
// forgot-to-dispose case -- both WebKit (HashSet<Ref<Node>>) and Chromium
|
|
60
|
+
// (HeapVector<Member<Node>>) implementations of MutationObserver retain
|
|
61
|
+
// strong references to their observed nodes internally, so a leaked
|
|
62
|
+
// observer keeps its target alive regardless. But it removes the explicit
|
|
63
|
+
// strong-ref Set from our code, makes the disposal path symmetric, and lets
|
|
64
|
+
// engines apply their own optimisation/finalization machinery to the
|
|
65
|
+
// observer chain. A v0.2.0 FinalizationRegistry-based auto-disconnect for
|
|
66
|
+
// orphaned observers is the real fix.
|
|
67
|
+
const activeObservers = new Set();
|
|
68
|
+
|
|
69
|
+
function dispatchMutation(records, slot) {
|
|
70
|
+
slot.lastRecords = records;
|
|
71
|
+
slot.tick.set(slot.tick.peek() + 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {object} MutationHandle
|
|
76
|
+
* @property {import('@zakkster/lite-signal').Signal<number>} tick Monotonic counter; increments per delivered batch.
|
|
77
|
+
* @property {() => MutationRecord[]} peekRecords Returns the latest batch without tracking. Empty array before the first batch.
|
|
78
|
+
* @property {() => void} dispose
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
const EMPTY = Object.freeze([]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Observe DOM mutations on `element` with the given options. Returns a
|
|
85
|
+
* handle whose `tick` signal increments each time a batch is delivered.
|
|
86
|
+
*
|
|
87
|
+
* @param {Node} element
|
|
88
|
+
* @param {MutationObserverInit} options
|
|
89
|
+
* @returns {MutationHandle}
|
|
90
|
+
*/
|
|
91
|
+
export function observeMutation(element, options) {
|
|
92
|
+
let inner = slotsByElement.get(element);
|
|
93
|
+
if (inner === undefined) {
|
|
94
|
+
inner = new Map();
|
|
95
|
+
slotsByElement.set(element, inner);
|
|
96
|
+
}
|
|
97
|
+
const key = optionsKey(options);
|
|
98
|
+
let slot = inner.get(key);
|
|
99
|
+
if (slot === undefined) {
|
|
100
|
+
slot = {
|
|
101
|
+
tick: signal(0),
|
|
102
|
+
lastRecords: EMPTY,
|
|
103
|
+
observer: null,
|
|
104
|
+
refCount: 0
|
|
105
|
+
};
|
|
106
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
107
|
+
slot.observer = new MutationObserver(function (records) {
|
|
108
|
+
dispatchMutation(records, slot);
|
|
109
|
+
});
|
|
110
|
+
slot.observer.observe(element, options);
|
|
111
|
+
activeObservers.add(slot.observer);
|
|
112
|
+
}
|
|
113
|
+
inner.set(key, slot);
|
|
114
|
+
}
|
|
115
|
+
slot.refCount++;
|
|
116
|
+
|
|
117
|
+
function peekRecords() { return slot.lastRecords; }
|
|
118
|
+
|
|
119
|
+
let disposed = false;
|
|
120
|
+
function dispose() {
|
|
121
|
+
if (disposed) return;
|
|
122
|
+
disposed = true;
|
|
123
|
+
slot.refCount--;
|
|
124
|
+
if (slot.refCount === 0) {
|
|
125
|
+
if (slot.observer !== null) {
|
|
126
|
+
slot.observer.disconnect();
|
|
127
|
+
activeObservers.delete(slot.observer);
|
|
128
|
+
}
|
|
129
|
+
inner.delete(key);
|
|
130
|
+
if (inner.size === 0) {
|
|
131
|
+
slotsByElement.delete(element);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return attachFinalizer(
|
|
137
|
+
{ tick: slot.tick, peekRecords: peekRecords, dispose: dispose },
|
|
138
|
+
dispose
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @internal */
|
|
143
|
+
export function _resetMutation() {
|
|
144
|
+
// Disconnect every active observer. The slotsByElement WeakMap drops
|
|
145
|
+
// its entries automatically once nothing else holds the elements.
|
|
146
|
+
for (const obs of activeObservers) obs.disconnect();
|
|
147
|
+
activeObservers.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @internal */
|
|
151
|
+
export function _mutationObserverCount() {
|
|
152
|
+
return activeObservers.size;
|
|
153
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-observe -- selector-filtered MutationObserver bridge.
|
|
3
|
+
*
|
|
4
|
+
* Wraps observeMutation with selector-based filtering. The common
|
|
5
|
+
* consumer pattern is "tell me when elements matching X are added or
|
|
6
|
+
* removed under this root" -- combobox option lists, infinite-scroll
|
|
7
|
+
* containers, autocomplete result panels. Without library-side
|
|
8
|
+
* support, every consumer reimplements the record walk + selector
|
|
9
|
+
* match, and most get the subtree-descendant case subtly wrong (a
|
|
10
|
+
* matching element added as a deep descendant of an added subtree
|
|
11
|
+
* arrives as a child of an addedNodes entry, not as its own entry).
|
|
12
|
+
*
|
|
13
|
+
* Signals:
|
|
14
|
+
* added: Signal<Element[]> -- matching elements added since last tick
|
|
15
|
+
* removed: Signal<Element[]> -- matching elements removed since last tick
|
|
16
|
+
* tick: Signal<number> -- monotonic batch counter (mirrors observeMutation)
|
|
17
|
+
*
|
|
18
|
+
* Allocation profile:
|
|
19
|
+
* The added / removed arrays are allocated fresh per batch that has
|
|
20
|
+
* at least one matching mutation. Consumer ergonomics win out over
|
|
21
|
+
* strict zero-alloc here -- a returned array consumers might retain
|
|
22
|
+
* for later inspection must be theirs, not the library's. Batches
|
|
23
|
+
* with zero matches reuse the frozen empty array (no allocation).
|
|
24
|
+
*
|
|
25
|
+
* Cleanup:
|
|
26
|
+
* Composes the inner observeMutation handle's dispose. The
|
|
27
|
+
* FinalizationRegistry orphan safety net inherited from the inner
|
|
28
|
+
* handle catches forgot-to-dispose; this wrapper does not need its
|
|
29
|
+
* own registry registration since dropping the outer handle drops
|
|
30
|
+
* the inner handle too.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { signal, effect, dispose as disposeEffect } from '@zakkster/lite-signal';
|
|
34
|
+
import { observeMutation } from './Mutation.js';
|
|
35
|
+
|
|
36
|
+
const EMPTY_ARR = Object.freeze([]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} MutationSelectorHandle
|
|
40
|
+
* @property {import('@zakkster/lite-signal').Signal<Element[]>} added
|
|
41
|
+
* @property {import('@zakkster/lite-signal').Signal<Element[]>} removed
|
|
42
|
+
* @property {import('@zakkster/lite-signal').Signal<number>} tick
|
|
43
|
+
* @property {() => MutationRecord[]} peekRecords
|
|
44
|
+
* @property {() => void} dispose
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Observe mutations under `root`, surfacing only nodes (or descendants
|
|
49
|
+
* of mutated subtrees, when `options.subtree` is true) matching `selector`.
|
|
50
|
+
*
|
|
51
|
+
* @param {Element} root The mutation root.
|
|
52
|
+
* @param {string} selector A CSS selector string. `.matches(selector)`
|
|
53
|
+
* must succeed on the host runtime.
|
|
54
|
+
* @param {MutationObserverInit} [options] Forwarded to the underlying
|
|
55
|
+
* MutationObserver. Defaults to `{ childList: true, subtree: true }`.
|
|
56
|
+
* @returns {MutationSelectorHandle}
|
|
57
|
+
*/
|
|
58
|
+
export function observeMutationSelector(root, selector, options) {
|
|
59
|
+
const opts = options || { childList: true, subtree: true };
|
|
60
|
+
const walkSubtree = opts.subtree === true;
|
|
61
|
+
|
|
62
|
+
const addedSig = signal(EMPTY_ARR);
|
|
63
|
+
const removedSig = signal(EMPTY_ARR);
|
|
64
|
+
|
|
65
|
+
const inner = observeMutation(root, opts);
|
|
66
|
+
|
|
67
|
+
// Effect: every tick of the inner handle, walk the latest batch and
|
|
68
|
+
// surface matching adds/removes. The effect is captured so we can
|
|
69
|
+
// dispose it in our own dispose path.
|
|
70
|
+
const fxHandle = effect(function () {
|
|
71
|
+
const t = inner.tick();
|
|
72
|
+
if (t === 0) return; // initial seed: no batch yet
|
|
73
|
+
const records = inner.peekRecords();
|
|
74
|
+
|
|
75
|
+
let added = null; // lazy alloc: only if a match shows up
|
|
76
|
+
let removed = null;
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < records.length; i++) {
|
|
79
|
+
const rec = records[i];
|
|
80
|
+
if (rec.type !== 'childList') continue;
|
|
81
|
+
|
|
82
|
+
const addedNodes = rec.addedNodes;
|
|
83
|
+
for (let j = 0; j < addedNodes.length; j++) {
|
|
84
|
+
const node = addedNodes[j];
|
|
85
|
+
if (node.nodeType !== 1) continue; // not an Element
|
|
86
|
+
if (typeof node.matches === 'function' && node.matches(selector)) {
|
|
87
|
+
if (added === null) added = [];
|
|
88
|
+
added.push(node);
|
|
89
|
+
}
|
|
90
|
+
if (walkSubtree && typeof node.querySelectorAll === 'function') {
|
|
91
|
+
const desc = node.querySelectorAll(selector);
|
|
92
|
+
for (let k = 0; k < desc.length; k++) {
|
|
93
|
+
if (added === null) added = [];
|
|
94
|
+
added.push(desc[k]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const removedNodes = rec.removedNodes;
|
|
100
|
+
for (let j = 0; j < removedNodes.length; j++) {
|
|
101
|
+
const node = removedNodes[j];
|
|
102
|
+
if (node.nodeType !== 1) continue;
|
|
103
|
+
if (typeof node.matches === 'function' && node.matches(selector)) {
|
|
104
|
+
if (removed === null) removed = [];
|
|
105
|
+
removed.push(node);
|
|
106
|
+
}
|
|
107
|
+
if (walkSubtree && typeof node.querySelectorAll === 'function') {
|
|
108
|
+
const desc = node.querySelectorAll(selector);
|
|
109
|
+
for (let k = 0; k < desc.length; k++) {
|
|
110
|
+
if (removed === null) removed = [];
|
|
111
|
+
removed.push(desc[k]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
addedSig.set(added !== null ? added : EMPTY_ARR);
|
|
118
|
+
removedSig.set(removed !== null ? removed : EMPTY_ARR);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let disposed = false;
|
|
122
|
+
function dispose() {
|
|
123
|
+
if (disposed) return;
|
|
124
|
+
disposed = true;
|
|
125
|
+
disposeEffect(fxHandle);
|
|
126
|
+
inner.dispose();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
added: addedSig,
|
|
131
|
+
removed: removedSig,
|
|
132
|
+
tick: inner.tick,
|
|
133
|
+
peekRecords: inner.peekRecords,
|
|
134
|
+
dispose: dispose
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/Resize.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-observe -- ResizeObserver bridge.
|
|
3
|
+
*
|
|
4
|
+
* One singleton ResizeObserver instance for the whole page. N consumers
|
|
5
|
+
* observing the same element pay the cost of one underlying observation;
|
|
6
|
+
* the slot is reference-counted and the element is unobserved when the last
|
|
7
|
+
* consumer disposes.
|
|
8
|
+
*
|
|
9
|
+
* Two fine-grained numeric signals per element: width and height. Reading
|
|
10
|
+
* `width` does not wake a consumer that only reads `height`, because
|
|
11
|
+
* lite-signal's Object.is equality gate halts propagation at the unchanged
|
|
12
|
+
* source. ResizeObserver fires with sub-pixel precision; we forward the raw
|
|
13
|
+
* `contentRect.width` / `contentRect.height` values.
|
|
14
|
+
*
|
|
15
|
+
* SSR-safe. In a Node environment without `ResizeObserver`, signals are
|
|
16
|
+
* created and stay at zero; observe is a no-op.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { signal } from '@zakkster/lite-signal';
|
|
20
|
+
import { attachFinalizer } from './_finalize.js';
|
|
21
|
+
|
|
22
|
+
// One singleton observer per page. Lazy: only constructed on first observe(),
|
|
23
|
+
// which keeps the module SSR-safe and lets tests install a mock before use.
|
|
24
|
+
let observerInstance = null;
|
|
25
|
+
|
|
26
|
+
// Element -> slot. A slot survives so long as refCount > 0. When it hits 0,
|
|
27
|
+
// the element is unobserved and the slot is dropped from the map.
|
|
28
|
+
const slots = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The observer callback. Hot path: must not allocate.
|
|
32
|
+
*
|
|
33
|
+
* `entries` is allocated by the browser; we cannot avoid that. We iterate
|
|
34
|
+
* 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.
|
|
41
|
+
*
|
|
42
|
+
* @param {ResizeObserverEntry[]} entries
|
|
43
|
+
*/
|
|
44
|
+
function dispatch(entries) {
|
|
45
|
+
for (let i = 0; i < entries.length; i++) {
|
|
46
|
+
const entry = entries[i];
|
|
47
|
+
const slot = slots.get(entry.target);
|
|
48
|
+
if (slot === undefined) continue;
|
|
49
|
+
// Content box: the historical default. Matches the rect a consumer
|
|
50
|
+
// 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);
|
|
55
|
+
// Border box: includes padding + border. Per spec the field is an
|
|
56
|
+
// array (to support multi-column layout); we read the first entry.
|
|
57
|
+
// Older browsers without borderBoxSize fall back to contentRect
|
|
58
|
+
// plus the differential measured via getBoundingClientRect at the
|
|
59
|
+
// next layout. For zero-alloc cleanliness we just mirror content
|
|
60
|
+
// here when borderBoxSize is absent; consumers who specifically
|
|
61
|
+
// 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);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureObserver() {
|
|
75
|
+
if (observerInstance !== null) return observerInstance;
|
|
76
|
+
if (typeof ResizeObserver === 'undefined') return null;
|
|
77
|
+
observerInstance = new ResizeObserver(dispatch);
|
|
78
|
+
return observerInstance;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {object} ResizeHandle
|
|
83
|
+
* @property {import('@zakkster/lite-signal').Signal<number>} width
|
|
84
|
+
* @property {import('@zakkster/lite-signal').Signal<number>} height
|
|
85
|
+
* @property {() => void} dispose
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {object} ResizeOptions
|
|
90
|
+
* @property {'content' | 'border'} [box] Which box dimensions to surface
|
|
91
|
+
* on the `width` / `height` signals. Default `'content'` (matches
|
|
92
|
+
* `contentRect`). Border box includes padding and border, matching
|
|
93
|
+
* what `getBoundingClientRect()` reports.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Observe an element's size as a pair of fine-grained signals.
|
|
98
|
+
*
|
|
99
|
+
* The first emission arrives asynchronously after the next layout. Until
|
|
100
|
+
* then both signals read zero. Multiple `observeResize(sameEl)` calls
|
|
101
|
+
* share one underlying observation and return distinct handles; each has
|
|
102
|
+
* its own dispose. The element stays observed until every handle has been
|
|
103
|
+
* disposed. Consumers requesting different boxes for the same element
|
|
104
|
+
* cooperate transparently: a single ResizeObserver fires both, and each
|
|
105
|
+
* handle surfaces the pair it asked for.
|
|
106
|
+
*
|
|
107
|
+
* @param {Element} element
|
|
108
|
+
* @param {ResizeOptions} [options]
|
|
109
|
+
* @returns {ResizeHandle}
|
|
110
|
+
*/
|
|
111
|
+
export function observeResize(element, options) {
|
|
112
|
+
const box = (options && options.box === 'border') ? 'border' : 'content';
|
|
113
|
+
let slot = slots.get(element);
|
|
114
|
+
if (slot === undefined) {
|
|
115
|
+
slot = {
|
|
116
|
+
contentWidth: signal(0),
|
|
117
|
+
contentHeight: signal(0),
|
|
118
|
+
borderWidth: signal(0),
|
|
119
|
+
borderHeight: signal(0),
|
|
120
|
+
refCount: 0
|
|
121
|
+
};
|
|
122
|
+
slots.set(element, slot);
|
|
123
|
+
const obs = ensureObserver();
|
|
124
|
+
if (obs !== null) obs.observe(element);
|
|
125
|
+
}
|
|
126
|
+
slot.refCount++;
|
|
127
|
+
|
|
128
|
+
let disposed = false;
|
|
129
|
+
function dispose() {
|
|
130
|
+
if (disposed) return;
|
|
131
|
+
disposed = true;
|
|
132
|
+
slot.refCount--;
|
|
133
|
+
if (slot.refCount === 0) {
|
|
134
|
+
if (observerInstance !== null) observerInstance.unobserve(element);
|
|
135
|
+
slots.delete(element);
|
|
136
|
+
// Symmetric with Intersection's empty-bucket tear-down: when no
|
|
137
|
+
// slots remain, disconnect and null the singleton. Next call to
|
|
138
|
+
// observeResize cheaply rebuilds it. Cost is one allocation on
|
|
139
|
+
// the first observe after a fully-idle period; gain is no
|
|
140
|
+
// long-lived observer holding references on otherwise-empty
|
|
141
|
+
// pages (single-page-app navigation that mounts and unmounts
|
|
142
|
+
// popovers benefits).
|
|
143
|
+
if (slots.size === 0 && observerInstance !== null) {
|
|
144
|
+
observerInstance.disconnect();
|
|
145
|
+
observerInstance = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const width = box === 'border' ? slot.borderWidth : slot.contentWidth;
|
|
151
|
+
const height = box === 'border' ? slot.borderHeight : slot.contentHeight;
|
|
152
|
+
|
|
153
|
+
return attachFinalizer(
|
|
154
|
+
{ width: width, height: height, dispose: dispose },
|
|
155
|
+
// Inner cleanup for the orphan path. Closes over `dispose` (which
|
|
156
|
+
// closes over slot/element/observerInstance/slots) -- but NOT over
|
|
157
|
+
// the handle, so the FinalizationRegistry can do its job.
|
|
158
|
+
dispose
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Test-isolation utility. Tears down all observations and the singleton
|
|
164
|
+
* observer. Outstanding handles' dispose calls become safe no-ops.
|
|
165
|
+
*
|
|
166
|
+
* @internal
|
|
167
|
+
*/
|
|
168
|
+
export function _resetResize() {
|
|
169
|
+
if (observerInstance !== null) {
|
|
170
|
+
observerInstance.disconnect();
|
|
171
|
+
observerInstance = null;
|
|
172
|
+
}
|
|
173
|
+
slots.clear();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Diagnostic: number of distinct elements currently under observation.
|
|
178
|
+
*
|
|
179
|
+
* @returns {number}
|
|
180
|
+
*/
|
|
181
|
+
export function _resizeSlotCount() {
|
|
182
|
+
return slots.size;
|
|
183
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-observe -- Page Visibility bridge.
|
|
3
|
+
*
|
|
4
|
+
* A single module-level signal: `documentVisible`. True iff the document is
|
|
5
|
+
* currently visible (`document.visibilityState === 'visible'`). Lazily
|
|
6
|
+
* attaches the `visibilitychange` listener on the 0->1 observer transition
|
|
7
|
+
* and detaches on 1->0.
|
|
8
|
+
*
|
|
9
|
+
* Importing this module does not attach a listener; only reading the signal
|
|
10
|
+
* inside an effect/computed (or subscribing to it) does.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { signal, observeObservers } from '@zakkster/lite-signal';
|
|
14
|
+
|
|
15
|
+
function readState() {
|
|
16
|
+
if (typeof document === 'undefined') return true;
|
|
17
|
+
return document.visibilityState === 'visible';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const documentVisible = signal(readState());
|
|
21
|
+
|
|
22
|
+
const onChange = function () { documentVisible.set(readState()); };
|
|
23
|
+
|
|
24
|
+
if (typeof document !== 'undefined') {
|
|
25
|
+
observeObservers(documentVisible, {
|
|
26
|
+
onConnect: function () {
|
|
27
|
+
// Re-seed in case visibility changed while detached.
|
|
28
|
+
documentVisible.set(readState());
|
|
29
|
+
document.addEventListener('visibilitychange', onChange);
|
|
30
|
+
},
|
|
31
|
+
onDisconnect: function () {
|
|
32
|
+
document.removeEventListener('visibilitychange', onChange);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @internal */
|
|
38
|
+
export function _forceVisibilitySync() {
|
|
39
|
+
documentVisible.set(readState());
|
|
40
|
+
}
|
package/src/_finalize.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-observe -- shared orphan-handle finalizer.
|
|
3
|
+
*
|
|
4
|
+
* The observer modules (Resize, Intersection, Mutation) return handles with
|
|
5
|
+
* a `dispose()` method. Consumers are expected to call dispose when they're
|
|
6
|
+
* done. If they don't, the underlying browser observer keeps its target
|
|
7
|
+
* alive via spec-defined internal references (HashSet<Ref<Node>> in WebKit,
|
|
8
|
+
* HeapVector<Member<Node>> in Chromium) -- a leak.
|
|
9
|
+
*
|
|
10
|
+
* FinalizationRegistry is the canonical safety net: when the consumer drops
|
|
11
|
+
* the handle (their last reference to it), the runtime eventually GCs the
|
|
12
|
+
* handle and fires our cleanup callback, which disconnects the browser
|
|
13
|
+
* observer.
|
|
14
|
+
*
|
|
15
|
+
* Caveats acknowledged:
|
|
16
|
+
* 1. Finalization is non-deterministic. The runtime may run the cleanup
|
|
17
|
+
* "eventually" -- on the next GC cycle, or never if the program exits
|
|
18
|
+
* first. Explicit dispose remains the primary path; this is a safety
|
|
19
|
+
* net for the forgot-to-dispose case.
|
|
20
|
+
* 2. The held value (second arg to register) must not reference the
|
|
21
|
+
* handle. We pass the bare inner-cleanup closure, which captures only
|
|
22
|
+
* slot / element / Map references -- never the handle itself.
|
|
23
|
+
* 3. Calling explicit dispose unregisters the finalizer so it doesn't
|
|
24
|
+
* double-fire. The wrapper that does this DOES capture the handle
|
|
25
|
+
* (via `registry.unregister(handle)`), but it's set as a property of
|
|
26
|
+
* the handle, so the self-reference is collected with the handle.
|
|
27
|
+
*
|
|
28
|
+
* Single registry shared across the three modules so finalisation pressure
|
|
29
|
+
* is concentrated -- the runtime processes one queue, not three.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const registry = typeof FinalizationRegistry !== 'undefined'
|
|
33
|
+
? new FinalizationRegistry(function (cleanup) {
|
|
34
|
+
// Swallow errors. A failing finalizer must not poison the queue
|
|
35
|
+
// for other pending finalizations.
|
|
36
|
+
try { cleanup(); } catch (_e) { /* intentional */ }
|
|
37
|
+
})
|
|
38
|
+
: null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register a handle for FinalizationRegistry-backed orphan cleanup.
|
|
42
|
+
*
|
|
43
|
+
* Used internally by `observeResize`, `observeIntersection`, and
|
|
44
|
+
* `observeMutation` so dropped handles trigger disconnect via GC.
|
|
45
|
+
* Public surface so downstream consumers can build their own observer-
|
|
46
|
+
* shaped primitives (a `ScrollObserver` over `addEventListener('scroll',
|
|
47
|
+
* ...)`, a `PointerCaptureObserver`, an `AbortSignal`-driven listener,
|
|
48
|
+
* etc.) with the same orphan safety.
|
|
49
|
+
*
|
|
50
|
+
* Contract for `innerCleanup`:
|
|
51
|
+
* - MUST NOT reference `handle` (closure capture would keep the
|
|
52
|
+
* handle alive and the finalizer would never fire).
|
|
53
|
+
* - MUST be idempotent (an explicit dispose may have already run,
|
|
54
|
+
* so internal state should guard against double-teardown).
|
|
55
|
+
* - Errors thrown are swallowed by the registry so the finalizer
|
|
56
|
+
* queue continues to drain for other pending finalisations.
|
|
57
|
+
*
|
|
58
|
+
* Note on timing: FinalizationRegistry is non-deterministic. The
|
|
59
|
+
* runtime decides when to process its queue. Explicit `dispose()` is
|
|
60
|
+
* still the primary path when you have a lifecycle hook; this is the
|
|
61
|
+
* safety net for the forgot-to-dispose case.
|
|
62
|
+
*
|
|
63
|
+
* Example -- building a ScrollObserver primitive:
|
|
64
|
+
*
|
|
65
|
+
* function observeScroll(target) {
|
|
66
|
+
* const positionSig = signal({ x: 0, y: 0 });
|
|
67
|
+
* let disposed = false;
|
|
68
|
+
* function onScroll() {
|
|
69
|
+
* positionSig.set({ x: target.scrollLeft, y: target.scrollTop });
|
|
70
|
+
* }
|
|
71
|
+
* target.addEventListener('scroll', onScroll);
|
|
72
|
+
* function cleanup() {
|
|
73
|
+
* if (disposed) return;
|
|
74
|
+
* disposed = true;
|
|
75
|
+
* target.removeEventListener('scroll', onScroll);
|
|
76
|
+
* }
|
|
77
|
+
* return attachFinalizer(
|
|
78
|
+
* { position: positionSig, dispose: cleanup },
|
|
79
|
+
* cleanup
|
|
80
|
+
* );
|
|
81
|
+
* }
|
|
82
|
+
*
|
|
83
|
+
* @param {object} handle Handle returned to the consumer; must
|
|
84
|
+
* have a `.dispose` method to wrap.
|
|
85
|
+
* @param {() => void} innerCleanup Bare cleanup; MUST NOT reference
|
|
86
|
+
* `handle`.
|
|
87
|
+
* @returns {object} The same `handle`, with `dispose` rewrapped to
|
|
88
|
+
* unregister the finalizer before running cleanup.
|
|
89
|
+
*/
|
|
90
|
+
export function attachFinalizer(handle, innerCleanup) {
|
|
91
|
+
if (registry === null) return handle;
|
|
92
|
+
registry.register(handle, innerCleanup, handle);
|
|
93
|
+
const originalDispose = handle.dispose;
|
|
94
|
+
handle.dispose = function () {
|
|
95
|
+
registry.unregister(handle);
|
|
96
|
+
originalDispose();
|
|
97
|
+
};
|
|
98
|
+
return handle;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Test hook. The shared FinalizationRegistry has no size; tests cannot
|
|
103
|
+
* directly observe pending finalisations. The runtime's `--expose-gc` flag
|
|
104
|
+
* plus a few synchronous gc() calls followed by a microtask flush is the
|
|
105
|
+
* portable way to encourage processing.
|
|
106
|
+
*
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
export function _hasFinalizationRegistry() {
|
|
110
|
+
return registry !== null;
|
|
111
|
+
}
|