Skip to content

Commit ee76345

Browse files
committed
fix: revamp the API design to remove risks of infinite renders
1 parent 1bc9463 commit ee76345

File tree

5 files changed

+61
-36
lines changed

5 files changed

+61
-36
lines changed

site/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "./theme/globalFonts";
22
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
3+
import { TimeSyncProvider } from "hooks/useTimeSync";
34
import {
45
type FC,
56
type ReactNode,
@@ -14,7 +15,6 @@ import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
1415
import { ThemeProvider } from "./contexts/ThemeProvider";
1516
import { AuthProvider } from "./contexts/auth/AuthProvider";
1617
import { router } from "./router";
17-
import { TimeSyncProvider } from "hooks/useTimeSync";
1818

1919
const defaultQueryClient = new QueryClient({
2020
defaultOptions: {

site/src/hooks/useTimeSync.tsx

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
/**
22
* @todo Things that still need to be done before this can be called done:
33
*
4-
* 1. Finish up the interval reconciliation method
4+
* 1. Revamp the entire class definition, and fill out all missing methods
55
* 2. Update the class to respect the resyncOnNewSubscription option
66
* 3. Make it so that if you provide an explicit type parameter to the hook
77
* call, you must provide a runtime select function
88
* 4. Add tests
99
*/
1010
import {
11+
type FC,
12+
type PropsWithChildren,
1113
createContext,
1214
useCallback,
1315
useContext,
1416
useId,
1517
useState,
1618
useSyncExternalStore,
17-
type FC,
18-
type PropsWithChildren,
1919
} from "react";
20+
import { useEffectEvent } from "./hookPolyfills";
2021

2122
export const IDEAL_REFRESH_ONE_SECOND = 1_000;
2223
export const IDEAL_REFRESH_ONE_MINUTE = 60 * 1_000;
@@ -70,12 +71,14 @@ type SubscriptionEntry = Readonly<{
7071
id: string;
7172
idealRefreshIntervalMs: number;
7273
onUpdate: (newDatetime: Date) => void;
74+
select?: (newSnapshot: Date) => unknown;
7375
}>;
7476

7577
interface TimeSyncApi {
76-
getLatestDatetimeSnapshot: () => Date;
7778
subscribe: (entry: SubscriptionEntry) => () => void;
7879
unsubscribe: (id: string) => void;
80+
getTimeSnapshot: () => Date;
81+
getSelectionSnapshot: <T = unknown>(id: string) => T;
7982
}
8083

8184
/**
@@ -109,8 +112,9 @@ export class TimeSync implements TimeSyncApi {
109112
readonly #setInterval: SetInterval;
110113
readonly #clearInterval: ClearInterval;
111114

112-
#latestSnapshot: Date;
115+
#latestDateSnapshot: Date;
113116
#subscriptions: SubscriptionEntry[];
117+
#selectionCache: Map<string, unknown>;
114118
#latestIntervalId: number | undefined;
115119

116120
constructor(options: Partial<TimeSyncInitOptions>) {
@@ -127,8 +131,9 @@ export class TimeSync implements TimeSyncApi {
127131
this.#createNewDatetime = createNewDatetime;
128132
this.#resyncOnNewSubscription = resyncOnNewSubscription;
129133

130-
this.#latestSnapshot = initialDatetime;
134+
this.#latestDateSnapshot = initialDatetime;
131135
this.#subscriptions = [];
136+
this.#selectionCache = new Map();
132137
this.#latestIntervalId = undefined;
133138
}
134139

@@ -139,15 +144,17 @@ export class TimeSync implements TimeSyncApi {
139144
}
140145

141146
const prevFastestInterval =
142-
this.#subscriptions[0]?.idealRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
147+
this.#subscriptions[0]?.idealRefreshIntervalMs ??
148+
Number.POSITIVE_INFINITY;
143149
if (this.#subscriptions.length > 1) {
144150
this.#subscriptions.sort(
145151
(e1, e2) => e1.idealRefreshIntervalMs - e2.idealRefreshIntervalMs,
146152
);
147153
}
148154

149155
const newFastestInterval =
150-
this.#subscriptions[0]?.idealRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
156+
this.#subscriptions[0]?.idealRefreshIntervalMs ??
157+
Number.POSITIVE_INFINITY;
151158
if (prevFastestInterval === newFastestInterval) {
152159
return;
153160
}
@@ -161,22 +168,28 @@ export class TimeSync implements TimeSyncApi {
161168
* when/how it should be updated
162169
*/
163170
this.#latestIntervalId = this.#setInterval(() => {
164-
this.#latestSnapshot = this.#createNewDatetime(this.#latestSnapshot);
171+
this.#latestDateSnapshot = this.#createNewDatetime(
172+
this.#latestDateSnapshot,
173+
);
165174
this.#notifySubscriptions();
166175
}, newFastestInterval);
167176
}
168177

