@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.
@@ -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
+ }
@@ -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
+ }