From edb3d1e425cf8e2cc9720d5fc128ee0dd2eb0b76 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Mon, 28 Apr 2025 15:03:51 +0000 Subject: [PATCH 01/29] fix: migrate over time changes --- site/src/App.tsx | 23 +- .../components/SignInLayout/SignInLayout.tsx | 8 +- site/src/hooks/useTime.ts | 43 --- site/src/hooks/useTimeSync.tsx | 326 ++++++++++++++++++ .../CliInstallPage/CliInstallPageView.tsx | 8 +- .../LicensesSettingsPage/LicenseCard.tsx | 8 +- site/src/pages/LoginPage/LoginPageView.tsx | 9 +- .../TemplateInsightsPage/DateRange.tsx | 4 +- .../TemplateInsightsPage.tsx | 183 +++++----- site/src/pages/WorkspacePage/AppStatuses.tsx | 11 +- .../WorkspaceScheduleControls.tsx | 7 +- site/src/pages/WorkspacesPage/LastUsed.tsx | 50 +-- 12 files changed, 497 insertions(+), 183 deletions(-) delete mode 100644 site/src/hooks/useTime.ts create mode 100644 site/src/hooks/useTimeSync.tsx diff --git a/site/src/App.tsx b/site/src/App.tsx index e4e6d4a665996..8c1c692ecf3e9 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -14,6 +14,7 @@ import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"; import { ThemeProvider } from "./contexts/ThemeProvider"; import { AuthProvider } from "./contexts/auth/AuthProvider"; import { router } from "./router"; +import { TimeSyncProvider } from "hooks/useTimeSync"; const defaultQueryClient = new QueryClient({ defaultOptions: { @@ -37,6 +38,8 @@ declare global { } } +const initialDatetime = new Date(); + export const AppProviders: FC = ({ children, queryClient = defaultQueryClient, @@ -64,15 +67,17 @@ export const AppProviders: FC = ({ return ( - - - - {children} - - - - {showDevtools && } - + + + + + {children} + + + + {showDevtools && } + + ); }; diff --git a/site/src/components/SignInLayout/SignInLayout.tsx b/site/src/components/SignInLayout/SignInLayout.tsx index 6a0d4f5865ea1..751e58224adc2 100644 --- a/site/src/components/SignInLayout/SignInLayout.tsx +++ b/site/src/components/SignInLayout/SignInLayout.tsx @@ -1,13 +1,19 @@ import type { Interpolation, Theme } from "@emotion/react"; +import { useTimeSync } from "hooks/useTimeSync"; import type { FC, PropsWithChildren } from "react"; export const SignInLayout: FC = ({ children }) => { + const year = useTimeSync({ + maxRefreshIntervalMs: Number.POSITIVE_INFINITY, + select: (date) => date.getFullYear(), + }); + return (
{children}
- {"\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..af1358059a9d6 --- /dev/null +++ b/site/src/hooks/useTimeSync.tsx @@ -0,0 +1,326 @@ +/** + * @todo Things that still need to be done before this can be called done: + * 1. Finish up the interval reconciliation method + * 2. Update the class to respect the resyncOnNewSubscription option + * 3. Add tests + */ +import { + createContext, + useCallback, + useContext, + useId, + useState, + useSyncExternalStore, + type FC, + type PropsWithChildren, +} from "react"; + +export const MAX_REFRESH_ONE_SECOND = 1_000; +export const MAX_REFRESH_ONE_MINUTE = 60 * 1_000; +export const MAX_REFRESH_ONE_HOUR = 60 * 60 * 1_000; +export const MAX_REFRESH_ONE_DAY = 24 * 60 * 60 * 1_000; + +type SetInterval = (fn: () => void, intervalMs: number) => number; +type ClearInterval = (id: number | undefined) => void; + +type TimeSyncInitOptions = Readonly<{ + /** + * Configures whether adding a new subscription will immediately create a new + * time snapshot and use it to update all other subscriptions. + */ + resyncOnNewSubscription: boolean; + + /** + * The Date value to use when initializing a TimeSync instance. + */ + initialDatetime: Date; + + /** + * The function to use when creating a new datetime snapshot when a TimeSync + * needs to update on an interval. + */ + createNewDatetime: (prevDatetime: Date) => Date; + + /** + * The function to use when creating new intervals. + */ + setInterval: SetInterval; + + /** + * The function to use when clearing intervals. + * + * (i.e., Clearing a previous interval because the TimeSync needs to make a + * new interval to increase/decrease its update speed.) + */ + clearInterval: ClearInterval; +}>; + +const defaultOptions: TimeSyncInitOptions = { + initialDatetime: new Date(), + resyncOnNewSubscription: true, + createNewDatetime: () => new Date(), + setInterval: window.setInterval, + clearInterval: window.clearInterval, +}; + +type SubscriptionEntry = Readonly<{ + id: string; + maxRefreshIntervalMs: number; + onUpdate: (newDatetime: Date) => void; +}>; + +interface TimeSyncApi { + getLatestDatetimeSnapshot: () => Date; + subscribe: (entry: SubscriptionEntry) => () => void; + unsubscribe: (id: string) => void; +} + +/** + * TimeSync provides a centralized authority for working with time values in a + * more structured, "pure function-ish" way, where all dependents for the time + * values must stay in sync with each other. (e.g., in a React codebase, you + * want multiple components that rely on time values to update together, to + * avoid screen tearing and stale data for only some parts of the screen). + * + * It lets any number of consumers subscribe to it, requiring that subscribers + * define the slowest possible update interval they need to receive new time + * values for. A value of positive Infinity indicates that a subscriber doesn't + * need updates; if all subscriptions have an update interval of Infinity, the + * class may not dispatch updates. + * + * The class aggregates all the update intervals, and will dispatch updates to + * all consumers based on the fastest refresh interval needed. (e.g., if + * subscriber A needs no updates, but subscriber B needs updates every second, + * BOTH will update every second until subscriber B unsubscribes. After that, + * TimeSync will stop dispatching updates until subscription C gets added, and C + * has a non-Infinite update interval). + * + * By design, there is no way to make one subscriber disable updates. That + * defeats the goal of needing to keep everything in sync with each other. If + * updates are happening too frequently in React, restructure how you're + * composing your components to minimize the costs of re-renders. + */ +export class TimeSync implements TimeSyncApi { + readonly #resyncOnNewSubscription: boolean; + readonly #createNewDatetime: (prev: Date) => Date; + readonly #setInterval: SetInterval; + readonly #clearInterval: ClearInterval; + + #latestSnapshot: Date; + #subscriptions: SubscriptionEntry[]; + #latestIntervalId: number | undefined; + + constructor(options: Partial) { + const { + initialDatetime = defaultOptions.initialDatetime, + resyncOnNewSubscription = defaultOptions.resyncOnNewSubscription, + createNewDatetime = defaultOptions.createNewDatetime, + setInterval = defaultOptions.setInterval, + clearInterval = defaultOptions.clearInterval, + } = options; + + this.#setInterval = setInterval; + this.#clearInterval = clearInterval; + this.#createNewDatetime = createNewDatetime; + this.#resyncOnNewSubscription = resyncOnNewSubscription; + + this.#latestSnapshot = initialDatetime; + this.#subscriptions = []; + this.#latestIntervalId = undefined; + } + + #reconcileRefreshIntervals(): void { + if (this.#subscriptions.length === 0) { + this.#clearInterval(this.#latestIntervalId); + return; + } + + const prevFastestInterval = + this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY; + if (this.#subscriptions.length > 1) { + this.#subscriptions.sort( + (e1, e2) => e1.maxRefreshIntervalMs - e2.maxRefreshIntervalMs, + ); + } + + const newFastestInterval = + this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY; + if (prevFastestInterval === newFastestInterval) { + return; + } + if (newFastestInterval === Number.POSITIVE_INFINITY) { + this.#clearInterval(this.#latestIntervalId); + return; + } + + /** + * @todo Figure out the conditions when the interval should be set up, and + * when/how it should be updated + */ + this.#latestIntervalId = this.#setInterval(() => { + this.#latestSnapshot = this.#createNewDatetime(this.#latestSnapshot); + this.#notifySubscriptions(); + }, newFastestInterval); + } + + #notifySubscriptions(): void { + for (const subEntry of this.#subscriptions) { + subEntry.onUpdate(this.#latestSnapshot); + } + } + + // All functions that are part of the public interface must be defined as + // arrow functions, so that they work properly with React + + getLatestDatetimeSnapshot = (): Date => { + return this.#latestSnapshot; + }; + + unsubscribe = (id: string): void => { + const updated = this.#subscriptions.filter((s) => s.id !== id); + if (updated.length === this.#subscriptions.length) { + return; + } + + this.#subscriptions = updated; + this.#reconcileRefreshIntervals(); + }; + + subscribe = (entry: SubscriptionEntry): (() => void) => { + if (entry.maxRefreshIntervalMs <= 0) { + throw new Error( + `Refresh interval ${entry.maxRefreshIntervalMs} must be a positive integer (or Infinity)`, + ); + } + + const unsub = () => this.unsubscribe(entry.id); + const subIndex = this.#subscriptions.findIndex((s) => s.id === entry.id); + if (subIndex === -1) { + this.#subscriptions.push(entry); + this.#reconcileRefreshIntervals(); + return unsub; + } + + const prev = this.#subscriptions[subIndex]; + if (prev === undefined) { + throw new Error("Went out of bounds"); + } + + this.#subscriptions[subIndex] = entry; + if (prev.maxRefreshIntervalMs !== entry.maxRefreshIntervalMs) { + this.#reconcileRefreshIntervals(); + } + return unsub; + }; +} + +const timeSyncContext = createContext(null); + +function useTimeSyncContext(): TimeSync { + const timeSync = useContext(timeSyncContext); + if (timeSync === null) { + throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider"); + } + + return timeSync; +} + +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 (e.g., not directly in a render path) + const [readonlySync] = useState( + () => new TimeSync(options ?? defaultOptions), + ); + + return ( + + {children} + + ); +}; + +type UseTimeSyncOptions = Readonly<{ + maxRefreshIntervalMs: number; + + /** + * Allows you to transform any date values received from the TimeSync class. + * + * Note that select functions are not memoized and will run on every render + * (similar to the ones in React Query and Redux Toolkit's default selectors). + * Select functions should be kept cheap to recalculate. + * + * 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; +}>; + +type ReactSubscriptionCallback = (notifyReact: () => void) => () => void; + +/** + * useTimeSync provides React bindings for the TimeSync class, letting a React + * component bind its update lifecycles 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 useTimeSync(options: UseTimeSyncOptions): T { + const { select, maxRefreshIntervalMs } = options; + + // Abusing useId a little bit here. It's mainly meant to be used for + // accessibility, but it also gives us a globally unique ID associated with + // whichever component instance is consuming this hook + const hookId = useId(); + const timeSync = useTimeSyncContext(); + + // We need to define this callback using useCallback instead of useEffectEvent + // because we want the memoization to be invaliated when the refresh interval + // changes. (When the subscription callback changes by reference, that causes + // useSyncExternalStore to redo the subscription with the new callback). All + // other values need to be included in the dependency array for correctness, + // but their memory references should always be stable + const subscribe = useCallback( + (notifyReact) => { + return timeSync.subscribe({ + maxRefreshIntervalMs, + onUpdate: notifyReact, + id: hookId, + }); + }, + [timeSync, hookId, maxRefreshIntervalMs], + ); + + const currentTime = useSyncExternalStore(subscribe, () => + timeSync.getLatestDatetimeSnapshot(), + ); + + const recast = currentTime as T & Date; + return select?.(recast) ?? recast; +} diff --git a/site/src/pages/CliInstallPage/CliInstallPageView.tsx b/site/src/pages/CliInstallPage/CliInstallPageView.tsx index 9356cee6153b3..a4ac1486f19e3 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 { useTimeSync } 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 = useTimeSync({ + maxRefreshIntervalMs: 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..d9c8cd48ed512 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({ maxRefreshIntervalMs: 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..cb5bc3d648725 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -7,6 +7,7 @@ import { type FC, useState } from "react"; import { useLocation } from "react-router-dom"; import { SignInForm } from "./SignInForm"; import { TermsOfServiceLink } from "./TermsOfServiceLink"; +import { useTimeSync } from "hooks/useTimeSync"; export interface LoginPageViewProps { authMethods: AuthMethods | undefined; @@ -27,6 +28,10 @@ export const LoginPageView: FC = ({ onSignIn, redirectTo, }) => { + const year = useTimeSync({ + maxRefreshIntervalMs: 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 = ({ /> )}