- {"\u00a9"} {new Date().getFullYear()} Coder Technologies, Inc.
+ {"\u00a9"} {year} Coder Technologies, Inc.
diff --git a/site/src/hooks/useTime.ts b/site/src/hooks/useTime.ts
deleted file mode 100644
index 7f03c05225017..0000000000000
--- a/site/src/hooks/useTime.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useEffect, useState } from "react";
-import { useEffectEvent } from "./hookPolyfills";
-
-interface UseTimeOptions {
- /**
- * Can be set to `true` to disable checking for updates in circumstances where it is known
- * that there is no work to do.
- */
- disabled?: boolean;
-
- /**
- * The amount of time in milliseconds that should pass between checking for updates.
- */
- interval?: number;
-}
-
-/**
- * useTime allows a component to rerender over time without a corresponding state change.
- * An example could be a relative timestamp (eg. "in 5 minutes") that should count down as it
- * approaches.
- */
-export function useTime(func: () => T, options: UseTimeOptions = {}): T {
- const [computedValue, setComputedValue] = useState(() => func());
- const { disabled = false, interval = 1000 } = options;
-
- const thunk = useEffectEvent(func);
-
- useEffect(() => {
- if (disabled) {
- return;
- }
-
- const handle = setInterval(() => {
- setComputedValue(() => thunk());
- }, interval);
-
- return () => {
- clearInterval(handle);
- };
- }, [thunk, disabled, interval]);
-
- return computedValue;
-}
diff --git a/site/src/hooks/useTimeSync.tsx b/site/src/hooks/useTimeSync.tsx
new file mode 100644
index 0000000000000..6ee1d66659a85
--- /dev/null
+++ b/site/src/hooks/useTimeSync.tsx
@@ -0,0 +1,334 @@
+/**
+ * @todo Things that still need to be done before this can be called done:
+ * 1. Add tests and address any bugs
+ */
+import {
+ type FC,
+ type PropsWithChildren,
+ createContext,
+ useCallback,
+ useContext,
+ useId,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import {
+ type SubscriptionEntry,
+ TimeSync,
+ type TimeSyncInitOptions,
+ defaultOptions,
+} from "utils/TimeSync";
+import { useEffectEvent } from "./hookPolyfills";
+
+export const TARGET_REFRESH_ONE_SECOND = 1_000;
+export const TARGET_REFRESH_ONE_MINUTE = 60 * 1_000;
+export const TARGET_REFRESH_ONE_HOUR = 60 * 60 * 1_000;
+export const TARGET_REFRESH_ONE_DAY = 24 * 60 * 60 * 1_000;
+
+type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
+
+type SelectCallback = (newSnapshot: Date) => unknown;
+
+type ReactSubscriptionEntry = Readonly<
+ SubscriptionEntry & {
+ select?: SelectCallback;
+ }
+>;
+
+// Need to wrap each value that we put in the selection cache, so that when we
+// try to retrieve a value, it's easy to differentiate between a value being
+// undefined because that's an explicit selection value, versus it being
+// undefined because we forgot to set it in the cache
+type SelectionCacheEntry = Readonly<{ value: unknown }>;
+
+interface ReactTimeSyncApi {
+ subscribe: (entry: ReactSubscriptionEntry) => () => void;
+ getTimeSnapshot: () => Date;
+ getSelectionSnapshot: (id: string) => T;
+ invalidateSelection: (id: string, select?: SelectCallback) => void;
+}
+
+class ReactTimeSync implements ReactTimeSyncApi {
+ readonly #timeSync: TimeSync;
+ readonly #selectionCache: Map;
+
+ constructor(options: Partial) {
+ this.#timeSync = new TimeSync(options);
+ this.#selectionCache = new Map();
+ }
+
+ // All functions that are part of the public interface must be defined as
+ // arrow functions, so they can be passed around React without losing their
+ // `this` context
+
+ getTimeSnapshot = () => {
+ return this.#timeSync.getTimeSnapshot();
+ };
+
+ subscribe = (entry: ReactSubscriptionEntry): (() => void) => {
+ const { select, id, targetRefreshInterval, onUpdate } = entry;
+
+ const fullEntry: SubscriptionEntry = {
+ id,
+ targetRefreshInterval,
+ onUpdate: (newDate) => {
+ const prevSelection = this.#selectionCache.get(id)?.value;
+ const newSelection = select?.(newDate) ?? newDate;
+ if (areValuesDeepEqual(prevSelection, newSelection)) {
+ return;
+ }
+
+ this.#selectionCache.set(id, { value: newSelection });
+ onUpdate(newDate);
+ },
+ };
+
+ this.#timeSync.subscribe(fullEntry);
+ return () => this.#timeSync.unsubscribe(id);
+ };
+
+ /**
+ * Allows you to grab the result of a selection that has been registered
+ * with ReactTimeSync.
+ *
+ * If this method is called with an ID before a subscription has been
+ * registered for that ID, that will cause the method to throw.
+ */
+ getSelectionSnapshot = (id: string): T => {
+ const cacheEntry = this.#selectionCache.get(id);
+ if (cacheEntry === undefined) {
+ throw new Error(
+ "Trying to retrieve value from selection cache without it being initialized",
+ );
+ }
+
+ return cacheEntry.value as T;
+ };
+
+ invalidateSelection = (id: string, select?: SelectCallback): void => {
+ // It is very, VERY important that we only change the value in the
+ // selection cache when it changes by value. If the state getter
+ // function for useSyncExternalStore always receives a new value by
+ // reference on every call, that will create an infinite render loop in
+ // dev mode
+ const dateSnapshot = this.#timeSync.getTimeSnapshot();
+ const newSelection = select?.(dateSnapshot) ?? dateSnapshot;
+
+ const cacheEntry = this.#selectionCache.get(id);
+ if (cacheEntry === undefined) {
+ this.#selectionCache.set(id, { value: newSelection });
+ return;
+ }
+
+ if (!areValuesDeepEqual(newSelection, cacheEntry.value)) {
+ this.#selectionCache.set(id, { value: newSelection });
+ }
+ };
+}
+
+function areValuesDeepEqual(value1: unknown, value2: unknown): boolean {
+ // JavaScript is fun and doesn't have a 100% foolproof comparison
+ // operation. Object.is covers the most cases, but you still need to
+ // compare 0 values, because even though JS programmers almost never
+ // care about +0 vs -0, Object.is does treat them as not being equal
+ if (Object.is(value1, value2)) {
+ return true;
+ }
+ if (value1 === 0 && value2 === 0) {
+ return true;
+ }
+
+ if (value1 instanceof Date && value2 instanceof Date) {
+ return value1.getMilliseconds() === value2.getMilliseconds();
+ }
+
+ // Can't reliably compare functions; just have to treat them as always
+ // different. Hopefully no one is storing functions in state for this
+ // hook, though
+ if (typeof value1 === "function" || typeof value2 === "function") {
+ return false;
+ }
+
+ if (Array.isArray(value1)) {
+ if (!Array.isArray(value2)) {
+ return false;
+ }
+ if (value1.length !== value2.length) {
+ return false;
+ }
+ return value1.every((el, i) => areValuesDeepEqual(el, value2[i]));
+ }
+
+ const obj1 = value1 as Record;
+ const obj2 = value1 as Record;
+ if (Object.keys(obj1).length !== Object.keys(obj2).length) {
+ return false;
+ }
+ for (const key in obj1) {
+ if (!areValuesDeepEqual(obj1[key], obj2[key])) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+const timeSyncContext = createContext(null);
+
+type TimeSyncProviderProps = Readonly<
+ PropsWithChildren<{
+ options?: Partial;
+ }>
+>;
+
+/**
+ * TimeSyncProvider provides an easy way to dependency-inject a TimeSync
+ * instance throughout a React application.
+ *
+ * Note that whatever options are provided on the first render will be locked in
+ * for the lifetime of the provider. There is no way to reconfigure options on
+ * re-renders.
+ */
+export const TimeSyncProvider: FC = ({
+ children,
+ options,
+}) => {
+ // Making the TimeSync instance be initialized via React State, so that it's
+ // easy to mock it out for component and story tests. TimeSync itself should
+ // be treated like a pseudo-ref value, where its values can only be used in
+ // very specific, React-approved ways
+ const [readonlySync] = useState(
+ () => new ReactTimeSync(options ?? defaultOptions),
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+function useTimeSyncContext(): ReactTimeSync {
+ const timeSync = useContext(timeSyncContext);
+ if (timeSync === null) {
+ throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
+ }
+ return timeSync;
+}
+
+type UseTimeSyncOptions = Readonly<{
+ /**
+ * targetRefreshInterval is the ideal interval of time, in milliseconds,
+ * that defines how often the hook should refresh with the newest Date
+ * value from TimeSync.
+ *
+ * Note that a refresh is not the same as a re-render. If the hook is
+ * refreshed with a new datetime, but its select callback produces the same
+ * value as before, the hook will skip re-rendering.
+ *
+ * The hook reserves the right to refresh MORE frequently than the
+ * specified value if it would guarantee that the hook does not get out of
+ * sync with other useTimeSync users that are currently mounted on screen.
+ */
+ targetRefreshInterval: number;
+}>;
+
+export function useTimeSync(options: UseTimeSyncOptions): Date {
+ const { targetRefreshInterval } = options;
+ const hookId = useId();
+ const timeSync = useTimeSyncContext();
+
+ const subscribe = useCallback(
+ (notifyReact) => {
+ return timeSync.subscribe({
+ targetRefreshInterval,
+ id: hookId,
+ onUpdate: notifyReact,
+ });
+ },
+ [hookId, timeSync, targetRefreshInterval],
+ );
+
+ const snapshot = useSyncExternalStore(subscribe, timeSync.getTimeSnapshot);
+ return snapshot;
+}
+
+type UseTimeSyncSelectOptions = Readonly<
+ UseTimeSyncOptions & {
+ /**
+ * Allows you to transform any Date values received from the TimeSync
+ * class, while providing render optimizations. Select functions are
+ * ALWAYS run during a render to guarantee no wrong data from stale
+ * closures.
+ *
+ * However, when a new time update is dispatched from TimeSync, the hook
+ * will use the latest select callback received to transform the value.
+ * If the select result has not changed compared to last time
+ * (comparing via deep equality), the hook will skip re-rendering.
+ *
+ * Select functions must not be async. The hook will error out at the
+ * type level if you provide one by mistake.
+ */
+ select: (latestDatetime: Date) => T extends Promise ? never : T;
+ }
+>;
+
+/**
+ * useTimeSync provides React bindings for the TimeSync class, letting a React
+ * component bind its update life cycles to interval updates from TimeSync. This
+ * hook should be used anytime you would want to use a Date instance directly in
+ * a component render path.
+ *
+ * By default, it returns the raw Date value from the most recent update, but
+ * by providing a `select` callback in the options, you can get a transformed
+ * version of the time value, using 100% pure functions.
+ *
+ * By specifying a value of positive Infinity, that indicates that the hook will
+ * not update by itself. But if another component is mounted with a more
+ * frequent update interval, both component instances will update on that
+ * interval.
+ */
+export function useTimeSyncSelect(options: UseTimeSyncSelectOptions): T {
+ const { select, targetRefreshInterval } = options;
+ const hookId = useId();
+ const timeSync = useTimeSyncContext();
+
+ // externalSelect is passed to the ReactTimeSync class for use when the
+ // class dispatches a new Date update. It should not be called inside the
+ // render logic whatsoever.
+ const externalStableSelect = useEffectEvent((date: Date): T => {
+ const recast = date as Date & T;
+ return select?.(recast) ?? recast;
+ });
+
+ // We're leaning into the React hook API behavior to simplify syncing the
+ // refresh intervals when they change during re-renders. Whenever
+ // useSyncExternalStore receives a new callback by reference, it will
+ // automatically unsubscribe with the previous callback and re-subscribe
+ // with the new one. useCallback stabilizes the reference, with the ability
+ // to invalidate it whenever the interval changes. We have to include all
+ // other data dependencies in the dependency array for correctness, but they
+ // should remain 100% stable by reference for the lifetime of the component
+ const subscribe = useCallback(
+ (notifyReact) => {
+ return timeSync.subscribe({
+ targetRefreshInterval,
+ id: hookId,
+ onUpdate: notifyReact,
+ select: externalStableSelect,
+ });
+ },
+ [timeSync, hookId, externalStableSelect, targetRefreshInterval],
+ );
+
+ const selection = useSyncExternalStore(subscribe, () => {
+ // Need to make sure that we use the un-memoized version of select here
+ // because we need to call select callback mid-render to guarantee no
+ // stale data. The memoized version only syncs AFTER the current render
+ // has finished in full.
+ timeSync.invalidateSelection(hookId, select);
+ return timeSync.getSelectionSnapshot(hookId);
+ });
+
+ return selection;
+}
diff --git a/site/src/modules/workspaces/activity.ts b/site/src/modules/workspaces/activity.ts
index 3f688efffcad3..53e2d17fc9da0 100644
--- a/site/src/modules/workspaces/activity.ts
+++ b/site/src/modules/workspaces/activity.ts
@@ -10,10 +10,11 @@ export type WorkspaceActivityStatus =
export function getWorkspaceActivityStatus(
workspace: Workspace,
+ currentDatetime: Date,
): WorkspaceActivityStatus {
const builtAt = dayjs(workspace.latest_build.created_at);
const usedAt = dayjs(workspace.last_used_at);
- const now = dayjs();
+ const now = dayjs(currentDatetime);
if (workspace.latest_build.status !== "running") {
return "notRunning";
diff --git a/site/src/pages/CliInstallPage/CliInstallPageView.tsx b/site/src/pages/CliInstallPage/CliInstallPageView.tsx
index 9356cee6153b3..ba37df59a3b0d 100644
--- a/site/src/pages/CliInstallPage/CliInstallPageView.tsx
+++ b/site/src/pages/CliInstallPage/CliInstallPageView.tsx
@@ -1,6 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
import { CodeExample } from "components/CodeExample/CodeExample";
import { Welcome } from "components/Welcome/Welcome";
+import { useTimeSyncSelect } from "hooks/useTimeSync";
import type { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
@@ -9,6 +10,11 @@ type CliInstallPageViewProps = {
};
export const CliInstallPageView: FC = ({ origin }) => {
+ const year = useTimeSyncSelect({
+ targetRefreshInterval: Number.POSITIVE_INFINITY,
+ select: (date) => date.getFullYear(),
+ });
+
return (
Install the Coder CLI
@@ -30,7 +36,7 @@ export const CliInstallPageView: FC = ({ origin }) => {
- {"\u00a9"} {new Date().getFullYear()} Coder Technologies, Inc.
+ {"\u00a9"} {year} Coder Technologies, Inc.
);
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
index 30ea89c72fdc6..814da2a3165a1 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
@@ -7,6 +7,7 @@ import { Pill } from "components/Pill/Pill";
import { Stack } from "components/Stack/Stack";
import { compareAsc } from "date-fns";
import dayjs from "dayjs";
+import { useTimeSync } from "hooks/useTimeSync";
import { type FC, useState } from "react";
type LicenseCardProps = {
@@ -24,6 +25,7 @@ export const LicenseCard: FC = ({
onRemove,
isRemoving,
}) => {
+ const time = useTimeSync({ targetRefreshInterval: 30_000 });
const [licenseIDMarkedForRemoval, setLicenseIDMarkedForRemoval] = useState<
number | undefined
>(undefined);
@@ -92,10 +94,8 @@ export const LicenseCard: FC = ({
alignItems="center"
width="134px" // standardize width of date column
>
- {compareAsc(
- new Date(license.claims.license_expires * 1000),
- new Date(),
- ) < 1 ? (
+ {compareAsc(new Date(license.claims.license_expires * 1000), time) <
+ 1 ? (
Expired
diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx
index 0c9b54e273963..7a88bef9107fb 100644
--- a/site/src/pages/LoginPage/LoginPageView.tsx
+++ b/site/src/pages/LoginPage/LoginPageView.tsx
@@ -3,6 +3,7 @@ import Button from "@mui/material/Button";
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
import { CustomLogo } from "components/CustomLogo/CustomLogo";
import { Loader } from "components/Loader/Loader";
+import { useTimeSyncSelect } from "hooks/useTimeSync";
import { type FC, useState } from "react";
import { useLocation } from "react-router-dom";
import { SignInForm } from "./SignInForm";
@@ -27,6 +28,10 @@ export const LoginPageView: FC = ({
onSignIn,
redirectTo,
}) => {
+ const year = useTimeSyncSelect({
+ targetRefreshInterval: Number.POSITIVE_INFINITY,
+ select: (date) => date.getFullYear(),
+ });
const location = useLocation();
// This allows messages to be displayed at the top of the sign in form.
// Helpful for any redirects that want to inform the user of something.
@@ -57,9 +62,7 @@ export const LoginPageView: FC = ({
/>
)}