169178
#notifySubscriptions(): void {
170179
for (const subEntry of this.#subscriptions) {
171-
subEntry.onUpdate(this.#latestSnapshot);
180+
subEntry.onUpdate(this.#latestDateSnapshot);
172181
}
173182
}
174183

175184
// All functions that are part of the public interface must be defined as
176185
// arrow functions, so that they work properly with React
177186

178-
getLatestDatetimeSnapshot = (): Date => {
179-
return this.#latestSnapshot;
187+
getTimeSnapshot = (): Date => {
188+
return this.#latestDateSnapshot;
189+
};
190+
191+
getSelectionSnapshot = <T,>(id: string): T => {
192+
return this.#selectionCache.get(id) as T;
180193
};
181194

182195
unsubscribe = (id: string): void => {
@@ -267,8 +280,6 @@ type UseTimeSyncOptions<T = Date> = Readonly<{
267280
select?: (latestDatetime: Date) => T extends Promise<unknown> ? never : T;
268281
}>;
269282

270-
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
271-
272283
/**
273284
* useTimeSync provides React bindings for the TimeSync class, letting a React
274285
* component bind its update lifecycles to interval updates from TimeSync. This
@@ -286,43 +297,57 @@ type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
286297
*/
287298
export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
288299
const { select, idealRefreshIntervalMs } = options;
300+
const timeSync = useContext(timeSyncContext);
301+
if (timeSync === null) {
302+
throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
303+
}
289304

290305
// Abusing useId a little bit here. It's mainly meant to be used for
291306
// accessibility, but it also gives us a globally unique ID associated with
292307
// whichever component instance is consuming this hook
293308
const hookId = useId();
294-
const timeSync = useContext(timeSyncContext);
295-
if (timeSync === null) {
296-
throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
297-
}
309+
310+
// This is the one place where we're borderline breaking the React rules.
311+
// useEffectEvent is meant to be used only in useEffect calls, and normally
312+
// shouldn't be called inside a render. But its behavior lines up with
313+
// useSyncExternalStore, letting us cheat a little. useSyncExternalStore's
314+
// state getter callback is called in two scenarios:
315+
// (1) Mid-render on mount
316+
// (2) Whenever React is notified of a state change (outside of React).
317+
//
318+
// Case 2 is basically an effect with extra steps (and single-threaded JS
319+
// gives us assurance about correctness). And for (1), useEffectEvent will be
320+
// initialized with whatever callback you give it on mount. So for the
321+
// mounting render alone, it's safe to call a useEffectEvent callback from
322+
// inside a render.
323+
const stableSelect = useEffectEvent((date: Date): T => {
324+
const recast = date as Date & T;
325+
return select?.(recast) ?? recast;
326+
});
298327

299328
// We need to define this callback using useCallback instead of
300329
// useEffectEvent because we want the memoization to be invaliated when the
301330
// refresh interval changes. (When the subscription callback changes by
302331
// reference, that causes useSyncExternalStore to redo the subscription with
303332
// the new callback). All other values need to be included in the dependency
304-
// array for correctness, but their memory references should always be
305-
// stable
333+
// array for correctness, but they should always maintain stable memory
334+
// addresses
335+
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
306336
const subscribe = useCallback<ReactSubscriptionCallback>(
307337
(notifyReact) => {
308338
return timeSync.subscribe({
309339
idealRefreshIntervalMs,
310-
onUpdate: notifyReact,
311340
id: hookId,
341+
onUpdate: notifyReact,
342+
select: stableSelect,
312343
});
313344
},
314-
[timeSync, hookId, idealRefreshIntervalMs],
345+
[timeSync, hookId, stableSelect, idealRefreshIntervalMs],
315346
);
316347

317-
// This function ONLY gets called when the subscription is first created, or
318-
// when React gets notified via a subscription update. It doesn't need to be
319-
// memoized – useSyncExternalStore treats it similar to a useEffectEvent
320-
// callback, except that unlike with useEffectEvent, it's render-safe.
321-
const getNewState = (): T => {
322-
const newSnapshot = timeSync.getLatestDatetimeSnapshot() as T & Date;
323-
const selected = (select?.(newSnapshot) ?? newSnapshot) as T;
324-
return selected;
325-
};
348+
const snapshot = useSyncExternalStore<T>(subscribe, () =>
349+
timeSync.getSelectionSnapshot(hookId),
350+
);
326351

327-
return useSyncExternalStore<T>(subscribe, getNewState);
352+
return snapshot;
328353
}

site/src/pages/LoginPage/LoginPageView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import Button from "@mui/material/Button";
33
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
44
import { CustomLogo } from "components/CustomLogo/CustomLogo";
55
import { Loader } from "components/Loader/Loader";
6+
import { useTimeSync } from "hooks/useTimeSync";
67
import { type FC, useState } from "react";
78
import { useLocation } from "react-router-dom";
89
import { SignInForm } from "./SignInForm";
910
import { TermsOfServiceLink } from "./TermsOfServiceLink";
10-
import { useTimeSync } from "hooks/useTimeSync";
1111

1212
export interface LoginPageViewProps {
1313
authMethods: AuthMethods | undefined;

site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import {
1717
startOfHour,
1818
subDays,
1919
} from "date-fns";
20+
import { useTimeSync } from "hooks/useTimeSync";
2021
import { type ComponentProps, type FC, useRef, useState } from "react";
2122
import { DateRangePicker, createStaticRanges } from "react-date-range";
22-
import { useTimeSync } from "hooks/useTimeSync";
2323

2424
// The type definition from @types is wrong
2525
declare module "react-date-range" {

site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
subDays,
4646
} from "date-fns";
4747
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
48+
import { IDEAL_REFRESH_ONE_DAY, useTimeSync } from "hooks/useTimeSync";
4849
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
4950
import {
5051
type FC,
@@ -62,7 +63,6 @@ import { DateRange as DailyPicker, type DateRangeValue } from "./DateRange";
6263
import { type InsightsInterval, IntervalMenu } from "./IntervalMenu";
6364
import { WeekPicker, numberOfWeeksOptions } from "./WeekPicker";
6465
import { lastWeeks } from "./utils";
65-
import { IDEAL_REFRESH_ONE_DAY, useTimeSync } from "hooks/useTimeSync";
6666

6767
const DEFAULT_NUMBER_OF_WEEKS = numberOfWeeksOptions[0];
6868

0 commit comments

Comments
 (0)