Skip to content

refactor(site): centralize time values in render functions as deterministic state #17587

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 32 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
edb3d1e
fix: migrate over time changes
Parkreiner Apr 28, 2025
39d8641
Merge branch 'main' into mes/use-time-2
Parkreiner Apr 28, 2025
93f85c4
docs: add one more todo
Parkreiner Apr 28, 2025
ba92d78
fix: resolve memoization wonkiness
Parkreiner Apr 29, 2025
e642293
docs: rewrite comment for clarity
Parkreiner Apr 29, 2025
1bc9463
refactor: slight cleanup
Parkreiner Apr 29, 2025
ee76345
fix: revamp the API design to remove risks of infinite renders
Parkreiner Apr 30, 2025
0ca593b
fix: beef up subscription update method
Parkreiner Apr 30, 2025
fc0694c
fix: add back logic that was removed during merge
Parkreiner Apr 30, 2025
ebe8821
chore: add provider to base Storybook meta
Parkreiner Apr 30, 2025
90f2146
fix: add decorator to right place
Parkreiner Apr 30, 2025
532ec85
fix: remove ghost import
Parkreiner Apr 30, 2025
8c5c49d
fix: get rid of UMD import warnings
Parkreiner Apr 30, 2025
30283f0
Merge branch 'main' into mes/use-time-2
Parkreiner May 7, 2025
9f258a2
Merge branch 'mes/use-time-2' of https://github.com/coder/coder into …
Parkreiner May 7, 2025
62e5f8e
refactor: split up TimeSync into vanilla file and React file
Parkreiner May 7, 2025
60b2220
fix: move resync option back to base class
Parkreiner May 7, 2025
62b6f6e
chore: flesh out more of React class
Parkreiner May 7, 2025
fe8b3ef
fix: get initial solution in place for fixing stale closure issues
Parkreiner May 7, 2025
9d8671e
refactor: make class less confusing
Parkreiner May 7, 2025
997c3da
chore: format
Parkreiner May 7, 2025
d0a57de
docs: add more info about properties
Parkreiner May 7, 2025
da5803d
refactor: split useTimeSync into two hooks
Parkreiner May 7, 2025
08fda7b
fix: add back type-checking
Parkreiner May 7, 2025
5233fb8
chore: finish initial implementation of onSubscriptionAdd
Parkreiner May 7, 2025
93cf58a
fix: make error case more clear
Parkreiner May 9, 2025
5ba068a
docs: add notes about potential other approach for hook
Parkreiner May 9, 2025
f352e3b
fix: remove dependencies from hook API
Parkreiner May 9, 2025
b5a6870
refactor: clean up code
Parkreiner May 9, 2025
10fbdfc
refactor: make hook less confusing
Parkreiner May 9, 2025
d7c70e2
fix: resolve bug when syncing new interval
Parkreiner May 9, 2025
0ffe2af
fix: remove race condition in caching logic
Parkreiner May 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: resolve memoization wonkiness
  • Loading branch information
Parkreiner committed Apr 29, 2025
commit ba92d7821fcd152f58766d55437fc246ba6b8d3a
2 changes: 1 addition & 1 deletion site/src/components/SignInLayout/SignInLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { FC, PropsWithChildren } from "react";

