phase 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -238,15 +238,15 @@ createLoop({
238
238
 
239
239
  #### Loop options
240
240
 
241
- | Option | Type | Default | Description |
242
- | --------------- | ----------------------------------- | ------------ | ----------------------------------------- |
243
- | `target` | `Element` | required | Element to observe for visibility |
244
- | `onTick` | `(frame: FrameState) => void` | required | Called each frame while running |
245
- | `fps` | `number` | — | Cap frames per second |
246
- | `reducedMotion` | `'pause' \| 'complete' \| 'ignore'` | `'pause'` | Behavior when user prefers reduced motion |
247
- | `degraded` | `'throttle' \| 'pause' \| 'ignore'` | `'throttle'` | Behavior when quality degrades |
248
- | `degradedFps` | `number` | `30` | FPS cap in degraded throttle mode |
249
- | `onPhaseChange` | `(phase, reason) => void` | — | Called on every phase transition |
241
+ | Option | Type | Default | Description |
242
+ | --------------- | ----------------------------------- | ------------ | ------------------------------------------------------------- |
243
+ | `target` | `Element \| Document` | required | Element to observe for visibility, or `document` for the page |
244
+ | `onTick` | `(frame: FrameState) => void` | required | Called each frame while running |
245
+ | `fps` | `number` | — | Cap frames per second |
246
+ | `reducedMotion` | `'pause' \| 'complete' \| 'ignore'` | `'pause'` | Behavior when user prefers reduced motion |
247
+ | `degraded` | `'throttle' \| 'pause' \| 'ignore'` | `'throttle'` | Behavior when quality degrades |
248
+ | `degradedFps` | `number` | `30` | FPS cap in degraded throttle mode |
249
+ | `onPhaseChange` | `(phase, reason) => void` | — | Called on every phase transition |
250
250
 
251
251
  ### createTicker
252
252
 
@@ -359,17 +359,17 @@ The `steps` option controls threshold granularity. Default `20` generates 21 eve
359
359
 
360
360
  #### ScrollProgress options
361
361
 
362
- | Option | Type | Default | Description |
363
- | ------------ | ----------------------------- | -------- | ---------------------------------- |
364
- | `target` | `Element` | required | Element to observe |
365
- | `onProgress` | `(ratio: number) => void` | required | Called at each threshold crossing |
366
- | `steps` | `number` | `20` | Number of evenly-spaced thresholds |
367
- | `root` | `Element \| Document \| null` | — | IO root element |
368
- | `rootMargin` | `string` | — | IO root margin |
362
+ | Option | Type | Default | Description |
363
+ | ------------ | ----------------------------- | -------- | ---------------------------------------------- |
364
+ | `target` | `Element \| Document` | required | Element to observe, or `document` for the page |
365
+ | `onProgress` | `(ratio: number) => void` | required | Called at each threshold crossing |
366
+ | `steps` | `number` | `20` | Number of evenly-spaced thresholds |
367
+ | `root` | `Element \| Document \| null` | — | IO root element |
368
+ | `rootMargin` | `string` | — | IO root margin |
369
369
 
370
370
  ### createScroll
371
371
 
372
- Tracks a scroll container's offset and progress. Reads `scrollLeft`/`scrollTop` once per rAF frame and reads the reflow-heavy geometry (`scrollWidth`/`clientWidth`) only on resize or explicit `measure()`, never on the scroll path. Auto-pauses off-screen via the shared IntersectionObserver pool. This is to `scroll` + `scrollWidth` what `createPointer` is to `pointermove` + `getBoundingClientRect`.
372
+ Tracks a scroll container's offset and progress. Reads `scrollLeft`/`scrollTop` once per rAF frame and reads the reflow-heavy geometry (`scrollWidth`/`clientWidth`) only on a coalesced resize or an explicit `measure()`, never on the scroll path. Auto-pauses off-screen via the shared IntersectionObserver pool. This is to `scroll` + `scrollWidth` what `createPointer` is to `pointermove` + `getBoundingClientRect`.
373
373
 
374
374
  > **Scroll offset, not visibility ratio.** This reports the element's own scroll position (for scrollbars, carousels, position indicators). For _how much of an element is in the viewport_, use [`createScrollProgress`](#createscrollprogress); for CSS-declarative scroll-linked animation, use the native `ScrollTimeline` API.
375
375
 
@@ -1236,15 +1236,15 @@ Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export
1236
1236
  | ------------------------- | ----------------: |
1237
1237
  | **Core** | |
1238
1238
  | `createTicker` | 847 B |
1239
- | `createSight` | 970 B |
1240
- | `createLifecycle` | 1.48 kB |
1241
- | `createLoop` | 2.6 kB |
1242
- | `createScrollProgress` | 869 B |
1239
+ | `createSight` | 1.05 kB |
1240
+ | `createLifecycle` | 1.55 kB |
1241
+ | `createLoop` | 2.68 kB |
1242
+ | `createScrollProgress` | 895 B |
1243
1243
  | `createRenderState` | 490 B |
1244
1244
  | `createDevicePixelRatio` | 544 B |
1245
- | `createMutation` | 1.17 kB |
1246
- | `createPointer` | 1.26 kB |
1247
- | `createScroll` | 1.56 kB |
1245
+ | `createMutation` | 1.21 kB |
1246
+ | `createPointer` | 1.29 kB |
1247
+ | `createScroll` | 1.61 kB |
1248
1248
  | `createThrottle` | 660 B |
1249
1249
  | `createDebounce` | 558 B |
1250
1250
  | `whenIdle` | 409 B |
@@ -1252,32 +1252,32 @@ Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export
1252
1252
  | **Ease** | |
1253
1253
  | `ease (all)` | 210 B |
1254
1254
  | **React** | |
1255
- | `useLoop` | 2.85 kB |
1256
- | `useLifecycle` | 1.68 kB |
1257
- | `useSight` | 1.18 kB |
1258
- | `useCanvas` | 3.47 kB |
1259
- | `useMutation` | 1.36 kB |
1260
- | `usePointer` | 1.47 kB |
1261
- | `useScroll` | 1.9 kB |
1262
- | `useThrottledCallback` | 797 B |
1263
- | `useDebouncedCallback` | 690 B |
1255
+ | `useLoop` | 3 kB |
1256
+ | `useLifecycle` | 1.83 kB |
1257
+ | `useSight` | 1.36 kB |
1258
+ | `useCanvas` | 3.58 kB |
1259
+ | `useMutation` | 1.39 kB |
1260
+ | `usePointer` | 1.51 kB |
1261
+ | `useScroll` | 1.96 kB |
1262
+ | `useThrottledCallback` | 804 B |
1263
+ | `useDebouncedCallback` | 696 B |
1264
1264
  | `useTween` | 680 B |
1265
1265
  | `usePresence` | 591 B |
1266
- | `useScrollProgress` | 1 kB |
1267
- | `useSize` | 378 B |
1268
- | `useContainerQuery` | 384 B |
1266
+ | `useScrollProgress` | 1.03 kB |
1267
+ | `useSize` | 418 B |
1268
+ | `useContainerQuery` | 389 B |
1269
1269
  | `useMediaQuery` | 246 B |
1270
1270
  | `usePrefersReducedMotion` | 272 B |
1271
1271
  | `useDevicePixelRatio` | 231 B |
1272
1272
  | `useSyncedRef` | 22 B |
1273
1273
  | `useStableCallback` | 39 B |
1274
1274
  | `Presence` | 741 B |
1275
- | `WhenVisible` | 1.43 kB |
1276
- | `WhenIdle` | 592 B |
1275
+ | `WhenVisible` | 1.61 kB |
1276
+ | `WhenIdle` | 596 B |
1277
1277
  | `Defer` | 86 B |
1278
- | `useIdle` | 435 B |
1279
- | `useWhenIdle` | 443 B |
1280
- | `useRenderState` | 521 B |
1278
+ | `useIdle` | 414 B |
1279
+ | `useWhenIdle` | 445 B |
1280
+ | `useRenderState` | 515 B |
1281
1281
  | `Swap` | 1.12 kB |
1282
1282
 
1283
1283
  <!-- SIZE-TABLE:END -->
@@ -60,7 +60,7 @@ function conflictingTargetError(fn) {
60
60
  throw new PhaseError(`${fn}() received both ref and target.`, {
61
61
  code: "conflicting_target",
62
62
  reason: "A tracker has one anchor.",
63
- fix: "Pass ref for an element, or target: 'page' for the page."
63
+ fix: "Pass one: ref for an element, target: 'page' for the page."
64
64
  });
65
65
  }
66
66
  function invalidDurationError(fn, value) {
@@ -213,13 +213,34 @@ function createTicker(options) {
213
213
  };
214
214
  }
215
215
  //#endregion
216
+ //#region src/core/_internal/dom/index.ts
217
+ const DOCUMENT_NODE = 9;
218
+ /**
219
+ * Discriminates a page target from an element target.
220
+ *
221
+ * Uses `nodeType` rather than a property check: `document.scrollingElement` is
222
+ * absent in some test DOMs, so feature detection would silently classify a
223
+ * Document as an Element.
224
+ */
225
+ function isDocument(target) {
226
+ return target.nodeType === DOCUMENT_NODE;
227
+ }
228
+ //#endregion
216
229
  //#region src/core/_internal/pool/io-pool.ts
217
230
  const pool$1 = /* @__PURE__ */ new Map();
218
231
  /**
219
232
  * Observe an element via a shared IntersectionObserver pool.
220
- * Elements with identical options share one IO instance.
233
+ * Elements with identical options share one IO instance. An element may have
234
+ * any number of subscribers; each receives every entry delivered after it
235
+ * subscribes, and the element stays observed until the last one cleans up.
221
236
  *
222
- * @returns Cleanup function that unobserves the element and removes the IO if empty.
237
+ * A subscriber joining an already-observed element does not get the initial
238
+ * entry: `observe()` is a no-op for a target the observer already holds, so
239
+ * nothing arrives until the next intersection change. Callers that need
240
+ * current state on subscribe have to read it themselves.
241
+ *
242
+ * @returns Cleanup function that removes this subscriber, unobserving the
243
+ * element once none remain and dropping the IO once it observes nothing.
223
244
  */
224
245
  function observeIntersection(options) {
225
246
  const { element, onIntersect, root, rootMargin, threshold } = options;
@@ -230,7 +251,12 @@ function observeIntersection(options) {
230
251
  };
231
252
  const key = getPoolKey(ioInit);
232
253
  const entry = getOrCreatePoolEntry(key, ioInit);
233
- entry.callbacks.set(element, onIntersect);
254
+ let subscribers = entry.callbacks.get(element);
255
+ if (!subscribers) {
256
+ subscribers = /* @__PURE__ */ new Set();
257
+ entry.callbacks.set(element, subscribers);
258
+ }
259
+ subscribers.add(onIntersect);
234
260
  entry.observer.observe(element);
235
261
  let disposed = false;
236
262
  return () => {
@@ -238,9 +264,13 @@ function observeIntersection(options) {
238
264
  disposed = true;
239
265
  const poolEntry = pool$1.get(key);
240
266
  if (!poolEntry) return;
241
- if (poolEntry.callbacks.get(element) === onIntersect) {
242
- poolEntry.observer.unobserve(element);
243
- poolEntry.callbacks.delete(element);
267
+ const current = poolEntry.callbacks.get(element);
268
+ if (current) {
269
+ current.delete(onIntersect);
270
+ if (current.size === 0) {
271
+ poolEntry.observer.unobserve(element);
272
+ poolEntry.callbacks.delete(element);
273
+ }
244
274
  }
245
275
  if (poolEntry.callbacks.size === 0) {
246
276
  poolEntry.observer.disconnect();
@@ -269,8 +299,9 @@ const createPoolEntry$1 = (options) => {
269
299
  return {
270
300
  observer: new IntersectionObserver((entries) => {
271
301
  for (const ioEntry of entries) {
272
- const cb = callbacks.get(ioEntry.target);
273
- if (cb) cb(ioEntry);
302
+ const subscribers = callbacks.get(ioEntry.target);
303
+ if (!subscribers) continue;
304
+ for (const cb of subscribers) cb(ioEntry);
274
305
  }
275
306
  }, options),
276
307
  callbacks
@@ -285,6 +316,9 @@ const createPoolEntry$1 = (options) => {
285
316
  * and the element is within the viewport. Uses a shared IntersectionObserver
286
317
  * pool. Multiple `createSight` calls with the same options share one observer.
287
318
  *
319
+ * Pass `document` to anchor to the page instead. There is no viewport test to
320
+ * make, so no observer is created and `phase` follows document visibility.
321
+ *
288
322
  * @example
289
323
  * const sight = createSight({
290
324
  * target: el,
@@ -303,8 +337,9 @@ function createSight(options) {
303
337
  let _phase = "unknown";
304
338
  let _reason = "initial";
305
339
  let stopped = false;
340
+ const isPage = isDocument(target);
306
341
  let documentVisible = !document.hidden;
307
- let elementInView = false;
342
+ let elementInView = isPage;
308
343
  function recompute(trigger) {
309
344
  if (stopped) return;
310
345
  const prev = _phase;
@@ -329,7 +364,15 @@ function createSight(options) {
329
364
  }
330
365
  document.addEventListener("visibilitychange", onVisibilityChange);
331
366
  window.addEventListener("pageshow", onPageShow);
332
- const unobserveIO = observeIntersection({
367
+ let unobserveIO;
368
+ if (isPage) try {
369
+ recompute("initial");
370
+ } catch (error) {
371
+ document.removeEventListener("visibilitychange", onVisibilityChange);
372
+ window.removeEventListener("pageshow", onPageShow);
373
+ throw error;
374
+ }
375
+ else unobserveIO = observeIntersection({
333
376
  element: target,
334
377
  onIntersect: onIntersection,
335
378
  ...intersectionOptions
@@ -341,7 +384,7 @@ function createSight(options) {
341
384
  unlinkAbort?.();
342
385
  document.removeEventListener("visibilitychange", onVisibilityChange);
343
386
  window.removeEventListener("pageshow", onPageShow);
344
- unobserveIO();
387
+ unobserveIO?.();
345
388
  _phase = "hidden";
346
389
  _reason = "initial";
347
390
  }
@@ -1219,30 +1262,46 @@ let observer = null;
1219
1262
  const callbacks = /* @__PURE__ */ new Map();
1220
1263
  /**
1221
1264
  * Observe an element via a singleton ResizeObserver.
1222
- * One RO instance for the entire page. Per-element `box` options are
1223
- * forwarded to `ResizeObserver.observe()`.
1265
+ * One RO instance for the entire page. An element may have any number of
1266
+ * subscribers; each receives every entry delivered after it subscribes, and
1267
+ * the element stays observed until the last one cleans up.
1224
1268
  *
1225
- * @returns Cleanup function that unobserves the element.
1269
+ * Per-element `box` options are forwarded to `ResizeObserver.observe()`. A
1270
+ * single RO holds one observation per target, so when subscribers disagree on
1271
+ * `box` the most recent call wins. Every entry carries all box sizes, so a
1272
+ * subscriber can still read the box it cares about; only which box change
1273
+ * triggers a notification is affected.
1274
+ *
1275
+ * @returns Cleanup function that removes this subscriber, unobserving the
1276
+ * element once none remain.
1226
1277
  */
1227
1278
  function observeResize(element, callback, box) {
1228
- callbacks.set(element, callback);
1279
+ let subscribers = callbacks.get(element);
1280
+ if (!subscribers) {
1281
+ subscribers = /* @__PURE__ */ new Set();
1282
+ callbacks.set(element, subscribers);
1283
+ }
1284
+ subscribers.add(callback);
1229
1285
  getObserver().observe(element, box ? { box } : void 0);
1230
1286
  let disposed = false;
1231
1287
  return () => {
1232
1288
  if (disposed) return;
1233
1289
  disposed = true;
1234
- if (callbacks.get(element) === callback) {
1235
- callbacks.delete(element);
1236
- observer?.unobserve(element);
1237
- }
1290
+ const current = callbacks.get(element);
1291
+ if (!current) return;
1292
+ current.delete(callback);
1293
+ if (current.size > 0) return;
1294
+ callbacks.delete(element);
1295
+ observer?.unobserve(element);
1238
1296
  };
1239
1297
  }
1240
1298
  /** Lazy-created singleton. RO takes zero constructor options, so one instance can observe everything. */
1241
1299
  function getObserver() {
1242
1300
  if (!observer) observer = new ResizeObserver((entries) => {
1243
1301
  for (const entry of entries) {
1244
- const cb = callbacks.get(entry.target);
1245
- if (cb) cb(entry);
1302
+ const subscribers = callbacks.get(entry.target);
1303
+ if (!subscribers) continue;
1304
+ for (const cb of subscribers) cb(entry);
1246
1305
  }
1247
1306
  });
1248
1307
  return observer;
@@ -1289,7 +1348,7 @@ function createScroll(options) {
1289
1348
  };
1290
1349
  let rafId = 0;
1291
1350
  let dirty = false;
1292
- let needsMeasure = false;
1351
+ let geometryDirty = false;
1293
1352
  let listenersAttached = false;
1294
1353
  let unobserveRO;
1295
1354
  function setPhase(phase, reason) {
@@ -1307,8 +1366,7 @@ function createScroll(options) {
1307
1366
  _state.progressX = maxX > 0 ? _state.x / maxX : 0;
1308
1367
  _state.progressY = maxY > 0 ? _state.y / maxY : 0;
1309
1368
  }
1310
- function measure() {
1311
- if (stopped || !listenersAttached) return;
1369
+ function readGeometry() {
1312
1370
  const scrollWidth = scroller.scrollWidth;
1313
1371
  const clientWidth = scroller.clientWidth;
1314
1372
  const scrollHeight = scroller.scrollHeight;
@@ -1319,19 +1377,21 @@ function createScroll(options) {
1319
1377
  _state.maxY = maxY > 0 ? maxY : 0;
1320
1378
  _state.visibleX = maxX > 0 ? clientWidth / scrollWidth : 1;
1321
1379
  _state.visibleY = maxY > 0 ? clientHeight / scrollHeight : 1;
1380
+ }
1381
+ function measure() {
1382
+ if (stopped || !listenersAttached) return;
1383
+ geometryDirty = false;
1384
+ readGeometry();
1322
1385
  computePosition();
1323
1386
  onScroll(_state);
1324
1387
  }
1325
1388
  function flush() {
1326
1389
  rafId = 0;
1327
- if (stopped) return;
1328
- if (needsMeasure) {
1329
- needsMeasure = false;
1330
- dirty = false;
1331
- measure();
1332
- return;
1390
+ if (stopped || !dirty && !geometryDirty) return;
1391
+ if (geometryDirty) {
1392
+ geometryDirty = false;
1393
+ readGeometry();
1333
1394
  }
1334
- if (!dirty) return;
1335
1395
  dirty = false;
1336
1396
  computePosition();
1337
1397
  onScroll(_state);
@@ -1346,7 +1406,7 @@ function createScroll(options) {
1346
1406
  rafId = 0;
1347
1407
  }
1348
1408
  dirty = false;
1349
- needsMeasure = false;
1409
+ geometryDirty = false;
1350
1410
  }
1351
1411
  function onScrollEvent() {
1352
1412
  if (stopped) return;
@@ -1354,13 +1414,9 @@ function createScroll(options) {
1354
1414
  scheduleFlush();
1355
1415
  }
1356
1416
  function onROResize() {
1357
- if (stopped) return;
1358
- if (pageDoc) {
1359
- needsMeasure = true;
1360
- scheduleFlush();
1361
- return;
1362
- }
1363
- measure();
1417
+ if (stopped || !listenersAttached) return;
1418
+ geometryDirty = true;
1419
+ scheduleFlush();
1364
1420
  }
1365
1421
  function attachListeners() {
1366
1422
  if (listenersAttached) return;
@@ -1418,7 +1474,13 @@ function createScroll(options) {
1418
1474
  window.removeEventListener("pageshow", onPageShow);
1419
1475
  unobserveIO?.();
1420
1476
  };
1421
- recompute();
1477
+ try {
1478
+ recompute();
1479
+ } catch (error) {
1480
+ cleanupVisibility();
1481
+ detachListeners();
1482
+ throw error;
1483
+ }
1422
1484
  } else {
1423
1485
  setPhase("tracking", "started");
1424
1486
  attachListeners();
@@ -1448,10 +1510,6 @@ function createScroll(options) {
1448
1510
  stop
1449
1511
  };
1450
1512
  }
1451
- const DOCUMENT_NODE = 9;
1452
- function isDocument(target) {
1453
- return target.nodeType === DOCUMENT_NODE;
1454
- }
1455
1513
  //#endregion
1456
1514
  //#region src/core/throttle/index.ts
1457
1515
  /**
@@ -1657,4 +1715,4 @@ function createDebounce(options) {
1657
1715
  //#endregion
1658
1716
  export { isPhaseError as C, linkAbortSignal as E, invalidDurationError as S, serverContextError as T, subscribeMediaQuery as _, createPointer as a, PhaseError as b, prefersReducedMotion as c, subscribeDpr as d, createRenderState as f, readMediaQuery as g, createLifecycle as h, observeResize as i, whenIdle as l, createLoop as m, createThrottle as n, createMutation as o, createScrollProgress as p, createScroll as r, REDUCED_MOTION_QUERY as s, createDebounce as t, readDpr as u, createSight as v, missingContextError as w, conflictingTargetError as x, createTicker as y };
1659
1717
 
1660
- //# sourceMappingURL=debounce-V_5xqZSY.js.map
1718
+ //# sourceMappingURL=debounce-ALTBuZYx.js.map