Files
frigate/web/src/components/dynamic/TimeAgo.tsx
T
Josh HawkinsandGitHub 192aba901a
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386)
* refactor websockets to remove react-tracked

react 19 removed useReducer eager bailout, which broke react-tracked.

react-tracked works by wrapping state in a JavaScript Proxy. When a component reads state.someField, the proxy records that access. On the next state update, it compares only the fields each component actually touched and skips re-renders if those fields are unchanged. Under the hood, this relies on useReducer — and in React 18, useReducer had an "eager bail-out" that short-circuited rendering when the new state was === to the old state. React 19 removed that optimization, so every dispatch now schedules a render regardless, and the proxy comparison runs too late to prevent it.

useSyncExternalStore is a React primitive (added in 18, stable in 19) designed for exactly this pattern: subscribing to an external store:

useSyncExternalStore(
  subscribe,   // (listener) => unsubscribe — called when the store changes
  getSnapshot  // () => value — returns the current value for this subscriber
)

React calls getSnapshot during render and compares the result with Object.is. If the value is the same reference, the component bails out — no re-render. The key difference from react-tracked is that this bail-out is built into React's reconciler, not bolted on via proxy tricks and useReducer.

The per-topic subscription model makes this efficient. Instead of one global store where every subscriber has to check if their fields changed, each useWs("some/topic", ...) call subscribes only to that topic's listener set. When a message arrives for front_door/detect/state, only components subscribed to that exact topic get their listener fired → React calls their getSnapshot → Object.is compares the value → bail-out if unchanged. Components watching back_yard/detect/state are never even notified.

* remove react-tracked and react-use-websocket

* refactor usePolygonStates to use ws topic subscription

* fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds)

older events now correctly refresh every minute/hour instead of every second

* simplify

* clean up

* don't resend onconnect

* clean up

* remove patch
2026-03-11 09:02:51 -05:00

126 lines
3.6 KiB
TypeScript

import { t } from "i18next";
import type { JSX } from "react";
import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp {
/** OPTIONAL: classname */
className?: string;
/** The time to calculate time-ago from */
time: number;
/** OPTIONAL: overwrite current time */
currentTime?: Date;
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
dense?: boolean;
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
manualRefreshInterval?: number;
}
type TimeUnit = {
unit: string;
full: string;
value: number;
};
const timeAgo = ({
time,
currentTime = new Date(),
dense = false,
}: IProp): string => {
if (typeof time !== "number" || time < 0) return "Invalid Time Provided";
const pastTime: Date = new Date(time);
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
const timeUnits: TimeUnit[] = [
{ unit: "yr", full: "year", value: 31536000 },
{ unit: "mo", full: "month", value: 0 },
{ unit: "d", full: "day", value: 86400 },
{ unit: "h", full: "hour", value: 3600 },
{ unit: "m", full: "minute", value: 60 },
{ unit: "s", full: "second", value: 1 },
];
const elapsed: number = elapsedTime / 1000;
if (elapsed < 10) {
return t("time.justNow", { ns: "common" });
}
for (let i = 0; i < timeUnits.length; i++) {
// if months
if (i === 1) {
// Get the month and year for the time provided
const pastMonth = pastTime.getUTCMonth();
const pastYear = pastTime.getUTCFullYear();
// get current month and year
const currentMonth = currentTime.getUTCMonth();
const currentYear = currentTime.getUTCFullYear();
let monthDiff =
(currentYear - pastYear) * 12 + (currentMonth - pastMonth);
// check if the time provided is the previous month but not exceeded 1 month ago.
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
monthDiff--;
}
if (monthDiff > 0) {
const unitAmount = monthDiff;
return t("time.ago", {
ns: "common",
timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
time: unitAmount,
}),
});
}
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return t("time.ago", {
ns: "common",
timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
time: unitAmount,
}),
});
}
}
return "Invalid Time";
};
const TimeAgo: FunctionComponent<IProp> = ({
className,
time,
manualRefreshInterval,
...rest
}): JSX.Element => {
const [currentTime, setCurrentTime] = useState<Date>(new Date());
const refreshInterval = useMemo(() => {
if (manualRefreshInterval) {
return manualRefreshInterval;
}
const elapsedMs = currentTime.getTime() - time;
if (elapsedMs < 60000) {
return 1000; // refresh every second
} else if (elapsedMs < 3600000) {
return 60000; // refresh every minute
} else {
return 3600000; // refresh every hour
}
}, [currentTime, manualRefreshInterval, time]);
useEffect(() => {
const intervalId: NodeJS.Timeout = setInterval(() => {
setCurrentTime(new Date());
}, refreshInterval);
return () => clearInterval(intervalId);
}, [refreshInterval]);
const timeAgoValue = useMemo(
() => timeAgo({ time, currentTime, ...rest }),
[currentTime, rest, time],
);
return <span className={className}>{timeAgoValue}</span>;
};
export default TimeAgo;