export const SignInLayout: FC<PropsWithChildren> = ({ children }) => {
const year = useTimeSync({
maxRefreshIntervalMs: Number.POSITIVE_INFINITY,
idealRefreshIntervalMs: Number.POSITIVE_INFINITY,
select: (date) => date.getFullYear(),
});

Expand Down
64 changes: 36 additions & 28 deletions site/src/hooks/useTimeSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type TimeSyncInitOptions = Readonly<{
/**
* The function to use when clearing intervals.
*
* (i.e., Clearing a previous interval because the TimeSync needs to make a
* (e.g., Clearing a previous interval because the TimeSync needs to make a
* new interval to increase/decrease its update speed.)
*/
clearInterval: ClearInterval;
Expand All @@ -65,7 +65,7 @@ const defaultOptions: TimeSyncInitOptions = {

type SubscriptionEntry = Readonly<{
id: string;
maxRefreshIntervalMs: number;
idealRefreshIntervalMs: number;
onUpdate: (newDatetime: Date) => void;
}>;

Expand Down Expand Up @@ -136,15 +136,15 @@ export class TimeSync implements TimeSyncApi {
}

const prevFastestInterval =
this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
this.#subscriptions[0]?.idealRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
if (this.#subscriptions.length > 1) {
this.#subscriptions.sort(
(e1, e2) => e1.maxRefreshIntervalMs - e2.maxRefreshIntervalMs,
(e1, e2) => e1.idealRefreshIntervalMs - e2.idealRefreshIntervalMs,
);
}

const newFastestInterval =
this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
this.#subscriptions[0]?.idealRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
if (prevFastestInterval === newFastestInterval) {
return;
}
Expand Down Expand Up @@ -187,9 +187,9 @@ export class TimeSync implements TimeSyncApi {
};

subscribe = (entry: SubscriptionEntry): (() => void) => {
if (entry.maxRefreshIntervalMs <= 0) {
if (entry.idealRefreshIntervalMs <= 0) {
throw new Error(
`Refresh interval ${entry.maxRefreshIntervalMs} must be a positive integer (or Infinity)`,
`Refresh interval ${entry.idealRefreshIntervalMs} must be a positive integer (or Infinity)`,
);
}

Expand All @@ -207,7 +207,7 @@ export class TimeSync implements TimeSyncApi {
}

this.#subscriptions[subIndex] = entry;
if (prev.maxRefreshIntervalMs !== entry.maxRefreshIntervalMs) {
if (prev.idealRefreshIntervalMs !== entry.idealRefreshIntervalMs) {
this.#reconcileRefreshIntervals();
}
return unsub;
Expand Down Expand Up @@ -246,7 +246,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
// 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)
// very specific, React-approved ways
const [readonlySync] = useState(
() => new TimeSync(options ?? defaultOptions),
);
Expand All @@ -259,14 +259,13 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
};

type UseTimeSyncOptions<T = Date> = Readonly<{
maxRefreshIntervalMs: number;
idealRefreshIntervalMs: 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 work similarly to the selects from React Query and Redux
* Toolkit – you don't need to memoize them, and they will only run when the
* underlying TimeSync state has changed.
*
* Select functions must not be async. The hook will error out at the type
* level if you provide one by mistake.
Expand All @@ -292,35 +291,44 @@ type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
* interval.
*/
export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
const { select, maxRefreshIntervalMs } = options;
const { select, idealRefreshIntervalMs } = 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
// 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<ReactSubscriptionCallback>(
(notifyReact) => {
return timeSync.subscribe({
maxRefreshIntervalMs,
idealRefreshIntervalMs,
onUpdate: notifyReact,
id: hookId,
});
},
[timeSync, hookId, maxRefreshIntervalMs],
[timeSync, hookId, idealRefreshIntervalMs],
);

const currentTime = useSyncExternalStore(subscribe, () =>
timeSync.getLatestDatetimeSnapshot(),
);
// Unlike subscribe, getNewState doesn't need to be memoized, because React
// doesn't actually care about its memory reference. Whenever React gets
// notified, it just calls the latest state function it received (whatever
// it happens to be). React will only re-render if the value returned by the
// function is different from what it got back last time (using === as the
// comparison). This function ONLY gets called when the subscription is
// first created, or when React gets notified via a subscription update.
const getNewState = (): T => {
const latestDate = timeSync.getLatestDatetimeSnapshot() as T & Date;
const selected = (select?.(latestDate) ?? latestDate) as T;
return selected;
};

const recast = currentTime as T & Date;
return select?.(recast) ?? recast;
return useSyncExternalStore<T>(subscribe, getNewState);
}
2 changes: 1 addition & 1 deletion site/src/pages/CliInstallPage/CliInstallPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type CliInstallPageViewProps = {

export const CliInstallPageView: FC<CliInstallPageViewProps> = ({ origin }) => {
const year = useTimeSync({
maxRefreshIntervalMs: Number.POSITIVE_INFINITY,
idealRefreshIntervalMs: Number.POSITIVE_INFINITY,
select: (date) => date.getFullYear(),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const LicenseCard: FC<LicenseCardProps> = ({
onRemove,
isRemoving,
}) => {
const time = useTimeSync({ maxRefreshIntervalMs: 30_000 });
const time = useTimeSync({ idealRefreshIntervalMs: 30_000 });
const [licenseIDMarkedForRemoval, setLicenseIDMarkedForRemoval] = useState<
number | undefined
>(undefined);
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/LoginPage/LoginPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
redirectTo,
}) => {
const year = useTimeSync({
maxRefreshIntervalMs: Number.POSITIVE_INFINITY,
idealRefreshIntervalMs: Number.POSITIVE_INFINITY,
select: (date) => date.getFullYear(),
});
const location = useLocation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ interface DateRangeProps {
}

export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
const currentTime = useTimeSync({ maxRefreshIntervalMs: 60_000 });
const currentTime = useTimeSync({ idealRefreshIntervalMs: 60_000 });
const selectionStatusRef = useRef<"idle" | "selecting">("idle");
const [ranges, setRanges] = useState<RangesState>([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function TemplateInsightsPage() {
const [searchParams, setSearchParams] = useSearchParams();

const insightsInterval = useTimeSync<InsightsInterval>({
maxRefreshIntervalMs: MAX_REFRESH_ONE_DAY,
idealRefreshIntervalMs: MAX_REFRESH_ONE_DAY,
select: (newDatetime) => {
const templateCreateDate = new Date(template.created_at);
const hasFiveWeeksOrMore = addWeeks(templateCreateDate, 5) < newDatetime;
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/WorkspacePage/AppStatuses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
referenceDate,
}) => {
const comparisonDate = useTimeSync({
maxRefreshIntervalMs: MAX_REFRESH_ONE_MINUTE,
idealRefreshIntervalMs: MAX_REFRESH_ONE_MINUTE,
select: (dateState) => referenceDate ?? dateState,
});
const theme = useTheme();
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ const AutostopDisplay: FC<AutostopDisplayProps> = ({
};

const activityStatus = useTimeSync({
maxRefreshIntervalMs: 1_000,
idealRefreshIntervalMs: 1_000,
select: () => getWorkspaceActivityStatus(workspace),
});
const { message, tooltip, danger } = autostopDisplay(
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/WorkspacesPage/LastUsed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
* @todo Verify that this is equivalent
*/
const [circle, message] = useTimeSync({
maxRefreshIntervalMs: MAX_REFRESH_ONE_MINUTE,
idealRefreshIntervalMs: MAX_REFRESH_ONE_MINUTE,
select: (date) => {
const t = dayjs(lastUsedAt);
const deltaMsg = t.from(dayjs(date));
Expand Down
Loading