Skip to content
Source
Sign inSign up

clock.ts

156 lines5.1 KB

1// @effect-diagnostics globalTimers:off globalDate:off - Render clocks for React hooks, outside an Effect runtime.
2​
3/**
4 * A `useSyncExternalStore`-shaped clock. Snapshots stay cached between
5 * notifications while subscribed, as `useSyncExternalStore` requires.
6 */
7export interface ClockStore<A> {
8 readonly subscribe: (listener: () => void) => () => void;
9 readonly getSnapshot: () => A;
11​
12// setTimeout fires immediately for delays beyond a signed 32-bit int.
13const MAX_TIMEOUT_MS = 2_147_483_647;
14​
15function makeTickStore<A>(read: () => A, periodMs: number): ClockStore<A> {
16 let value = read();
17 let timer: ReturnType<typeof setTimeout> | undefined;
18 const listeners = new Set<() => void>();
19​
20 // Each tick lands on a wall-clock boundary so every subscriber updates in
21 // the same frame instead of each drifting on its own phase.
22 const schedule = () => {
23 timer = setTimeout(
24 () => {
25 timer = undefined;
26 const next = read();
27 if (next !== value) {
28 value = next;
29 for (const listener of listeners) listener();
30 }
31 // Listeners may unsubscribe (stopping the clock) or resubscribe (arming
32 // a fresh timer) while notified; only continue a clock nobody re-armed.
33 if (listeners.size > 0 && timer === undefined) schedule();
34 },
35 periodMs - (Date.now() % periodMs),
36 );
37 };
38​
39 return {
40 subscribe: (listener) => {
41 if (listeners.size === 0) {
42 value = read();
43 schedule();
44 }
45 listeners.add(listener);
46 return () => {
47 listeners.delete(listener);
48 if (listeners.size === 0) {
49 clearTimeout(timer);
50 timer = undefined;
51 }
52 };
53 },
54 getSnapshot: () => {
55 if (timer === undefined) value = read();
56 return value;
57 },
58 };
60​
61/** Epoch ms floored to the second, advancing on each wall-clock second. */
62export const secondTick: ClockStore<number> = makeTickStore(
63 () => Math.floor(Date.now() / 1000) * 1000,
64 1000,
65);
66​
67/** ISO timestamp truncated to the minute (`YYYY-MM-DDTHH:mm`). */
68export const minuteTick: ClockStore<string> = makeTickStore(
69 () => new Date().toISOString().slice(0, 16),
70 60_000,
71);
72​
73export interface DeadlineStore extends ClockStore<boolean> {
74 /**
75 * Re-checks `Date.now()` and notifies if the deadline has passed. For callers
76 * that observe the deadline first, e.g. a click after a throttled or slept
77 * timer, so the snapshot catches up without waiting for the late timer.
78 */
79 readonly refresh: () => void;
81​
82const neverPassed: DeadlineStore = {
83 subscribe: () => () => {},
84 getSnapshot: () => false,
85 refresh: () => {},
86};
87​
88interface DeadlineEntry {
89 readonly store: DeadlineStore;
90 readonly listeners: Set<() => void>;
91 passed: boolean;
92 timer: ReturnType<typeof setTimeout> | undefined;
94​
95// The single owner of each subscribed deadline. Handles obtained before the
96// entry exists join it on subscribe, so they share its timer and notifications.
97const deadlines = new Map<number, DeadlineEntry>();
98​
99function passDeadline(entry: DeadlineEntry) {
100 clearTimeout(entry.timer);
101 entry.timer = undefined;
102 entry.passed = true;
103 for (const listener of entry.listeners) listener();
105​
106function armDeadline(entry: DeadlineEntry, deadlineMs: number) {
107 const delay = deadlineMs - Date.now();
108 if (delay <= 0) {
109 passDeadline(entry);
110 return;
111 }
112 entry.timer = setTimeout(() => armDeadline(entry, deadlineMs), Math.min(delay, MAX_TIMEOUT_MS));
114​
115/** The epoch-ms deadline `delayMs` from now, for `deadlineStore`. */
116export function deadlineAfter(delayMs: number): number {
117 return Date.now() + delayMs;
119​
120/**
121 * Whether `Date.now()` has reached `deadlineMs`. One timer per distinct
122 * deadline, shared by its subscribers; once passed the snapshot stays true.
123 * Deadlines register only while subscribed, so renders React discards leak nothing.
124 * A non-finite deadline never passes.
125 */
126export function deadlineStore(deadlineMs: number | null): DeadlineStore {
127 if (deadlineMs === null || !Number.isFinite(deadlineMs)) return neverPassed;
128 const existing = deadlines.get(deadlineMs);
129 if (existing !== undefined) return existing.store;
130​
131 const store: DeadlineStore = {
132 subscribe: (listener) => {
133 let entry = deadlines.get(deadlineMs);
134 if (entry === undefined) {
135 entry = { store, listeners: new Set(), passed: Date.now() >= deadlineMs, timer: undefined };
136 deadlines.set(deadlineMs, entry);
137 if (!entry.passed) armDeadline(entry, deadlineMs);
138 }
139 const joined = entry;
140 joined.listeners.add(listener);
141 return () => {
142 joined.listeners.delete(listener);
143 if (joined.listeners.size > 0) return;
144 clearTimeout(joined.timer);
145 joined.timer = undefined;
146 if (deadlines.get(deadlineMs) === joined) deadlines.delete(deadlineMs);
147 };
148 },
149 getSnapshot: () => deadlines.get(deadlineMs)?.passed ?? Date.now() >= deadlineMs,
150 refresh: () => {
151 const entry = deadlines.get(deadlineMs);
152 if (entry !== undefined && !entry.passed && Date.now() >= deadlineMs) passDeadline(entry);
153 },
154 };
155 return store;