phase 0.4.2 → 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 {
@@ -93,42 +180,10 @@ function missingContextError(child, parent) {
93
180
  }
94
181
  //#endregion
95
182
  //#region src/core/tick/index.ts
96
- /** Prevents teleportation on resume. Matches motion's maxElapsed. */
183
+ /** Maximum amount delta may exceed the active FPS interval. */
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;
@@ -153,23 +208,27 @@ function advanceDeadline(deadline, now, interval) {
153
208
  return next + Math.floor(behind / interval) * interval;
154
209
  }
155
210
  /**
156
- * Core rAF loop primitive with FPS cap, delta clamping, and strong pause.
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);
226
+ let maxDeltaTime = minFrameTime + MAX_DELTA_MS;
167
227
  let _phase = "idle";
168
228
  let _reason = "initial";
169
- let lastTickTime = 0;
170
- let pauseStartTime = 0;
171
- let totalPausedTime = 0;
172
- let startTime = 0;
229
+ let lastTickTime = -1;
230
+ let elapsedTime = 0;
231
+ let frameCount = 0;
173
232
  let nextDueTime = 0;
174
233
  const frame = {
175
234
  time: 0,
@@ -179,20 +238,18 @@ function createTicker(options) {
179
238
  };
180
239
  function tick(now) {
181
240
  if (now < nextDueTime) return;
182
- const isFirstDelivery = lastTickTime === 0;
183
- const rawDelta = isFirstDelivery ? DEFAULT_FIRST_DELTA_MS : now - lastTickTime;
241
+ const isFirstDelivery = lastTickTime < 0;
242
+ const rawDelta = isFirstDelivery ? minFrameTime || DEFAULT_FIRST_DELTA_MS : now - lastTickTime;
184
243
  lastTickTime = now;
185
244
  if (minFrameTime > 0) nextDueTime = isFirstDelivery ? now + minFrameTime : advanceDeadline(nextDueTime, now, minFrameTime);
186
245
  frame.time = now;
187
- frame.delta = rawDelta > MAX_DELTA_MS ? MAX_DELTA_MS : rawDelta;
188
- frame.elapsed = now - startTime - totalPausedTime;
189
- frame.frame++;
246
+ frame.delta = rawDelta > maxDeltaTime ? maxDeltaTime : rawDelta;
247
+ elapsedTime += frame.delta;
248
+ frame.elapsed = elapsedTime;
249
+ frameCount++;
250
+ frame.frame = frameCount;
190
251
  onTick(frame);
191
252
  }
192
- const subscription = {
193
- callback: tick,
194
- joinedFrame: 0
195
- };
196
253
  function start() {
197
254
  if (_phase === "running") return;
198
255
  if (_phase === "stopped") tickerStoppedError();
@@ -202,41 +259,41 @@ function createTicker(options) {
202
259
  }
203
260
  _phase = "running";
204
261
  _reason = "started";
205
- startTime = performance.now();
206
- lastTickTime = 0;
262
+ lastTickTime = -1;
207
263
  nextDueTime = 0;
208
- totalPausedTime = 0;
264
+ elapsedTime = 0;
265
+ frameCount = 0;
209
266
  resetFrameState(frame);
210
- joinSharedClock(subscription);
267
+ joinTick(tick);
211
268
  }
212
269
  function pause() {
213
270
  if (_phase !== "running") return;
214
271
  _phase = "paused";
215
272
  _reason = "manual";
216
- pauseStartTime = performance.now();
217
- leaveSharedClock(subscription);
273
+ leaveTick(tick);
218
274
  }
219
275
  function resume() {
220
276
  if (_phase === "stopped") tickerStoppedError();
221
277
  if (_phase !== "paused") return;
222
- totalPausedTime += performance.now() - pauseStartTime;
223
- lastTickTime = 0;
278
+ lastTickTime = -1;
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();
231
- minFrameTime = resolveMinFrameTime("setFps", fps);
232
- nextDueTime = lastTickTime === 0 ? 0 : lastTickTime + minFrameTime;
286
+ const nextMinFrameTime = resolveMinFrameTime("setFps", fps);
287
+ minFrameTime = nextMinFrameTime;
288
+ maxDeltaTime = nextMinFrameTime + MAX_DELTA_MS;
289
+ nextDueTime = lastTickTime < 0 ? 0 : lastTickTime + minFrameTime;
233
290
  }
234
291
  function stop() {
235
292
  if (_phase === "stopped") return;
236
293
  _phase = "stopped";
237
294
  _reason = _reason === "initial" ? "disposed" : "manual";
238
295
  unlinkAbort?.();
239
- leaveSharedClock(subscription);
296
+ leaveTick(tick);
240
297
  }
241
298
  let unlinkAbort;
242
299
  unlinkAbort = linkAbortSignal(signal, stop);
@@ -1057,7 +1114,6 @@ function createMutation(options) {
1057
1114
  let _reason = "initial";
1058
1115
  let stopped = false;
1059
1116
  let pendingRecords = [];
1060
- let rafId = 0;
1061
1117
  function setPhase(phase, reason) {
1062
1118
  const prev = _phase;
1063
1119
  _phase = phase;
@@ -1065,20 +1121,15 @@ function createMutation(options) {
1065
1121
  if (prev !== phase) onPhaseChange?.(phase, reason);
1066
1122
  }
1067
1123
  function flushRecords() {
1068
- rafId = 0;
1069
1124
  if (stopped || pendingRecords.length === 0) return;
1070
1125
  const batch = pendingRecords;
1071
1126
  pendingRecords = [];
1072
1127
  onMutations(batch);
1073
1128
  }
1074
- function scheduleFlush() {
1075
- if (rafId !== 0) return;
1076
- rafId = requestAnimationFrame(flushRecords);
1077
- }
1078
1129
  const mo = new MutationObserver((records) => {
1079
1130
  if (stopped || _phase !== "observing") return;
1080
1131
  for (let i = 0, len = records.length; i < len; i++) pendingRecords[pendingRecords.length] = records[i];
1081
- scheduleFlush();
1132
+ scheduleInput(flushRecords);
1082
1133
  });
1083
1134
  function startObserving() {
1084
1135
  if (stopped || _phase === "observing") return;
@@ -1089,10 +1140,7 @@ function createMutation(options) {
1089
1140
  if (stopped || _phase === "paused") return;
1090
1141
  setPhase("paused", reason);
1091
1142
  mo.disconnect();
1092
- if (rafId !== 0) {
1093
- cancelAnimationFrame(rafId);
1094
- rafId = 0;
1095
- }
1143
+ cancelInput(flushRecords);
1096
1144
  pendingRecords = [];
1097
1145
  }
1098
1146
  let cleanupVisibility;
@@ -1136,10 +1184,7 @@ function createMutation(options) {
1136
1184
  unlinkAbort?.();
1137
1185
  cleanupVisibility?.();
1138
1186
  mo.disconnect();
1139
- if (rafId !== 0) {
1140
- cancelAnimationFrame(rafId);
1141
- rafId = 0;
1142
- }
1187
+ cancelInput(flushRecords);
1143
1188
  pendingRecords = [];
1144
1189
  setPhase("stopped", "disposed");
1145
1190
  }
@@ -1178,7 +1223,6 @@ function createPointer(options) {
1178
1223
  y: 0,
1179
1224
  active: false
1180
1225
  };
1181
- let rafId = 0;
1182
1226
  let lastClientX = 0;
1183
1227
  let lastClientY = 0;
1184
1228
  let dirty = false;
@@ -1189,7 +1233,6 @@ function createPointer(options) {
1189
1233
  if (prev !== phase) onPhaseChange?.(phase, reason);
1190
1234
  }
1191
1235
  function flush() {
1192
- rafId = 0;
1193
1236
  if (!dirty || stopped) return;
1194
1237
  dirty = false;
1195
1238
  const rect = element.getBoundingClientRect();
@@ -1197,15 +1240,8 @@ function createPointer(options) {
1197
1240
  _state.y = lastClientY - rect.top;
1198
1241
  onPointer(_state);
1199
1242
  }
1200
- function scheduleFlush() {
1201
- if (rafId !== 0) return;
1202
- rafId = requestAnimationFrame(flush);
1203
- }
1204
1243
  function cancelFlush() {
1205
- if (rafId !== 0) {
1206
- cancelAnimationFrame(rafId);
1207
- rafId = 0;
1208
- }
1244
+ cancelInput(flush);
1209
1245
  dirty = false;
1210
1246
  }
1211
1247
  function onPointerMove(e) {
@@ -1214,7 +1250,7 @@ function createPointer(options) {
1214
1250
  lastClientX = pe.clientX;
1215
1251
  lastClientY = pe.clientY;
1216
1252
  dirty = true;
1217
- scheduleFlush();
1253
+ scheduleInput(flush);
1218
1254
  }
1219
1255
  function onPointerEnter() {
1220
1256
  if (stopped) return;
@@ -1399,7 +1435,6 @@ function createScroll(options) {
1399
1435
  visibleX: 1,
1400
1436
  visibleY: 1
1401
1437
  };
1402
- let rafId = 0;
1403
1438
  let dirty = false;
1404
1439
  let geometryDirty = false;
1405
1440
  let listenersAttached = false;
@@ -1439,7 +1474,6 @@ function createScroll(options) {
1439
1474
  onScroll(_state);
1440
1475
  }
1441
1476
  function flush() {
1442
- rafId = 0;
1443
1477
  if (stopped || !dirty && !geometryDirty) return;
1444
1478
  if (geometryDirty) {
1445
1479
  geometryDirty = false;
@@ -1449,27 +1483,20 @@ function createScroll(options) {
1449
1483
  computePosition();
1450
1484
  onScroll(_state);
1451
1485
  }
1452
- function scheduleFlush() {
1453
- if (rafId !== 0) return;
1454
- rafId = requestAnimationFrame(flush);
1455
- }
1456
1486
  function cancelFlush() {
1457
- if (rafId !== 0) {
1458
- cancelAnimationFrame(rafId);
1459
- rafId = 0;
1460
- }
1487
+ cancelInput(flush);
1461
1488
  dirty = false;
1462
1489
  geometryDirty = false;
1463
1490
  }
1464
1491
  function onScrollEvent() {
1465
1492
  if (stopped) return;
1466
1493
  dirty = true;
1467
- scheduleFlush();
1494
+ scheduleInput(flush);
1468
1495
  }
1469
1496
  function onROResize() {
1470
1497
  if (stopped || !listenersAttached) return;
1471
1498
  geometryDirty = true;
1472
- scheduleFlush();
1499
+ scheduleInput(flush);
1473
1500
  }
1474
1501
  function attachListeners() {
1475
1502
  if (listenersAttached) return;
@@ -1584,7 +1611,6 @@ function createThrottle(options) {
1584
1611
  let stopped = false;
1585
1612
  let pending = false;
1586
1613
  let lastFire = 0;
1587
- let rafId = 0;
1588
1614
  let documentVisible = !document.hidden;
1589
1615
  let latest = void 0;
1590
1616
  function fire(now) {
@@ -1592,21 +1618,11 @@ function createThrottle(options) {
1592
1618
  pending = false;
1593
1619
  callback(latest);
1594
1620
  }
1595
- function cancelRaf() {
1596
- if (rafId !== 0) {
1597
- cancelAnimationFrame(rafId);
1598
- rafId = 0;
1599
- }
1600
- }
1601
- function tick() {
1602
- rafId = 0;
1621
+ function flushWhenDue() {
1603
1622
  if (stopped || !pending) return;
1604
1623
  const now = performance.now();
1605
1624
  if (now - lastFire >= interval) fire(now);
1606
- else rafId = requestAnimationFrame(tick);
1607
- }
1608
- function scheduleRaf() {
1609
- if (rafId === 0) rafId = requestAnimationFrame(tick);
1625
+ else return true;
1610
1626
  }
1611
1627
  function call(value) {
1612
1628
  if (stopped) return;
@@ -1625,17 +1641,17 @@ function createThrottle(options) {
1625
1641
  }
1626
1642
  if (trailing) {
1627
1643
  pending = true;
1628
- scheduleRaf();
1644
+ scheduleInput(flushWhenDue);
1629
1645
  }
1630
1646
  }
1631
1647
  function flush() {
1632
1648
  if (stopped || !pending) return;
1633
- cancelRaf();
1649
+ cancelInput(flushWhenDue);
1634
1650
  fire(performance.now());
1635
1651
  }
1636
1652
  function cancel() {
1637
1653
  if (stopped) return;
1638
- cancelRaf();
1654
+ cancelInput(flushWhenDue);
1639
1655
  pending = false;
1640
1656
  lastFire = 0;
1641
1657
  }
@@ -1643,15 +1659,15 @@ function createThrottle(options) {
1643
1659
  documentVisible = !document.hidden;
1644
1660
  if (stopped) return;
1645
1661
  if (!documentVisible) {
1646
- cancelRaf();
1662
+ cancelInput(flushWhenDue);
1647
1663
  if (pending) if (hidden === "flush") fire(performance.now());
1648
1664
  else pending = false;
1649
- } else if (pending) scheduleRaf();
1665
+ } else if (pending) scheduleInput(flushWhenDue);
1650
1666
  }
1651
1667
  function onPageShow(event) {
1652
1668
  if (!event.persisted) return;
1653
1669
  documentVisible = true;
1654
- if (!stopped && pending) scheduleRaf();
1670
+ if (!stopped && pending) scheduleInput(flushWhenDue);
1655
1671
  }
1656
1672
  document.addEventListener("visibilitychange", onVisibilityChange);
1657
1673
  window.addEventListener("pageshow", onPageShow);
@@ -1662,7 +1678,7 @@ function createThrottle(options) {
1662
1678
  unlinkAbort?.();
1663
1679
  document.removeEventListener("visibilitychange", onVisibilityChange);
1664
1680
  window.removeEventListener("pageshow", onPageShow);
1665
- cancelRaf();
1681
+ cancelInput(flushWhenDue);
1666
1682
  pending = false;
1667
1683
  }
1668
1684
  unlinkAbort = linkAbortSignal(signal, stop);
@@ -1768,4 +1784,4 @@ function createDebounce(options) {
1768
1784
  //#endregion
1769
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 };
1770
1786
 
1771
- //# sourceMappingURL=debounce-BkVwTxwI.js.map
1787
+ //# sourceMappingURL=debounce-CJYqsSXm.js.map