phase 0.5.0 → 0.5.2

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
@@ -36,9 +36,10 @@ function Orbit({ radius }) {
36
36
 
37
37
  - **Pauses when unseen.** Off-screen or in a background tab, work stops and CPU drops to zero.
38
38
  - **Respects reduced motion by default.** Accessibility is built in, not an opt-in.
39
- - **Never forces a reflow.** No `getBoundingClientRect`, no layout thrash, anywhere in the package.
39
+ - **Batches layout reads.** Element-relative pointer tracking reads one rect per dirty frame; scroll geometry is read on attachment or explicit measurement and coalesced after resize signals; other dimensions and visibility come from observers.
40
40
  - **Zero re-renders from the frame loop.** Per-frame work writes to refs and the DOM, never React state.
41
- - **Frame-locked shared clock.** Every animation on the page reads one clock, so nothing drifts out of sync.
41
+ - **Frame-locked shared clock.** Tickers using the same clock protocol read one timestamp, so they do not drift out of sync.
42
+ - **Input before frame loops.** Within one clock protocol, pointer, scroll, mutation, and throttle work queued before a frame flushes before its animation callbacks.
42
43
  - **Renders only what matters.** Skip painting off-screen content, mount non-critical UI when idle.
43
44
 
44
45
  Read the [full documentation](https://github.com/vercel-labs/phase#readme), install the [phase agent skill](https://github.com/vercel-labs/phase/tree/main/skills/phase), review the [changelog](https://github.com/vercel-labs/phase/blob/main/CHANGELOG.md), or see the [MIT license](https://github.com/vercel-labs/phase/blob/main/packages/phase/LICENSE).
@@ -22,6 +22,93 @@ function linkAbortSignal(signal, stop) {
22
22
  /** Shared empty unlink for the no-signal and already-aborted paths. */
23
23
  function unlinkNoop() {}
24
24
  //#endregion
25
+ //#region src/core/_internal/clock/index.ts
26
+ const EXECUTING = -1;
27
+ const RESCHEDULED = -2;
28
+ let sharedClock;
29
+ let inputError;
30
+ let inputFailed = false;
31
+ function getSharedClock() {
32
+ const registry = globalThis;
33
+ return registry[Symbol.for("phase.clock@2")] ??= {
34
+ rafId: null,
35
+ frame: 0,
36
+ time: 0,
37
+ ticks: /* @__PURE__ */ new Map(),
38
+ input: /* @__PURE__ */ new Map()
39
+ };
40
+ }
41
+ function stopIfEmpty() {
42
+ if (sharedClock.ticks.size === 0 && sharedClock.input.size === 0 && sharedClock.rafId !== null) {
43
+ cancelAnimationFrame(sharedClock.rafId);
44
+ sharedClock.rafId = null;
45
+ }
46
+ }
47
+ function dispatchInput(joinedFrame, callback) {
48
+ if (joinedFrame < sharedClock.frame) {
49
+ sharedClock.input.set(callback, EXECUTING);
50
+ let keep = false;
51
+ try {
52
+ keep = callback(sharedClock.time) === true;
53
+ } catch (error) {
54
+ if (!inputFailed) inputError = error;
55
+ inputFailed = true;
56
+ }
57
+ const state = sharedClock.input.get(callback);
58
+ if (state !== void 0 && (keep || state !== EXECUTING)) {
59
+ sharedClock.input.set(callback, sharedClock.frame);
60
+ scheduleFrame();
61
+ } else sharedClock.input.delete(callback);
62
+ }
63
+ }
64
+ function dispatchTick(joinedFrame, callback) {
65
+ if (joinedFrame < sharedClock.frame) callback(sharedClock.time);
66
+ }
67
+ function tick(time) {
68
+ sharedClock.time = time;
69
+ sharedClock.frame++;
70
+ inputFailed = false;
71
+ sharedClock.rafId = sharedClock.ticks.size === 0 ? null : requestAnimationFrame(tick);
72
+ sharedClock.input.forEach(dispatchInput);
73
+ const failed = inputFailed;
74
+ const error = inputError;
75
+ inputFailed = false;
76
+ inputError = void 0;
77
+ sharedClock.ticks.forEach(dispatchTick);
78
+ stopIfEmpty();
79
+ if (failed) throw error;
80
+ }
81
+ function scheduleFrame() {
82
+ if (sharedClock.rafId === null) sharedClock.rafId = requestAnimationFrame(tick);
83
+ }
84
+ function joinTick(callback) {
85
+ sharedClock ??= getSharedClock();
86
+ sharedClock.ticks.set(callback, sharedClock.frame);
87
+ scheduleFrame();
88
+ }
89
+ function leaveTick(callback) {
90
+ if (!sharedClock) return;
91
+ sharedClock.ticks.delete(callback);
92
+ stopIfEmpty();
93
+ }
94
+ function scheduleInput(callback) {
95
+ sharedClock ??= getSharedClock();
96
+ const state = sharedClock.input.get(callback);
97
+ if (state === EXECUTING) {
98
+ sharedClock.input.set(callback, RESCHEDULED);
99
+ scheduleFrame();
100
+ return;
101
+ }
102
+ if (state !== void 0) return;
103
+ sharedClock.input.set(callback, sharedClock.frame);
104
+ scheduleFrame();
105
+ }
106
+ function cancelInput(callback) {
107
+ if (!sharedClock) return;
108
+ sharedClock.input.delete(callback);
109
+ stopIfEmpty();
110
+ }
111
+ //#endregion
25
112
  //#region src/core/_internal/errors/index.ts
26
113
  /** Lightweight structured error for phase. */
27
114
  var PhaseError = class extends Error {
@@ -97,38 +184,6 @@ function missingContextError(child, parent) {
97
184
  const MAX_DELTA_MS = 40;
98
185
  /** Default first-frame delta when no previous tick exists. */
99
186
  const DEFAULT_FIRST_DELTA_MS = 16.67;
100
- let sharedClock;
101
- function getSharedClock() {
102
- const registry = globalThis;
103
- return registry[Symbol.for("phase.clock@1")] ??= {
104
- rafId: null,
105
- frame: 0,
106
- time: 0,
107
- subscribers: /* @__PURE__ */ new Set()
108
- };
109
- }
110
- function dispatchSharedSubscription(subscription) {
111
- if (subscription.joinedFrame < sharedClock.frame) subscription.callback(sharedClock.time);
112
- }
113
- function sharedTick(time) {
114
- sharedClock.time = time;
115
- sharedClock.frame++;
116
- sharedClock.rafId = requestAnimationFrame(sharedTick);
117
- sharedClock.subscribers.forEach(dispatchSharedSubscription);
118
- }
119
- function joinSharedClock(subscription) {
120
- const wasEmpty = sharedClock.subscribers.size === 0;
121
- subscription.joinedFrame = sharedClock.frame;
122
- sharedClock.subscribers.add(subscription);
123
- if (wasEmpty) sharedClock.rafId = requestAnimationFrame(sharedTick);
124
- }
125
- function leaveSharedClock(subscription) {
126
- sharedClock.subscribers.delete(subscription);
127
- if (sharedClock.subscribers.size === 0 && sharedClock.rafId !== null) {
128
- cancelAnimationFrame(sharedClock.rafId);
129
- sharedClock.rafId = null;
130
- }
131
- }
132
187
  function resetFrameState(state) {
133
188
  state.time = 0;
134
189
  state.delta = 0;
@@ -156,12 +211,16 @@ function advanceDeadline(deadline, now, interval) {
156
211
  * Low-level requestAnimationFrame loop with an optional FPS limit and pause controls.
157
212
  *
158
213
  * @remarks
214
+ * Event-derived callbacks sharing this clock protocol run before `onTick` when
215
+ * queued before frame dispatch. A callback first queued during dispatch runs
216
+ * in the next frame; additional work can coalesce into an eligible callback
217
+ * that has not run yet.
218
+ *
159
219
  * `FrameState` is reused across frames. Do not store a reference to it.
160
220
  * Read values immediately in your `onTick` callback.
161
221
  */
162
222
  function createTicker(options) {
163
223
  if (typeof requestAnimationFrame === "undefined") serverContextError("createTicker");
164
- sharedClock ??= getSharedClock();
165
224
  const { onTick, signal } = options;
166
225
  let minFrameTime = resolveMinFrameTime("createTicker", options.fps);
167
226
  let maxDeltaTime = minFrameTime + MAX_DELTA_MS;
@@ -191,10 +250,6 @@ function createTicker(options) {
191
250
  frame.frame = frameCount;
192
251
  onTick(frame);
193
252
  }
194
- const subscription = {
195
- callback: tick,
196
- joinedFrame: 0
197
- };
198
253
  function start() {
199
254
  if (_phase === "running") return;
200
255
  if (_phase === "stopped") tickerStoppedError();
@@ -209,13 +264,13 @@ function createTicker(options) {
209
264
  elapsedTime = 0;
210
265
  frameCount = 0;
211
266
  resetFrameState(frame);
212
- joinSharedClock(subscription);
267
+ joinTick(tick);
213
268
  }
214
269
  function pause() {
215
270
  if (_phase !== "running") return;
216
271
  _phase = "paused";
217
272
  _reason = "manual";
218
- leaveSharedClock(subscription);
273
+ leaveTick(tick);
219
274
  }
220
275
  function resume() {
221
276
  if (_phase === "stopped") tickerStoppedError();
@@ -224,7 +279,7 @@ function createTicker(options) {
224
279
  nextDueTime = 0;
225
280
  _phase = "running";
226
281
  _reason = "resumed";
227
- joinSharedClock(subscription);
282
+ joinTick(tick);
228
283
  }
229
284
  function setFps(fps) {
230
285
  if (_phase === "stopped") tickerStoppedError();
@@ -238,7 +293,7 @@ function createTicker(options) {
238
293
  _phase = "stopped";
239
294
  _reason = _reason === "initial" ? "disposed" : "manual";
240
295
  unlinkAbort?.();
241
- leaveSharedClock(subscription);
296
+ leaveTick(tick);
242
297
  }
243
298
  let unlinkAbort;
244
299
  unlinkAbort = linkAbortSignal(signal, stop);
@@ -272,6 +327,8 @@ function isDocument(target) {
272
327
  //#endregion
273
328
  //#region src/core/_internal/pool/io-pool.ts
274
329
  const pool$1 = /* @__PURE__ */ new Map();
330
+ const rootIds = /* @__PURE__ */ new WeakMap();
331
+ let nextRootId = 1;
275
332
  /**
276
333
  * Observe an element via a shared IntersectionObserver pool.
277
334
  * Elements with identical options share one IO instance. An element may have
@@ -327,7 +384,18 @@ function observeIntersection(options) {
327
384
  * IO options are immutable after construction, so identical options can share.
328
385
  */
329
386
  function getPoolKey(opts) {
330
- return `${opts.root ? "custom" : "null"}|${opts.rootMargin ?? "0px"}|${Array.isArray(opts.threshold) ? opts.threshold.join(",") : String(opts.threshold ?? 0)}`;
387
+ let root = "null";
388
+ if (opts.root) {
389
+ let rootId = rootIds.get(opts.root);
390
+ if (rootId === void 0) {
391
+ rootId = nextRootId++;
392
+ rootIds.set(opts.root, rootId);
393
+ }
394
+ root = String(rootId);
395
+ }
396
+ const margin = opts.rootMargin ?? "0px";
397
+ const threshold = Array.isArray(opts.threshold) ? opts.threshold.join(",") : String(opts.threshold ?? 0);
398
+ return `${root}|${margin}|${threshold}`;
331
399
  }
332
400
  /** Return an existing pool entry for this key, or create and register a new one. */
333
401
  function getOrCreatePoolEntry(key, options) {
@@ -1059,7 +1127,6 @@ function createMutation(options) {
1059
1127
  let _reason = "initial";
1060
1128
  let stopped = false;
1061
1129
  let pendingRecords = [];
1062
- let rafId = 0;
1063
1130
  function setPhase(phase, reason) {
1064
1131
  const prev = _phase;
1065
1132
  _phase = phase;
@@ -1067,20 +1134,15 @@ function createMutation(options) {
1067
1134
  if (prev !== phase) onPhaseChange?.(phase, reason);
1068
1135
  }
1069
1136
  function flushRecords() {
1070
- rafId = 0;
1071
1137
  if (stopped || pendingRecords.length === 0) return;
1072
1138
  const batch = pendingRecords;
1073
1139
  pendingRecords = [];
1074
1140
  onMutations(batch);
1075
1141
  }
1076
- function scheduleFlush() {
1077
- if (rafId !== 0) return;
1078
- rafId = requestAnimationFrame(flushRecords);
1079
- }
1080
1142
  const mo = new MutationObserver((records) => {
1081
1143
  if (stopped || _phase !== "observing") return;
1082
1144
  for (let i = 0, len = records.length; i < len; i++) pendingRecords[pendingRecords.length] = records[i];
1083
- scheduleFlush();
1145
+ scheduleInput(flushRecords);
1084
1146
  });
1085
1147
  function startObserving() {
1086
1148
  if (stopped || _phase === "observing") return;
@@ -1091,10 +1153,7 @@ function createMutation(options) {
1091
1153
  if (stopped || _phase === "paused") return;
1092
1154
  setPhase("paused", reason);
1093
1155
  mo.disconnect();
1094
- if (rafId !== 0) {
1095
- cancelAnimationFrame(rafId);
1096
- rafId = 0;
1097
- }
1156
+ cancelInput(flushRecords);
1098
1157
  pendingRecords = [];
1099
1158
  }
1100
1159
  let cleanupVisibility;
@@ -1138,10 +1197,7 @@ function createMutation(options) {
1138
1197
  unlinkAbort?.();
1139
1198
  cleanupVisibility?.();
1140
1199
  mo.disconnect();
1141
- if (rafId !== 0) {
1142
- cancelAnimationFrame(rafId);
1143
- rafId = 0;
1144
- }
1200
+ cancelInput(flushRecords);
1145
1201
  pendingRecords = [];
1146
1202
  setPhase("stopped", "disposed");
1147
1203
  }
@@ -1180,7 +1236,6 @@ function createPointer(options) {
1180
1236
  y: 0,
1181
1237
  active: false
1182
1238
  };
1183
- let rafId = 0;
1184
1239
  let lastClientX = 0;
1185
1240
  let lastClientY = 0;
1186
1241
  let dirty = false;
@@ -1191,7 +1246,6 @@ function createPointer(options) {
1191
1246
  if (prev !== phase) onPhaseChange?.(phase, reason);
1192
1247
  }
1193
1248
  function flush() {
1194
- rafId = 0;
1195
1249
  if (!dirty || stopped) return;
1196
1250
  dirty = false;
1197
1251
  const rect = element.getBoundingClientRect();
@@ -1199,15 +1253,8 @@ function createPointer(options) {
1199
1253
  _state.y = lastClientY - rect.top;
1200
1254
  onPointer(_state);
1201
1255
  }
1202
- function scheduleFlush() {
1203
- if (rafId !== 0) return;
1204
- rafId = requestAnimationFrame(flush);
1205
- }
1206
1256
  function cancelFlush() {
1207
- if (rafId !== 0) {
1208
- cancelAnimationFrame(rafId);
1209
- rafId = 0;
1210
- }
1257
+ cancelInput(flush);
1211
1258
  dirty = false;
1212
1259
  }
1213
1260
  function onPointerMove(e) {
@@ -1216,7 +1263,7 @@ function createPointer(options) {
1216
1263
  lastClientX = pe.clientX;
1217
1264
  lastClientY = pe.clientY;
1218
1265
  dirty = true;
1219
- scheduleFlush();
1266
+ scheduleInput(flush);
1220
1267
  }
1221
1268
  function onPointerEnter() {
1222
1269
  if (stopped) return;
@@ -1401,7 +1448,6 @@ function createScroll(options) {
1401
1448
  visibleX: 1,
1402
1449
  visibleY: 1
1403
1450
  };
1404
- let rafId = 0;
1405
1451
  let dirty = false;
1406
1452
  let geometryDirty = false;
1407
1453
  let listenersAttached = false;
@@ -1441,7 +1487,6 @@ function createScroll(options) {
1441
1487
  onScroll(_state);
1442
1488
  }
1443
1489
  function flush() {
1444
- rafId = 0;
1445
1490
  if (stopped || !dirty && !geometryDirty) return;
1446
1491
  if (geometryDirty) {
1447
1492
  geometryDirty = false;
@@ -1451,27 +1496,20 @@ function createScroll(options) {
1451
1496
  computePosition();
1452
1497
  onScroll(_state);
1453
1498
  }
1454
- function scheduleFlush() {
1455
- if (rafId !== 0) return;
1456
- rafId = requestAnimationFrame(flush);
1457
- }
1458
1499
  function cancelFlush() {
1459
- if (rafId !== 0) {
1460
- cancelAnimationFrame(rafId);
1461
- rafId = 0;
1462
- }
1500
+ cancelInput(flush);
1463
1501
  dirty = false;
1464
1502
  geometryDirty = false;
1465
1503
  }
1466
1504
  function onScrollEvent() {
1467
1505
  if (stopped) return;
1468
1506
  dirty = true;
1469
- scheduleFlush();
1507
+ scheduleInput(flush);
1470
1508
  }
1471
1509
  function onROResize() {
1472
1510
  if (stopped || !listenersAttached) return;
1473
1511
  geometryDirty = true;
1474
- scheduleFlush();
1512
+ scheduleInput(flush);
1475
1513
  }
1476
1514
  function attachListeners() {
1477
1515
  if (listenersAttached) return;
@@ -1586,7 +1624,6 @@ function createThrottle(options) {
1586
1624
  let stopped = false;
1587
1625
  let pending = false;
1588
1626
  let lastFire = 0;
1589
- let rafId = 0;
1590
1627
  let documentVisible = !document.hidden;
1591
1628
  let latest = void 0;
1592
1629
  function fire(now) {
@@ -1594,21 +1631,11 @@ function createThrottle(options) {
1594
1631
  pending = false;
1595
1632
  callback(latest);
1596
1633
  }
1597
- function cancelRaf() {
1598
- if (rafId !== 0) {
1599
- cancelAnimationFrame(rafId);
1600
- rafId = 0;
1601
- }
1602
- }
1603
- function tick() {
1604
- rafId = 0;
1634
+ function flushWhenDue() {
1605
1635
  if (stopped || !pending) return;
1606
1636
  const now = performance.now();
1607
1637
  if (now - lastFire >= interval) fire(now);
1608
- else rafId = requestAnimationFrame(tick);
1609
- }
1610
- function scheduleRaf() {
1611
- if (rafId === 0) rafId = requestAnimationFrame(tick);
1638
+ else return true;
1612
1639
  }
1613
1640
  function call(value) {
1614
1641
  if (stopped) return;
@@ -1627,17 +1654,17 @@ function createThrottle(options) {
1627
1654
  }
1628
1655
  if (trailing) {
1629
1656
  pending = true;
1630
- scheduleRaf();
1657
+ scheduleInput(flushWhenDue);
1631
1658
  }
1632
1659
  }
1633
1660
  function flush() {
1634
1661
  if (stopped || !pending) return;
1635
- cancelRaf();
1662
+ cancelInput(flushWhenDue);
1636
1663
  fire(performance.now());
1637
1664
  }
1638
1665
  function cancel() {
1639
1666
  if (stopped) return;
1640
- cancelRaf();
1667
+ cancelInput(flushWhenDue);
1641
1668
  pending = false;
1642
1669
  lastFire = 0;
1643
1670
  }
@@ -1645,15 +1672,15 @@ function createThrottle(options) {
1645
1672
  documentVisible = !document.hidden;
1646
1673
  if (stopped) return;
1647
1674
  if (!documentVisible) {
1648
- cancelRaf();
1675
+ cancelInput(flushWhenDue);
1649
1676
  if (pending) if (hidden === "flush") fire(performance.now());
1650
1677
  else pending = false;
1651
- } else if (pending) scheduleRaf();
1678
+ } else if (pending) scheduleInput(flushWhenDue);
1652
1679
  }
1653
1680
  function onPageShow(event) {
1654
1681
  if (!event.persisted) return;
1655
1682
  documentVisible = true;
1656
- if (!stopped && pending) scheduleRaf();
1683
+ if (!stopped && pending) scheduleInput(flushWhenDue);
1657
1684
  }
1658
1685
  document.addEventListener("visibilitychange", onVisibilityChange);
1659
1686
  window.addEventListener("pageshow", onPageShow);
@@ -1664,7 +1691,7 @@ function createThrottle(options) {
1664
1691
  unlinkAbort?.();
1665
1692
  document.removeEventListener("visibilitychange", onVisibilityChange);
1666
1693
  window.removeEventListener("pageshow", onPageShow);
1667
- cancelRaf();
1694
+ cancelInput(flushWhenDue);
1668
1695
  pending = false;
1669
1696
  }
1670
1697
  unlinkAbort = linkAbortSignal(signal, stop);
@@ -1770,4 +1797,4 @@ function createDebounce(options) {
1770
1797
  //#endregion
1771
1798
  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 };
1772
1799
 
1773
- //# sourceMappingURL=debounce-Bh0QOKIX.js.map
1800
+ //# sourceMappingURL=debounce-B9PFEG-M.js.map