import { MINUTE } from "@kablamo/kerosene";
import * as React from "react";
interface CurrentTimeSubscription {
lastUpdatedAt: number;
listener: () => void;
period: number;
}
class CurrentTimeEmitter {
private currentTime: number;
/**
* Stores the handler returned by `setInterval`
*/
private interval?: number;
/**
* Stores the current interval period. Set to `Infinity` when no interval is active
*/
private period = Infinity;
private subscribers: readonly CurrentTimeSubscription[] = [];
constructor(
/**
* Stores the time of initial render for SSR. The default here is probably not useful for SSR, so it is strongly
* recommended to use `` with an `ssrTime` when using SSR and hydration. But having a default
* means that use of the `useCurrentTime()` hook will not crash when using SSR.
*/
private ssrTime = new Date("2000-01-01T00:00:00.000Z").getTime(),
/**
* Stores the default interval period which will be used if none is specified
*/
private defaultPeriod = MINUTE,
) {
this.currentTime = ssrTime;
}
/**
* `getSnapshot()` function used by `React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)`
*/
public readonly getCurrentTime = () => this.currentTime;
/**
* `getServerSnapshot()` function used by `React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)`
*/
public readonly getSsrTime = () => this.ssrTime;
/**
* Updates `this.currentTime` and calls all subscribers which need an update
*/
private update = () => {
this.currentTime = Date.now();
this.subscribers
.filter(
({ period, lastUpdatedAt }) =>
// Include subscribers which are within half their interval period of needing an update
this.currentTime >= lastUpdatedAt + period / 2,
)
.forEach((s) => {
// eslint-disable-next-line no-param-reassign
s.lastUpdatedAt = this.currentTime;
s.listener();
});
};
/**
* Mark all subscribers as requiring an update as soon as the next call to `this.update()`
*/
private markAllForUpdate = () => {
// eslint-disable-next-line no-param-reassign
this.subscribers.forEach((s) => (s.lastUpdatedAt = 0));
};
/**
* Handles `document` `"visibilitychange"` events by disabling the interval whilst the document is hidden, and
* re-running updates when the page is resumed.
*/
private onVisibilityChange = () => {
const { hidden = false } = document;
if (hidden) {
window.clearInterval(this.interval);
delete this.interval;
} else if (this.subscribers.length) {
this.period = Math.min(...this.subscribers.map((s) => s.period))!;
this.interval = window.setInterval(this.update, this.period);
}
this.markAllForUpdate();
this.update();
};
public readonly subscribe = (
listener: () => void,
period = this.defaultPeriod,
) => {
if (!this.subscribers.length) {
// If there were previously no subscribers, start listening to the `"visibilitychange"` event
document.addEventListener("visibilitychange", this.onVisibilityChange);
}
// If the new period is less than the period of the currently operating interval
if (period < this.period) {
// Clear the existing interval
window.clearInterval(this.interval);
// Update to the more frequent period of this subscriber
this.period = period;
// Create a new interval with the updated period
this.interval = window.setInterval(this.update, this.period);
}
// Add the new subscriber
this.subscribers = [
...this.subscribers,
{ listener, period, lastUpdatedAt: 0 },
];
this.update();
// Return a function which removes this subscription
return () => {
// Remove this subscription
this.subscribers = this.subscribers.filter(
(s) => s.listener !== listener,
);
// If there are no more subscribers after removing this one, we need to cleanup
if (!this.subscribers.length) {
// Clear the existing interval
window.clearInterval(this.interval);
delete this.interval;
// Set the period to indicate that there is no interval
this.period = Infinity;
// Remove the `"visibilitychange"` listener
document.removeEventListener(
"visibilitychange",
this.onVisibilityChange,
);
} else {
// Find the minimum period of the remaining listeners
const newPeriod = Math.min(...this.subscribers.map((s) => s.period));
// If the interval period needs updating
if (newPeriod !== this.period) {
// Clear the existing interval
window.clearInterval(this.interval);
this.period = newPeriod;
// Create a new interval with the updated period
this.interval = window.setInterval(this.update, this.period);
// Force an update soon, just in case the last interval was about to fire before it was cleared
this.markAllForUpdate();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.resolve().then(() => this.update());
}
}
};
};
}
// Set up the context with a default `CurrentTimeEmitter` singleton so that the `useCurrentTime()` hook may be used
// ergonimically without `` when there is no server side rendering
const CurrentTimeEmitterContext = React.createContext(new CurrentTimeEmitter());
/**
* Custom hook which uses a shared `CurrentTimeEmitter` class to listen for time updates in a performant way.
* Will update at least once every `period` milliseconds whilst the page is visible. Uses only a single interval to
* avoid overloading the browser when there are a large number of components listening to the time. Whilst this hook
* will attempt to updates components only as-required, it is not recommended to use this hook for extremely frequent
* updates (sub 1-second) and for such specific cases, `requestAnimationFrame` should be used instead.
* @param period Interval period
* @returns `currentTime`
*/
export default function useCurrentTime(period?: number): number {
const emitter = React.useContext(CurrentTimeEmitterContext);
const subscribe = React.useCallback(
(callback: () => void) => {
return emitter.subscribe(callback, period);
},
[emitter, period],
);
return React.useSyncExternalStore(
subscribe,
emitter.getCurrentTime,
emitter.getSsrTime,
);
}
export interface CurrentTimeProviderProps {
children?: React.ReactNode;
defaultPeriod?: number;
ssrTime?: number;
}
/**
* Context Provider for the CurrentTimeEmitter used internally by the `useCurrentTime` hook.
* Recommended for use when using SSR so that on initial render and hydration a consistent and correct time will be used.
* @param props.children
* @param props.defaultPeriod
* @param props.ssrTime Unix epoch milliseconds for initial SSR render
*/
export const CurrentTimeProvider = ({
children,
defaultPeriod,
ssrTime,
}: CurrentTimeProviderProps) => (
new CurrentTimeEmitter(ssrTime, defaultPeriod),
[ssrTime, defaultPeriod],
)}
>
{children}
);