phase 0.0.7 → 0.0.8

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
@@ -35,6 +35,8 @@ Each guarantee is a [tested invariant](#guarantees), not an aspiration. Every ex
35
35
  - [createLifecycle](#createlifecycle)
36
36
  - [createScrollProgress](#createscrollprogress)
37
37
  - [createScroll](#createscroll)
38
+ - [createThrottle](#createthrottle)
39
+ - [createDebounce](#createdebounce)
38
40
  - [prefersReducedMotion](#prefersreducedmotion)
39
41
  - [Easing and math](#easing-and-math)
40
42
  - [Choosing a primitive](#choosing-a-primitive)
@@ -46,6 +48,8 @@ Each guarantee is a [tested invariant](#guarantees), not an aspiration. Every ex
46
48
  - [usePresence](#usepresence)
47
49
  - [useScrollProgress](#usescrollprogress)
48
50
  - [useScroll](#usescroll)
51
+ - [useThrottledCallback](#usethrottledcallback)
52
+ - [useDebouncedCallback](#usedebouncedcallback)
49
53
  - [Utility hooks](#utility-hooks)
50
54
  - [React components](#react-components)
51
55
  - [How animations work](#how-animations-work)
@@ -398,6 +402,69 @@ scroll.stop();
398
402
 
399
403
  The options type is `CreateScrollOptions` (`ScrollOptions` is a `lib.dom` global and must not be shadowed).
400
404
 
405
+ ### createThrottle
406
+
407
+ Frame-aligned, visibility-aware throttle for event-driven work below frame rate (socket emits, worker messaging, expensive recompute). Leading calls fire synchronously; a pending trailing call fires with the latest value on the first animation frame at or past `interval`. Nothing is scheduled while the trigger is idle or the document is hidden.
408
+
409
+ > **Event-driven, not a loop.** This fires on the trigger and idles otherwise. To cap a continuous render loop, use `fps` on [`createLoop`](#createloop). To think in rates, `interval: 1000 / 20` reads as "at most 20 per second".
410
+
411
+ ```ts
412
+ import { createThrottle } from 'phase';
413
+
414
+ const throttle = createThrottle({
415
+ callback: (state) => socket.emit('cursor', state.x, state.y),
416
+ interval: 50,
417
+ });
418
+
419
+ const pointer = createPointer({ element, onPointer: throttle.call });
420
+
421
+ // throttle.flush() fires a pending trailing call now
422
+ // throttle.cancel() discards it and resets the window
423
+ // cleanup:
424
+ throttle.stop();
425
+ ```
426
+
427
+ When the document hides, a pending call is flushed with the latest value (default) or dropped per `hidden`. Calls made while hidden are recorded but fire nothing until the document is visible again.
428
+
429
+ #### Throttle options
430
+
431
+ | Option | Type | Default | Description |
432
+ | ---------- | ----------------------------------- | --------- | --------------------------------------------- |
433
+ | `callback` | `(value: T) => void` | required | Called with the latest value passed to `call` |
434
+ | `interval` | `number` | required | Minimum ms between invocations |
435
+ | `edge` | `'leading' \| 'trailing' \| 'both'` | `'both'` | Which edges fire |
436
+ | `hidden` | `'flush' \| 'drop'` | `'flush'` | Pending-call policy when the document hides |
437
+ | `signal` | `AbortSignal` | — | Stops the throttle when aborted |
438
+
439
+ ### createDebounce
440
+
441
+ Visibility-aware trailing debounce: fires the callback with the latest value once `wait` ms pass without a new call. No timer runs while the document is hidden; the quiet period restarts on return. Use it for work that should wait out a burst, like reallocating canvas buffers after a resize stream settles.
442
+
443
+ ```ts
444
+ import { createDebounce } from 'phase';
445
+
446
+ const debounce = createDebounce({
447
+ callback: (size) => reallocateBuffers(size),
448
+ wait: 250,
449
+ });
450
+
451
+ debounce.call({ width, height });
452
+
453
+ // cleanup:
454
+ debounce.stop();
455
+ ```
456
+
457
+ Same surface as `createThrottle`: `flush()`, `cancel()`, a synchronous `pending` read, and terminal `stop()`.
458
+
459
+ #### Debounce options
460
+
461
+ | Option | Type | Default | Description |
462
+ | ---------- | -------------------- | --------- | --------------------------------------------- |
463
+ | `callback` | `(value: T) => void` | required | Called with the latest value passed to `call` |
464
+ | `wait` | `number` | required | Quiet period in ms; each call restarts it |
465
+ | `hidden` | `'flush' \| 'drop'` | `'flush'` | Pending-call policy when the document hides |
466
+ | `signal` | `AbortSignal` | — | Stops the debounce when aborted |
467
+
401
468
  ### prefersReducedMotion
402
469
 
403
470
  Returns `true` when reduced motion is enabled at the OS level. Use it to gate expensive setup or dynamic imports.
@@ -468,6 +535,8 @@ Easing, interpolation, and your value range are three separate concerns. `phase`
468
535
  | Pause non-`phase` work inside a `Defer` subtree | `useRenderState` |
469
536
  | Subscribe to scroll, size, or media values reactively | `useScrollProgress` / `useSize` / `useContainerQuery` / `useMediaQuery` |
470
537
  | Scroll/size/visibility without re-renders? | Same hooks with a callback (`onProgress` / `onResize` / `onVisibilityChange`), read via ref |
538
+ | Rate-limit event-driven work (sockets, workers) | `useThrottledCallback` |
539
+ | Run once after a burst settles (resize, typing) | `useDebouncedCallback` |
471
540
 
472
541
  **`useSight` vs `useLifecycle`:** `useSight` reports pure visibility (for lazy-mounting, analytics, `WhenVisible`). `useLifecycle` folds in reduced motion and a manual pause, so you can't accidentally animate for users who asked not to. If you're gating an animation, use `useLifecycle`. If you're gating content, use `useSight`.
473
542
 
@@ -668,6 +737,42 @@ function Carousel({ children }) {
668
737
 
669
738
  Scrolling writes to the DOM directly with zero re-renders. Read the latest position on demand from `stateRef.current` (e.g. inside a `useLoop` tick), and call `measure()` after changing scrollable content.
670
739
 
740
+ ### useThrottledCallback
741
+
742
+ Wraps `createThrottle` with React lifecycle management. Returns a stable-identity throttled function (with `flush()` and `cancel()` attached) that drops directly into any callback slot and always invokes the latest `callback`.
743
+
744
+ ```tsx
745
+ import { usePointer, useThrottledCallback } from 'phase/react';
746
+
747
+ function LiveCursor() {
748
+ const emit = useThrottledCallback(
749
+ (s: PointerState) => socket.emit('cursor', { x: s.x, y: s.y }),
750
+ { interval: 50 },
751
+ );
752
+ const { ref } = usePointer({ onPointer: emit });
753
+ return <div ref={ref} />;
754
+ }
755
+ ```
756
+
757
+ Unmount and option changes discard a pending trailing call. When the final value must land, flush in your own cleanup: `useEffect(() => () => emit.flush(), [emit])`.
758
+
759
+ ### useDebouncedCallback
760
+
761
+ Wraps `createDebounce` with React lifecycle management. Same shape as `useThrottledCallback`, but fires once `wait` ms pass without a new call.
762
+
763
+ ```tsx
764
+ import { useSize, useDebouncedCallback } from 'phase/react';
765
+
766
+ function SimulationCanvas() {
767
+ const realloc = useDebouncedCallback(
768
+ (size: Size) => reallocateBuffers(size),
769
+ { wait: 250 },
770
+ );
771
+ const { ref } = useSize({ onResize: realloc });
772
+ return <canvas ref={ref} />;
773
+ }
774
+ ```
775
+
671
776
  ### Utility hooks
672
777
 
673
778
  | Hook | Purpose |
@@ -983,36 +1088,40 @@ Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export
983
1088
  | `createMutation` | 1.17 kB |
984
1089
  | `createPointer` | 1.26 kB |
985
1090
  | `createScroll` | 1.45 kB |
1091
+ | `createThrottle` | 657 B |
1092
+ | `createDebounce` | 559 B |
986
1093
  | `whenIdle` | 409 B |
987
1094
  | `prefersReducedMotion` | 101 B |
988
1095
  | **Ease** | |
989
1096
  | `ease (all)` | 210 B |
990
1097
  | **React** | |
991
- | `useLoop` | 2.81 kB |
992
- | `useLifecycle` | 1.69 kB |
993
- | `useSight` | 1.18 kB |
1098
+ | `useLoop` | 2.82 kB |
1099
+ | `useLifecycle` | 1.68 kB |
1100
+ | `useSight` | 1.19 kB |
994
1101
  | `useCanvas` | 3.44 kB |
995
1102
  | `useMutation` | 1.36 kB |
996
1103
  | `usePointer` | 1.48 kB |
997
- | `useScroll` | 1.71 kB |
998
- | `useTween` | 617 B |
999
- | `usePresence` | 593 B |
1104
+ | `useScroll` | 1.72 kB |
1105
+ | `useThrottledCallback` | 797 B |
1106
+ | `useDebouncedCallback` | 688 B |
1107
+ | `useTween` | 619 B |
1108
+ | `usePresence` | 591 B |
1000
1109
  | `useScrollProgress` | 993 B |
1001
- | `useSize` | 384 B |
1002
- | `useContainerQuery` | 357 B |
1003
- | `useMediaQuery` | 245 B |
1110
+ | `useSize` | 378 B |
1111
+ | `useContainerQuery` | 384 B |
1112
+ | `useMediaQuery` | 246 B |
1004
1113
  | `usePrefersReducedMotion` | 272 B |
1005
1114
  | `useDevicePixelRatio` | 231 B |
1006
1115
  | `useSyncedRef` | 22 B |
1007
1116
  | `useStableCallback` | 39 B |
1008
- | `Presence` | 739 B |
1117
+ | `Presence` | 741 B |
1009
1118
  | `WhenVisible` | 1.44 kB |
1010
- | `WhenIdle` | 592 B |
1119
+ | `WhenIdle` | 593 B |
1011
1120
  | `Defer` | 86 B |
1012
1121
  | `useIdle` | 435 B |
1013
- | `useWhenIdle` | 449 B |
1122
+ | `useWhenIdle` | 445 B |
1014
1123
  | `useRenderState` | 527 B |
1015
- | `Swap` | 1.12 kB |
1124
+ | `Swap` | 1.13 kB |
1016
1125
 
1017
1126
  <!-- SIZE-TABLE:END -->
1018
1127
 
@@ -1414,6 +1414,208 @@ function createScroll(options) {
1414
1414
  };
1415
1415
  }
1416
1416
  //#endregion
1417
- export { linkAbortSignal as C, serverContextError as S, createTicker as _, REDUCED_MOTION_QUERY as a, isPhaseError as b, readDpr as c, createScrollProgress as d, createLoop as f, createSight as g, subscribeMediaQuery as h, createMutation as i, subscribeDpr as l, readMediaQuery as m, observeResize as n, prefersReducedMotion as o, createLifecycle as p, createPointer as r, whenIdle as s, createScroll as t, createRenderState as u, PhaseError as v, missingContextError as x, invalidDurationError as y };
1417
+ //#region src/core/throttle/index.ts
1418
+ /**
1419
+ * Frame-aligned, visibility-aware throttle. Leading calls fire synchronously;
1420
+ * a pending trailing call rides a one-shot rAF chain and fires with the latest
1421
+ * value on the first frame at or past `interval`. While the document is hidden
1422
+ * nothing is scheduled: a pending call is flushed or dropped per `hidden`, and
1423
+ * new calls are recorded but deferred until the document is visible again.
1424
+ *
1425
+ * @remarks
1426
+ * `call` takes exactly one value and stores it by reference, so the hot path
1427
+ * never allocates. Trailing calls read the value at fire time.
1428
+ */
1429
+ function createThrottle(options) {
1430
+ if (typeof document === "undefined") serverContextError("createThrottle");
1431
+ const { callback, interval, edge = "both", hidden = "flush", signal } = options;
1432
+ const leading = edge !== "trailing";
1433
+ const trailing = edge !== "leading";
1434
+ let stopped = false;
1435
+ let pending = false;
1436
+ let lastFire = 0;
1437
+ let rafId = 0;
1438
+ let documentVisible = !document.hidden;
1439
+ let latest = void 0;
1440
+ function fire(now) {
1441
+ lastFire = now;
1442
+ pending = false;
1443
+ callback(latest);
1444
+ }
1445
+ function cancelRaf() {
1446
+ if (rafId !== 0) {
1447
+ cancelAnimationFrame(rafId);
1448
+ rafId = 0;
1449
+ }
1450
+ }
1451
+ function tick() {
1452
+ rafId = 0;
1453
+ if (stopped || !pending) return;
1454
+ const now = performance.now();
1455
+ if (now - lastFire >= interval) fire(now);
1456
+ else rafId = requestAnimationFrame(tick);
1457
+ }
1458
+ function scheduleRaf() {
1459
+ if (rafId === 0) rafId = requestAnimationFrame(tick);
1460
+ }
1461
+ function call(value) {
1462
+ if (stopped) return;
1463
+ latest = value;
1464
+ if (!documentVisible) {
1465
+ pending = true;
1466
+ return;
1467
+ }
1468
+ const now = performance.now();
1469
+ if (now - lastFire >= interval) {
1470
+ if (leading) {
1471
+ fire(now);
1472
+ return;
1473
+ }
1474
+ lastFire = now;
1475
+ }
1476
+ if (trailing) {
1477
+ pending = true;
1478
+ scheduleRaf();
1479
+ }
1480
+ }
1481
+ function flush() {
1482
+ if (stopped || !pending) return;
1483
+ cancelRaf();
1484
+ fire(performance.now());
1485
+ }
1486
+ function cancel() {
1487
+ if (stopped) return;
1488
+ cancelRaf();
1489
+ pending = false;
1490
+ lastFire = 0;
1491
+ }
1492
+ function onVisibilityChange() {
1493
+ documentVisible = !document.hidden;
1494
+ if (stopped) return;
1495
+ if (!documentVisible) {
1496
+ cancelRaf();
1497
+ if (pending) if (hidden === "flush") fire(performance.now());
1498
+ else pending = false;
1499
+ } else if (pending) scheduleRaf();
1500
+ }
1501
+ function onPageShow(event) {
1502
+ if (!event.persisted) return;
1503
+ documentVisible = true;
1504
+ if (!stopped && pending) scheduleRaf();
1505
+ }
1506
+ document.addEventListener("visibilitychange", onVisibilityChange);
1507
+ window.addEventListener("pageshow", onPageShow);
1508
+ let unlinkAbort;
1509
+ function stop() {
1510
+ if (stopped) return;
1511
+ stopped = true;
1512
+ unlinkAbort?.();
1513
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1514
+ window.removeEventListener("pageshow", onPageShow);
1515
+ cancelRaf();
1516
+ pending = false;
1517
+ }
1518
+ unlinkAbort = linkAbortSignal(signal, stop);
1519
+ return {
1520
+ call,
1521
+ flush,
1522
+ cancel,
1523
+ get pending() {
1524
+ return pending;
1525
+ },
1526
+ stop
1527
+ };
1528
+ }
1529
+ //#endregion
1530
+ //#region src/core/debounce/index.ts
1531
+ /**
1532
+ * Visibility-aware trailing debounce. Fires the callback with the latest
1533
+ * value once `wait` milliseconds pass without a new call. While the document
1534
+ * is hidden no timer runs: a pending call is flushed or dropped per `hidden`,
1535
+ * and new calls are recorded but wait until the document is visible again,
1536
+ * when the quiet timer restarts.
1537
+ */
1538
+ function createDebounce(options) {
1539
+ if (typeof document === "undefined") serverContextError("createDebounce");
1540
+ const { callback, wait, hidden = "flush", signal } = options;
1541
+ let stopped = false;
1542
+ let pending = false;
1543
+ let timer;
1544
+ let documentVisible = !document.hidden;
1545
+ let latest = void 0;
1546
+ function fire() {
1547
+ pending = false;
1548
+ callback(latest);
1549
+ }
1550
+ function clearTimer() {
1551
+ if (timer !== void 0) {
1552
+ clearTimeout(timer);
1553
+ timer = void 0;
1554
+ }
1555
+ }
1556
+ function onTimer() {
1557
+ timer = void 0;
1558
+ if (stopped || !pending) return;
1559
+ fire();
1560
+ }
1561
+ function startTimer() {
1562
+ clearTimer();
1563
+ timer = setTimeout(onTimer, wait);
1564
+ }
1565
+ function call(value) {
1566
+ if (stopped) return;
1567
+ latest = value;
1568
+ pending = true;
1569
+ if (documentVisible) startTimer();
1570
+ }
1571
+ function flush() {
1572
+ if (stopped || !pending) return;
1573
+ clearTimer();
1574
+ fire();
1575
+ }
1576
+ function cancel() {
1577
+ if (stopped) return;
1578
+ clearTimer();
1579
+ pending = false;
1580
+ }
1581
+ function onVisibilityChange() {
1582
+ documentVisible = !document.hidden;
1583
+ if (stopped) return;
1584
+ if (!documentVisible) {
1585
+ clearTimer();
1586
+ if (pending) if (hidden === "flush") fire();
1587
+ else pending = false;
1588
+ } else if (pending) startTimer();
1589
+ }
1590
+ function onPageShow(event) {
1591
+ if (!event.persisted) return;
1592
+ documentVisible = true;
1593
+ if (!stopped && pending) startTimer();
1594
+ }
1595
+ document.addEventListener("visibilitychange", onVisibilityChange);
1596
+ window.addEventListener("pageshow", onPageShow);
1597
+ let unlinkAbort;
1598
+ function stop() {
1599
+ if (stopped) return;
1600
+ stopped = true;
1601
+ unlinkAbort?.();
1602
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1603
+ window.removeEventListener("pageshow", onPageShow);
1604
+ clearTimer();
1605
+ pending = false;
1606
+ }
1607
+ unlinkAbort = linkAbortSignal(signal, stop);
1608
+ return {
1609
+ call,
1610
+ flush,
1611
+ cancel,
1612
+ get pending() {
1613
+ return pending;
1614
+ },
1615
+ stop
1616
+ };
1617
+ }
1618
+ //#endregion
1619
+ export { missingContextError as C, isPhaseError as S, linkAbortSignal 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, serverContextError as w, invalidDurationError as x, createTicker as y };
1418
1620
 
1419
- //# sourceMappingURL=scroll-DZvWyPQE.js.map
1621
+ //# sourceMappingURL=debounce-D5ugkrXN.js.map