@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/index.d.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @zakkster/lite-observe -- type declarations.
3
+ *
4
+ * Zero-GC reactive bridge for the DOM observer APIs.
5
+ */
6
+
7
+ import type { Signal } from '@zakkster/lite-signal';
8
+
9
+ // --- Resize ------------------------------------------------------------------
10
+
11
+ /** Handle returned by {@link observeResize}. */
12
+ export interface ResizeHandle {
13
+ /** Width in CSS pixels. Content-box by default; border-box when the
14
+ * handle was created with `{ box: 'border' }`. Zero until the first
15
+ * ResizeObserver callback fires after layout. */
16
+ readonly width: Signal<number>;
17
+ /** Height in CSS pixels. Content-box by default; border-box when the
18
+ * handle was created with `{ box: 'border' }`. Zero until the first
19
+ * ResizeObserver callback fires after layout. */
20
+ readonly height: Signal<number>;
21
+ /** Drop this consumer's reference. Idempotent. When the last handle for
22
+ * an element is disposed, the element is unobserved. */
23
+ dispose(): void;
24
+ }
25
+
26
+ /** Options for {@link observeResize}. */
27
+ export interface ResizeOptions {
28
+ /** Which box dimensions to surface on `width` / `height`.
29
+ * - `'content'` (default): mirrors `entry.contentRect`. Excludes
30
+ * padding and border, matching the historical default.
31
+ * - `'border'`: mirrors `entry.borderBoxSize`. Includes padding
32
+ * and border, matching `getBoundingClientRect()`. On legacy
33
+ * browsers without `borderBoxSize` we fall back to contentRect
34
+ * until the runtime catches up. */
35
+ box?: 'content' | 'border';
36
+ }
37
+
38
+ /**
39
+ * Observe an element's size as fine-grained signals. N consumers of the same
40
+ * element share one underlying observation; the element is unobserved when
41
+ * the last handle is disposed.
42
+ *
43
+ * @param element Element to observe.
44
+ * @param options Optional. `box` selects content-box (default) or border-box
45
+ * dimensions for the `width` / `height` signals.
46
+ */
47
+ export function observeResize(element: Element, options?: ResizeOptions): ResizeHandle;
48
+
49
+ // --- Intersection ------------------------------------------------------------
50
+
51
+ /** Handle returned by {@link observeIntersection}. */
52
+ export interface IntersectionHandle {
53
+ /** True iff the element currently intersects the root by at least the
54
+ * configured threshold. False until the first callback fires. */
55
+ readonly isIntersecting: Signal<boolean>;
56
+ /** Raw `IntersectionObserverEntry.intersectionRatio` -- 0..1. */
57
+ readonly ratio: Signal<number>;
58
+ /** Idempotent dispose. */
59
+ dispose(): void;
60
+ }
61
+
62
+ /**
63
+ * Observe an element's intersection with a root (default: viewport).
64
+ * Equivalent options (same root identity, same rootMargin string, same
65
+ * threshold) share one underlying observer.
66
+ *
67
+ * @param element Element to observe.
68
+ * @param options Standard `IntersectionObserverInit`. Optional.
69
+ */
70
+ export function observeIntersection(
71
+ element: Element,
72
+ options?: IntersectionObserverInit
73
+ ): IntersectionHandle;
74
+
75
+ // --- Mutation ----------------------------------------------------------------
76
+
77
+ /** Handle returned by {@link observeMutation}. */
78
+ export interface MutationHandle {
79
+ /** Monotonic counter; increments by 1 each time a batch of mutation
80
+ * records is delivered. Mutations are events, not state -- consumers
81
+ * react to this counter changing. */
82
+ readonly tick: Signal<number>;
83
+ /** Returns the latest delivered batch of records without tracking. Empty
84
+ * array before the first batch. The returned array is the
85
+ * browser-allocated MutationRecord[]; do not retain across batches. */
86
+ peekRecords(): ReadonlyArray<MutationRecord>;
87
+ /** Idempotent dispose. */
88
+ dispose(): void;
89
+ }
90
+
91
+ /**
92
+ * Observe DOM mutations on `element`. Consumers asking for the same element
93
+ * with the same options share one underlying observer; differing options
94
+ * spawn distinct observers (the option set is part of an observer's
95
+ * identity in the spec).
96
+ *
97
+ * @param element Node to observe (Element or document).
98
+ * @param options Standard `MutationObserverInit`. Required.
99
+ */
100
+ export function observeMutation(
101
+ element: Node,
102
+ options: MutationObserverInit
103
+ ): MutationHandle;
104
+
105
+ /** Handle returned by {@link observeMutationSelector}. */
106
+ export interface MutationSelectorHandle {
107
+ /** Elements matching the selector that were added in the most recent
108
+ * batch. The frozen empty array is returned (with stable identity)
109
+ * when a batch contains no matching adds. */
110
+ readonly added: Signal<readonly Element[]>;
111
+ /** Elements matching the selector that were removed in the most recent
112
+ * batch. Same allocation behaviour as `added`. */
113
+ readonly removed: Signal<readonly Element[]>;
114
+ /** Monotonic batch counter mirroring the underlying `observeMutation`
115
+ * handle's tick. */
116
+ readonly tick: Signal<number>;
117
+ /** Pass-through to the underlying handle. */
118
+ peekRecords(): readonly MutationRecord[];
119
+ /** Tears down the underlying observer + the internal filtering effect. */
120
+ dispose(): void;
121
+ }
122
+
123
+ /**
124
+ * Observe mutations under `root`, surfacing only matching elements.
125
+ *
126
+ * When `options.subtree` is true, also walks descendants of added /
127
+ * removed subtrees with `querySelectorAll(selector)` -- catches the case
128
+ * where a matching element arrives as a deep descendant of an added
129
+ * container rather than as its own addedNodes entry.
130
+ *
131
+ * Allocation: `added` / `removed` arrays are allocated fresh per batch
132
+ * with at least one matching mutation, so consumers may retain references
133
+ * safely. Batches with zero matches return the same frozen empty array
134
+ * (no allocation).
135
+ *
136
+ * @param root The mutation root.
137
+ * @param selector A CSS selector string. `element.matches(selector)` must
138
+ * be available on the host runtime.
139
+ * @param options Forwarded to MutationObserver. Defaults to
140
+ * `{ childList: true, subtree: true }`.
141
+ */
142
+ export function observeMutationSelector(
143
+ root: Element,
144
+ selector: string,
145
+ options?: MutationObserverInit
146
+ ): MutationSelectorHandle;
147
+
148
+ // --- Media -------------------------------------------------------------------
149
+
150
+ /**
151
+ * Boolean signal that reflects whether `query` currently matches. The same
152
+ * query string returns the same signal across calls. The underlying
153
+ * `change` listener is attached lazily on first read and detached when no
154
+ * consumer is reading; an imported-but-unread query costs only its signal
155
+ * node and one Map entry.
156
+ *
157
+ * @param query A media-query string, e.g. `(min-width: 768px)`.
158
+ */
159
+ export function observeMedia(query: string): Signal<boolean>;
160
+
161
+ // --- Visibility --------------------------------------------------------------
162
+
163
+ /**
164
+ * Page-visibility signal. True iff `document.visibilityState === 'visible'`.
165
+ * The `visibilitychange` listener is attached lazily on first read and
166
+ * detached when no consumer is reading.
167
+ */
168
+ export const documentVisible: Signal<boolean>;
169
+
170
+ // --- Internal / test isolation ----------------------------------------------
171
+
172
+ /** @internal Reset all resize observation state. Outstanding handles' dispose
173
+ * calls become no-ops. */
174
+ export function _resetResize(): void;
175
+ /** @internal */
176
+ export function _resizeSlotCount(): number;
177
+
178
+ /** @internal */
179
+ export function _resetIntersection(): void;
180
+ /** @internal */
181
+ export function _intersectionBucketCount(): number;
182
+
183
+ /** @internal */
184
+ export function _resetMutation(): void;
185
+ /** @internal */
186
+ export function _mutationObserverCount(): number;
187
+
188
+ /** @internal */
189
+ export function _resetMedia(): void;
190
+ /** @internal */
191
+ export function _mediaCacheSize(): number;
192
+
193
+ /** @internal */
194
+ export function _forceVisibilitySync(): void;
195
+
196
+ // --- Orphan-cleanup safety net ----------------------------------------------
197
+
198
+ /**
199
+ * Register a consumer handle for FinalizationRegistry-backed orphan cleanup.
200
+ * When the consumer drops the handle without calling its `dispose()`, the
201
+ * runtime eventually GCs the handle and `innerCleanup` runs as a safety net.
202
+ *
203
+ * Public so downstream code can build observer-shaped primitives with the
204
+ * same orphan safety the built-in observers have. See JSDoc in source for
205
+ * the contract: `innerCleanup` must not capture `handle`, must be
206
+ * idempotent, may throw safely.
207
+ *
208
+ * Timing is non-deterministic; explicit dispose is still the primary path
209
+ * when a lifecycle hook is available.
210
+ */
211
+ export function attachFinalizer<T extends { dispose: () => void }>(
212
+ handle: T,
213
+ innerCleanup: () => void
214
+ ): T;
215
+
216
+ /** Returns true on runtimes with FinalizationRegistry support (all evergreen
217
+ * browsers + Node 14+). Returns false on environments without it; in that
218
+ * case `attachFinalizer` becomes a no-op pass-through. */
219
+ export function _hasFinalizationRegistry(): boolean;
package/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @zakkster/lite-observe
3
+ *
4
+ * Zero-GC reactive bridge for the DOM observer APIs.
5
+ *
6
+ * ResizeObserver, IntersectionObserver, MutationObserver, matchMedia, and
7
+ * Page Visibility, each surfaced as fine-grained lite-signal signals. A
8
+ * shared internal registry means N components observing the same element
9
+ * (or matching the same media query) share one underlying observer.
10
+ */
11
+
12
+ export { observeResize } from './src/Resize.js';
13
+ export { observeIntersection } from './src/Intersection.js';
14
+ export { observeMutation } from './src/Mutation.js';
15
+ export { observeMutationSelector } from './src/MutationSelector.js';
16
+ export { observeMedia } from './src/Media.js';
17
+ export { documentVisible } from './src/Visibility.js';
18
+
19
+ // Test-isolation utilities. Not part of the stable public surface.
20
+ export { _resetResize, _resizeSlotCount } from './src/Resize.js';
21
+ export { _resetIntersection, _intersectionBucketCount } from './src/Intersection.js';
22
+ export { _resetMutation, _mutationObserverCount } from './src/Mutation.js';
23
+ export { _resetMedia, _mediaCacheSize } from './src/Media.js';
24
+ export { _forceVisibilitySync } from './src/Visibility.js';
25
+ export { _hasFinalizationRegistry, attachFinalizer } from './src/_finalize.js';
package/llms.txt ADDED
@@ -0,0 +1,86 @@
1
+ # @zakkster/lite-observe
2
+
3
+ Zero-GC reactive bridge for the DOM observer APIs. ResizeObserver,
4
+ IntersectionObserver, MutationObserver, matchMedia, and Page Visibility
5
+ collapsed to fine-grained lite-signal signals with a shared internal
6
+ registry: N consumers of the same element share one underlying observer.
7
+
8
+ ## Exports
9
+
10
+ - `observeResize(el, options?)` -> `{ width: Signal<number>, height: Signal<number>, dispose }`
11
+ Singleton ResizeObserver, refcounted per element. Width and height are
12
+ independent signals (Object.is-gated). First emission async. `options.box`
13
+ selects `'content'` (default, contentRect) or `'border'` (borderBoxSize,
14
+ matches getBoundingClientRect). Mixed-box consumers of one element still
15
+ share a single observation.
16
+
17
+ - `observeIntersection(el, options?)` -> `{ isIntersecting: Signal<boolean>, ratio: Signal<number>, dispose }`
18
+ Observers keyed by (root, rootMargin, threshold). Equivalent options
19
+ share one observer. Different options spawn distinct ones (per spec).
20
+
21
+ - `observeMutation(el, options)` -> `{ tick: Signal<number>, peekRecords(), dispose }`
22
+ Tick is a monotonic counter; increments per delivered batch.
23
+ peekRecords() returns the latest MutationRecord[] without tracking and
24
+ without copying. One observer per (element, optionsKey).
25
+
26
+ - `observeMutationSelector(root, selector, options?)` -> `{ added: Signal<Element[]>, removed: Signal<Element[]>, tick: Signal<number>, peekRecords(), dispose }`
27
+ Selector-filtered wrapper over observeMutation. Surfaces matching elements
28
+ added/removed per batch. With `subtree: true` (the default option set is
29
+ `{ childList: true, subtree: true }`), walks descendants of added/removed
30
+ subtrees via querySelectorAll so deep matches arriving inside an added
31
+ container are caught. added/removed allocate fresh arrays only on batches
32
+ with a match (so consumers may retain them); zero-match batches reuse a
33
+ frozen empty array. Composes the inner handle's FinalizationRegistry safety.
34
+
35
+ - `observeMedia(query)` -> `Signal<boolean>`
36
+ One signal per query string, cached. Listener attached lazily via
37
+ lite-signal observeObservers; detached when no consumer is reading.
38
+
39
+ - `documentVisible: Signal<boolean>`
40
+ Module-level. True iff document.visibilityState === 'visible'.
41
+ Lazy listener.
42
+
43
+ ## Orphan-safety utility (public)
44
+
45
+ - `attachFinalizer(handle, innerCleanup)` -> `handle`
46
+ Registers `handle` with a shared FinalizationRegistry so dropping it
47
+ without calling dispose() triggers `innerCleanup` on a later GC pass.
48
+ `innerCleanup` MUST NOT capture `handle` (would pin it and the finalizer
49
+ would never fire) and MUST be idempotent. Rewraps `handle.dispose` to
50
+ unregister before running, preventing double-fire. Exported so downstream
51
+ code can build observer-shaped primitives (scroll, pointer-capture, etc.)
52
+ with the same orphan safety the built-ins have.
53
+ - `_hasFinalizationRegistry()` -> boolean. False on runtimes without
54
+ FinalizationRegistry; there `attachFinalizer` is a no-op pass-through.
55
+
56
+ ## Internal / test-only
57
+
58
+ - `_resetResize`, `_resizeSlotCount`
59
+ - `_resetIntersection`, `_intersectionBucketCount`
60
+ - `_resetMutation`, `_mutationObserverCount`
61
+ - `_resetMedia`, `_mediaCacheSize`
62
+ - `_forceVisibilitySync`
63
+
64
+ ## Design
65
+
66
+ - Fine-grained signals, not one rect-shaped object. Consumers tracking
67
+ one field do not wake on others.
68
+ - Tick + peek for mutations, not a records-shaped signal. Mutation records
69
+ are transient; a records signal would force allocations or break
70
+ identity semantics.
71
+ - Lazy listeners on the media + visibility paths via observeObservers
72
+ 0->1 / 1->0 transitions. Idle signals cost only their node.
73
+ - Indexed for-loops in callbacks, no iterator allocation. Closures
74
+ pre-allocated at registration, not per callback. Library-side allocations
75
+ occur only at registration time.
76
+ - Orphan-handle cleanup via shared FinalizationRegistry. Handles from
77
+ observeResize / observeIntersection / observeMutation are registered;
78
+ dropping the handle without dispose() triggers cleanup on the next GC
79
+ pass. Explicit dispose unregisters first to avoid double-fire. Non-
80
+ deterministic in timing; explicit dispose is still preferred when you
81
+ have a lifecycle hook.
82
+ - SSR safe via typeof guards. Importing never throws under Node.
83
+
84
+ ## Peer dependency
85
+
86
+ @zakkster/lite-signal ^1.2.2
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@zakkster/lite-observe",
3
+ "version": "1.0.0",
4
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
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
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "index.js",
9
+ "module": "index.js",
10
+ "types": "./index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "node": "./index.js",
14
+ "import": "./index.js",
15
+ "types": "./index.d.ts",
16
+ "default": "./index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "src/",
21
+ "index.js",
22
+ "index.d.ts",
23
+ "README.md",
24
+ "llms.txt",
25
+ "LICENSE.txt",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test test/*.test.js",
30
+ "test:gc": "node --expose-gc --test test/*.test.js",
31
+ "test:browser": "node test/browser/serve.js",
32
+ "demo": "node test/browser/serve.js 8767 demo/index.html",
33
+ "bench": "node --expose-gc bench/lite-observe.bench.js",
34
+ "verify": "npm test && npm run bench"
35
+ },
36
+ "keywords": [
37
+ "resize-observer",
38
+ "intersection-observer",
39
+ "mutation-observer",
40
+ "match-media",
41
+ "page-visibility",
42
+ "reactive",
43
+ "signals",
44
+ "lite-signal",
45
+ "fine-grained",
46
+ "zero-gc",
47
+ "zero-dependency",
48
+ "shared-observer",
49
+ "tiny",
50
+ "esm"
51
+ ],
52
+ "peerDependencies": {
53
+ "@zakkster/lite-signal": "^1.2.2"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "license": "MIT",
59
+ "homepage": "https://github.com/PeshoVurtoleta/lite-observe#readme",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/PeshoVurtoleta/lite-observe.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/PeshoVurtoleta/lite-observe/issues",
66
+ "email": "shinikchiev@yahoo.com"
67
+ },
68
+ "engines": {
69
+ "node": ">=18"
70
+ }
71
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @zakkster/lite-observe -- IntersectionObserver bridge.
3
+ *
4
+ * IntersectionObserver instances are NOT interchangeable: the (root,
5
+ * rootMargin, threshold) tuple is part of the observer's identity, and an
6
+ * `observe(el)` call inherits whatever configuration the observer was built
7
+ * with. Two consumers asking for the same element with different thresholds
8
+ * must each get their own underlying observer.
9
+ *
10
+ * Sharing model: a Map keyed by a stringified options tuple holds one
11
+ * observer plus a per-element slot map. Within a tuple, N consumers of the
12
+ * same element share one slot; the element is unobserved when the last slot
13
+ * consumer disposes, and the observer is destroyed when its slot map empties.
14
+ *
15
+ * Per-slot signals: `isIntersecting` (boolean) and `ratio` (number, the raw
16
+ * IntersectionObserverEntry.intersectionRatio). Consumers tracking only
17
+ * visibility do not wake on ratio changes when the boolean is unchanged.
18
+ */
19
+
20
+ import { signal } from '@zakkster/lite-signal';
21
+ import { attachFinalizer } from './_finalize.js';
22
+
23
+ /**
24
+ * Build a deterministic key for an IntersectionObserver options object.
25
+ * The same key must match across equivalent options to enable sharing.
26
+ *
27
+ * @param {IntersectionObserverInit | undefined} options
28
+ * @returns {string}
29
+ */
30
+ function optionsKey(options) {
31
+ if (options === undefined) return '||0';
32
+ const root = options.root;
33
+ // Root identity is by reference; we cannot serialise an Element. Use a
34
+ // WeakMap-backed counter to assign each distinct root a stable id.
35
+ const rootId = root == null ? '' : rootIdFor(root);
36
+ const margin = options.rootMargin == null ? '' : options.rootMargin;
37
+ const threshold = options.threshold;
38
+ let thrKey;
39
+ if (threshold === undefined) thrKey = '0';
40
+ else if (typeof threshold === 'number') thrKey = String(threshold);
41
+ else {
42
+ // Array: stable order, no allocation beyond the join.
43
+ thrKey = threshold.join(',');
44
+ }
45
+ return rootId + '|' + margin + '|' + thrKey;
46
+ }
47
+
48
+ const rootIds = new WeakMap();
49
+ let nextRootId = 1;
50
+ function rootIdFor(root) {
51
+ let id = rootIds.get(root);
52
+ if (id === undefined) {
53
+ id = nextRootId++;
54
+ rootIds.set(root, id);
55
+ }
56
+ return 'r' + id;
57
+ }
58
+
59
+ // optionsKey -> { observer, slots: Map<Element, slot>, options }
60
+ const observersByKey = new Map();
61
+
62
+ /**
63
+ * Dispatch path. `entries` and the per-callback `this` binding are
64
+ * browser-allocated; we add nothing. Indexed for-loop, no iterator.
65
+ *
66
+ * @param {IntersectionObserverEntry[]} entries
67
+ * @param {{ slots: Map<Element, any> }} bucket
68
+ */
69
+ function dispatchIntersection(entries, bucket) {
70
+ const map = bucket.slots;
71
+ for (let i = 0; i < entries.length; i++) {
72
+ const entry = entries[i];
73
+ const slot = map.get(entry.target);
74
+ if (slot === undefined) continue;
75
+ slot.isIntersecting.set(entry.isIntersecting);
76
+ slot.ratio.set(entry.intersectionRatio);
77
+ }
78
+ }
79
+
80
+ function ensureBucket(options) {
81
+ const key = optionsKey(options);
82
+ let bucket = observersByKey.get(key);
83
+ if (bucket !== undefined) return bucket;
84
+ if (typeof IntersectionObserver === 'undefined') {
85
+ // SSR / unsupported: install a sentinel bucket with a null observer.
86
+ // Slots are still created so consumer code reads sane defaults.
87
+ bucket = { observer: null, slots: new Map(), key };
88
+ observersByKey.set(key, bucket);
89
+ return bucket;
90
+ }
91
+ bucket = { observer: null, slots: new Map(), key };
92
+ bucket.observer = new IntersectionObserver(
93
+ function (entries) { dispatchIntersection(entries, bucket); },
94
+ options
95
+ );
96
+ observersByKey.set(key, bucket);
97
+ return bucket;
98
+ }
99
+
100
+ /**
101
+ * @typedef {object} IntersectionHandle
102
+ * @property {import('@zakkster/lite-signal').Signal<boolean>} isIntersecting
103
+ * @property {import('@zakkster/lite-signal').Signal<number>} ratio
104
+ * @property {() => void} dispose
105
+ */
106
+
107
+ /**
108
+ * Observe an element's intersection with a root (default: viewport) as
109
+ * fine-grained signals.
110
+ *
111
+ * The first emission arrives asynchronously after the next layout. Until then
112
+ * `isIntersecting` is false and `ratio` is zero. Equivalent options
113
+ * (same root, same rootMargin, same threshold) share one underlying observer.
114
+ *
115
+ * @param {Element} element
116
+ * @param {IntersectionObserverInit} [options]
117
+ * @returns {IntersectionHandle}
118
+ */
119
+ export function observeIntersection(element, options) {
120
+ const bucket = ensureBucket(options);
121
+ let slot = bucket.slots.get(element);
122
+ if (slot === undefined) {
123
+ slot = {
124
+ isIntersecting: signal(false),
125
+ ratio: signal(0),
126
+ refCount: 0
127
+ };
128
+ bucket.slots.set(element, slot);
129
+ if (bucket.observer !== null) bucket.observer.observe(element);
130
+ }
131
+ slot.refCount++;
132
+
133
+ let disposed = false;
134
+ function dispose() {
135
+ if (disposed) return;
136
+ disposed = true;
137
+ slot.refCount--;
138
+ if (slot.refCount === 0) {
139
+ if (bucket.observer !== null) bucket.observer.unobserve(element);
140
+ bucket.slots.delete(element);
141
+ if (bucket.slots.size === 0) {
142
+ if (bucket.observer !== null) bucket.observer.disconnect();
143
+ observersByKey.delete(bucket.key);
144
+ }
145
+ }
146
+ }
147
+
148
+ return attachFinalizer(
149
+ {
150
+ isIntersecting: slot.isIntersecting,
151
+ ratio: slot.ratio,
152
+ dispose: dispose
153
+ },
154
+ dispose
155
+ );
156
+ }
157
+
158
+ /** @internal */
159
+ export function _resetIntersection() {
160
+ for (const bucket of observersByKey.values()) {
161
+ if (bucket.observer !== null) bucket.observer.disconnect();
162
+ }
163
+ observersByKey.clear();
164
+ }
165
+
166
+ /** @internal */
167
+ export function _intersectionBucketCount() {
168
+ return observersByKey.size;
169
+ }
package/src/Media.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @zakkster/lite-observe -- matchMedia bridge.
3
+ *
4
+ * A `MediaQueryList` is one DOM object per query string. We cache the
5
+ * corresponding signal so that N components asking for `(min-width: 768px)`
6
+ * all read from the same node, and only one `change` listener is attached
7
+ * to the underlying MediaQueryList.
8
+ *
9
+ * The listener is attached lazily on the first read of the signal (via
10
+ * lite-signal's `observeObservers` 0->1 transition) and removed on the 1->0
11
+ * transition. A query that is imported but never read costs nothing beyond
12
+ * the signal node and one Map entry.
13
+ */
14
+
15
+ import { signal, observeObservers } from '@zakkster/lite-signal';
16
+
17
+ const signalsByQuery = new Map();
18
+
19
+ /**
20
+ * Get a boolean signal that reflects whether `query` currently matches.
21
+ * The same query string returns the same signal across calls.
22
+ *
23
+ * In SSR (no `matchMedia`), the signal exists and stays at its initial
24
+ * value (false).
25
+ *
26
+ * @param {string} query e.g. `(min-width: 768px)`, `(prefers-color-scheme: dark)`
27
+ * @returns {import('@zakkster/lite-signal').Signal<boolean>}
28
+ */
29
+ export function observeMedia(query) {
30
+ let s = signalsByQuery.get(query);
31
+ if (s !== undefined) return s;
32
+
33
+ if (typeof matchMedia === 'undefined') {
34
+ s = signal(false);
35
+ signalsByQuery.set(query, s);
36
+ return s;
37
+ }
38
+
39
+ const mql = matchMedia(query);
40
+ s = signal(mql.matches);
41
+
42
+ // Lazy attach/detach. While no consumer is reading this signal we do
43
+ // not need a change listener; lite-signal's observer-lifecycle hook
44
+ // fires exactly on the 0<->1 transitions.
45
+ const onChange = function () { s.set(mql.matches); };
46
+
47
+ observeObservers(s, {
48
+ onConnect: function () {
49
+ // Re-seed: the MQL may have changed value between detach and
50
+ // re-attach. Cheaper than maintaining a permanent listener.
51
+ s.set(mql.matches);
52
+ if (typeof mql.addEventListener === 'function') {
53
+ mql.addEventListener('change', onChange);
54
+ } else if (typeof mql.addListener === 'function') {
55
+ // Safari < 14 legacy API.
56
+ mql.addListener(onChange);
57
+ }
58
+ },
59
+ onDisconnect: function () {
60
+ if (typeof mql.removeEventListener === 'function') {
61
+ mql.removeEventListener('change', onChange);
62
+ } else if (typeof mql.removeListener === 'function') {
63
+ mql.removeListener(onChange);
64
+ }
65
+ // Evict from the memo when no consumer is observing. Closure
66
+ // references (s, mql, onChange) survive in any consumer still
67
+ // holding the signal -- if they re-subscribe, observeObservers
68
+ // re-fires onConnect and re-attaches the listener, so the held
69
+ // reference continues to work correctly. New callers asking
70
+ // for the same query after eviction get a fresh signal+mql;
71
+ // wasteful in that transient (two MQL instances for one query),
72
+ // but correct, and the waste collapses when the held reference
73
+ // is released.
74
+ //
75
+ // This bounds the memo size to "queries currently being
76
+ // observed" rather than "queries ever requested", which closes
77
+ // the unbounded-cache leak for consumers generating dynamic
78
+ // query strings.
79
+ signalsByQuery.delete(query);
80
+ }
81
+ });
82
+
83
+ signalsByQuery.set(query, s);
84
+ return s;
85
+ }
86
+
87
+ /** @internal */
88
+ export function _resetMedia() {
89
+ signalsByQuery.clear();
90
+ }
91
+
92
+ /** @internal */
93
+ export function _mediaCacheSize() {
94
+ return signalsByQuery.size;
95
+ }