phase 0.5.0 → 0.5.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
@@ -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);
@@ -1059,7 +1114,6 @@ function createMutation(options) {
1059
1114
  let _reason = "initial";
1060
1115
  let stopped = false;
1061
1116
  let pendingRecords = [];
1062
- let rafId = 0;
1063
1117
  function setPhase(phase, reason) {
1064
1118
  const prev = _phase;
1065
1119
  _phase = phase;
@@ -1067,20 +1121,15 @@ function createMutation(options) {
1067
1121
  if (prev !== phase) onPhaseChange?.(phase, reason);
1068
1122
  }
1069
1123
  function flushRecords() {
1070
- rafId = 0;
1071
1124
  if (stopped || pendingRecords.length === 0) return;
1072
1125
  const batch = pendingRecords;
1073
1126
  pendingRecords = [];
1074
1127
  onMutations(batch);
1075
1128
  }
1076
- function scheduleFlush() {
1077
- if (rafId !== 0) return;
1078
- rafId = requestAnimationFrame(flushRecords);
1079
- }
1080
1129
  const mo = new MutationObserver((records) => {
1081
1130
  if (stopped || _phase !== "observing") return;
1082
1131
  for (let i = 0, len = records.length; i < len; i++) pendingRecords[pendingRecords.length] = records[i];
1083
- scheduleFlush();
1132
+ scheduleInput(flushRecords);
1084
1133
  });
1085
1134
  function startObserving() {
1086
1135
  if (stopped || _phase === "observing") return;
@@ -1091,10 +1140,7 @@ function createMutation(options) {
1091
1140
  if (stopped || _phase === "paused") return;
1092
1141
  setPhase("paused", reason);
1093
1142
  mo.disconnect();
1094
- if (rafId !== 0) {
1095
- cancelAnimationFrame(rafId);
1096
- rafId = 0;
1097
- }
1143
+ cancelInput(flushRecords);
1098
1144
  pendingRecords = [];
1099
1145
  }
1100
1146
  let cleanupVisibility;
@@ -1138,10 +1184,7 @@ function createMutation(options) {
1138
1184
  unlinkAbort?.();
1139
1185
  cleanupVisibility?.();
1140
1186
  mo.disconnect();
1141
- if (rafId !== 0) {
1142
- cancelAnimationFrame(rafId);
1143
- rafId = 0;
1144
- }
1187
+ cancelInput(flushRecords);
1145
1188
  pendingRecords = [];
1146
1189
  setPhase("stopped", "disposed");
1147
1190
  }
@@ -1180,7 +1223,6 @@ function createPointer(options) {
1180
1223
  y: 0,
1181
1224
  active: false
1182
1225
  };
1183
- let rafId = 0;
1184
1226
  let lastClientX = 0;
1185
1227
  let lastClientY = 0;
1186
1228
  let dirty = false;
@@ -1191,7 +1233,6 @@ function createPointer(options) {
1191
1233
  if (prev !== phase) onPhaseChange?.(phase, reason);
1192
1234
  }
1193
1235
  function flush() {
1194
- rafId = 0;
1195
1236
  if (!dirty || stopped) return;
1196
1237
  dirty = false;
1197
1238
  const rect = element.getBoundingClientRect();
@@ -1199,15 +1240,8 @@ function createPointer(options) {
1199
1240
  _state.y = lastClientY - rect.top;
1200
1241
  onPointer(_state);
1201
1242
  }
1202
- function scheduleFlush() {
1203
- if (rafId !== 0) return;
1204
- rafId = requestAnimationFrame(flush);
1205
- }
1206
1243
  function cancelFlush() {
1207
- if (rafId !== 0) {
1208
- cancelAnimationFrame(rafId);
1209
- rafId = 0;
1210
- }
1244
+ cancelInput(flush);
1211
1245
  dirty = false;
1212
1246
  }
1213
1247
  function onPointerMove(e) {
@@ -1216,7 +1250,7 @@ function createPointer(options) {
1216
1250
  lastClientX = pe.clientX;
1217
1251
  lastClientY = pe.clientY;
1218
1252
  dirty = true;
1219
- scheduleFlush();
1253
+ scheduleInput(flush);
1220
1254
  }
1221
1255
  function onPointerEnter() {
1222
1256
  if (stopped) return;
@@ -1401,7 +1435,6 @@ function createScroll(options) {
1401
1435
  visibleX: 1,
1402
1436
  visibleY: 1
1403
1437
  };
1404
- let rafId = 0;
1405
1438
  let dirty = false;
1406
1439
  let geometryDirty = false;
1407
1440
  let listenersAttached = false;
@@ -1441,7 +1474,6 @@ function createScroll(options) {
1441
1474
  onScroll(_state);
1442
1475
  }
1443
1476
  function flush() {
1444
- rafId = 0;
1445
1477
  if (stopped || !dirty && !geometryDirty) return;
1446
1478
  if (geometryDirty) {
1447
1479
  geometryDirty = false;
@@ -1451,27 +1483,20 @@ function createScroll(options) {
1451
1483
  computePosition();
1452
1484
  onScroll(_state);
1453
1485
  }
1454
- function scheduleFlush() {
1455
- if (rafId !== 0) return;
1456
- rafId = requestAnimationFrame(flush);
1457
- }
1458
1486
  function cancelFlush() {
1459
- if (rafId !== 0) {
1460
- cancelAnimationFrame(rafId);
1461
- rafId = 0;
1462
- }
1487
+ cancelInput(flush);
1463
1488
  dirty = false;
1464
1489
  geometryDirty = false;
1465
1490
  }
1466
1491
  function onScrollEvent() {
1467
1492
  if (stopped) return;
1468
1493
  dirty = true;
1469
- scheduleFlush();
1494
+ scheduleInput(flush);
1470
1495
  }
1471
1496
  function onROResize() {
1472
1497
  if (stopped || !listenersAttached) return;
1473
1498
  geometryDirty = true;
1474
- scheduleFlush();
1499
+ scheduleInput(flush);
1475
1500
  }
1476
1501
  function attachListeners() {
1477
1502
  if (listenersAttached) return;
@@ -1586,7 +1611,6 @@ function createThrottle(options) {
1586
1611
  let stopped = false;
1587
1612
  let pending = false;
1588
1613
  let lastFire = 0;
1589
- let rafId = 0;
1590
1614
  let documentVisible = !document.hidden;
1591
1615
  let latest = void 0;
1592
1616
  function fire(now) {
@@ -1594,21 +1618,11 @@ function createThrottle(options) {
1594
1618
  pending = false;
1595
1619
  callback(latest);
1596
1620
  }
1597
- function cancelRaf() {
1598
- if (rafId !== 0) {
1599
- cancelAnimationFrame(rafId);
1600
- rafId = 0;
1601
- }
1602
- }
1603
- function tick() {
1604
- rafId = 0;
1621
+ function flushWhenDue() {
1605
1622
  if (stopped || !pending) return;
1606
1623
  const now = performance.now();
1607
1624
  if (now - lastFire >= interval) fire(now);
1608
- else rafId = requestAnimationFrame(tick);
1609
- }
1610
- function scheduleRaf() {
1611
- if (rafId === 0) rafId = requestAnimationFrame(tick);
1625
+ else return true;
1612
1626
  }
1613
1627
  function call(value) {
1614
1628
  if (stopped) return;
@@ -1627,17 +1641,17 @@ function createThrottle(options) {
1627
1641
  }
1628
1642
  if (trailing) {
1629
1643
  pending = true;
1630
- scheduleRaf();
1644
+ scheduleInput(flushWhenDue);
1631
1645
  }
1632
1646
  }
1633
1647
  function flush() {
1634
1648
  if (stopped || !pending) return;
1635
- cancelRaf();
1649
+ cancelInput(flushWhenDue);
1636
1650
  fire(performance.now());
1637
1651
  }
1638
1652
  function cancel() {
1639
1653
  if (stopped) return;
1640
- cancelRaf();
1654
+ cancelInput(flushWhenDue);
1641
1655
  pending = false;
1642
1656
  lastFire = 0;
1643
1657
  }
@@ -1645,15 +1659,15 @@ function createThrottle(options) {
1645
1659
  documentVisible = !document.hidden;
1646
1660
  if (stopped) return;
1647
1661
  if (!documentVisible) {
1648
- cancelRaf();
1662
+ cancelInput(flushWhenDue);
1649
1663
  if (pending) if (hidden === "flush") fire(performance.now());
1650
1664
  else pending = false;
1651
- } else if (pending) scheduleRaf();
1665
+ } else if (pending) scheduleInput(flushWhenDue);
1652
1666
  }
1653
1667
  function onPageShow(event) {
1654
1668
  if (!event.persisted) return;
1655
1669
  documentVisible = true;
1656
- if (!stopped && pending) scheduleRaf();
1670
+ if (!stopped && pending) scheduleInput(flushWhenDue);
1657
1671
  }
1658
1672
  document.addEventListener("visibilitychange", onVisibilityChange);
1659
1673
  window.addEventListener("pageshow", onPageShow);
@@ -1664,7 +1678,7 @@ function createThrottle(options) {
1664
1678
  unlinkAbort?.();
1665
1679
  document.removeEventListener("visibilitychange", onVisibilityChange);
1666
1680
  window.removeEventListener("pageshow", onPageShow);
1667
- cancelRaf();
1681
+ cancelInput(flushWhenDue);
1668
1682
  pending = false;
1669
1683
  }
1670
1684
  unlinkAbort = linkAbortSignal(signal, stop);
@@ -1770,4 +1784,4 @@ function createDebounce(options) {
1770
1784
  //#endregion
1771
1785
  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
1786
 
1773
- //# sourceMappingURL=debounce-Bh0QOKIX.js.map
1787
+ //# sourceMappingURL=debounce-CJYqsSXm.js.map