mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
121 lines
3.6 KiB
JavaScript
121 lines
3.6 KiB
JavaScript
// Shared Clock Hook
|
|
// Purpose: Lets display-only countdown components share one browser interval per cadence.
|
|
// Scope: Keeps timers out of control/safety paths; callers opt in only for UI text that can tolerate coarse updates.
|
|
import { useEffect, useState } from 'react';
|
|
|
|
const clockStores = new Map();
|
|
|
|
function nowMs() {
|
|
return Date.now();
|
|
}
|
|
|
|
function normalizeDelay(delayMs) {
|
|
const delay = Number(delayMs);
|
|
if (!Number.isFinite(delay) || delay <= 0) return 1000;
|
|
return Math.max(16, Math.round(delay));
|
|
}
|
|
|
|
function getStore(delayMs) {
|
|
const delay = normalizeDelay(delayMs);
|
|
const existing = clockStores.get(delay);
|
|
if (existing) return existing;
|
|
|
|
const store = {
|
|
delay,
|
|
timer: null,
|
|
visibilityHandler: null,
|
|
current: nowMs(),
|
|
listeners: new Set(),
|
|
};
|
|
clockStores.set(delay, store);
|
|
return store;
|
|
}
|
|
|
|
function stopStore(store) {
|
|
if (store.timer) {
|
|
clearInterval(store.timer);
|
|
store.timer = null;
|
|
}
|
|
if (store.visibilityHandler && typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', store.visibilityHandler);
|
|
store.visibilityHandler = null;
|
|
}
|
|
}
|
|
|
|
function publish(store) {
|
|
store.current = nowMs();
|
|
store.listeners.forEach((listener) => listener(store.current));
|
|
}
|
|
|
|
function ensureVisibilityHandler(store) {
|
|
if (store.visibilityHandler || typeof document === 'undefined') return;
|
|
store.visibilityHandler = () => {
|
|
if (!store.listeners.size) return;
|
|
if (document.visibilityState === 'visible') {
|
|
/*
|
|
Hidden tabs do not need display-only countdown work. When the tab comes
|
|
back, publish immediately so labels catch up before the next interval.
|
|
*/
|
|
publish(store);
|
|
startStore(store);
|
|
return;
|
|
}
|
|
if (store.timer) {
|
|
clearInterval(store.timer);
|
|
store.timer = null;
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', store.visibilityHandler);
|
|
}
|
|
|
|
function startStore(store) {
|
|
if (store.timer || !store.listeners.size) return;
|
|
ensureVisibilityHandler(store);
|
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
/*
|
|
One interval fans out to every mounted consumer using the same cadence. The
|
|
previous pattern created independent intervals in each countdown component,
|
|
which meant several panels could wake the main thread separately even though
|
|
they only needed the same "current time" value for human-readable labels.
|
|
*/
|
|
store.timer = setInterval(() => {
|
|
publish(store);
|
|
}, store.delay);
|
|
}
|
|
|
|
function subscribe(store, listener) {
|
|
store.listeners.add(listener);
|
|
startStore(store);
|
|
|
|
return () => {
|
|
store.listeners.delete(listener);
|
|
/*
|
|
Stop the interval as soon as the last consumer leaves. This matters for
|
|
tab panels and overlays because hidden or unmounted UI should not keep a
|
|
display clock alive just because another render path created it earlier.
|
|
*/
|
|
if (!store.listeners.size) {
|
|
stopStore(store);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function useSharedClock(delayMs = 1000, enabled = true) {
|
|
const [now, setNow] = useState(() => nowMs());
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return undefined;
|
|
const store = getStore(delayMs);
|
|
/*
|
|
Subscribing starts the shared interval but does not synchronously push
|
|
state from inside the effect. React's newer lint rules flag synchronous
|
|
effect-time state writes because they can cascade renders; the hook's
|
|
initial Date.now state is already fresh for the first paint, and later
|
|
updates arrive through the interval or visibility callback.
|
|
*/
|
|
return subscribe(store, setNow);
|
|
}, [delayMs, enabled]);
|
|
|
|
return now;
|
|
}
|