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