Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9f258a2

Browse files
committed
Merge branch 'mes/use-time-2' of https://github.com/coder/coder into mes/use-time-2
2 parents 30283f0 + 8c5c49d commit 9f258a2

File tree

11 files changed

+134
-74
lines changed

11 files changed

+134
-74
lines changed

site/.storybook/preview.jsx

+25-2
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
* Storybook decorator function used to inject baseline data dependencies into
1717
* our React components during testing.
1818
*/
19+
import React from "react";
1920
import "../src/index.css";
2021
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
2122
import CssBaseline from "@mui/material/CssBaseline";
2223
import {
2324
ThemeProvider as MuiThemeProvider,
2425
StyledEngineProvider,
25-
// biome-ignore lint/nursery/noRestrictedImports: we extend the MUI theme
2626
} from "@mui/material/styles";
2727
import { DecoratorHelpers } from "@storybook/addon-themes";
2828
import isChromatic from "chromatic/isChromatic";
@@ -31,12 +31,19 @@ import { HelmetProvider } from "react-helmet-async";
3131
import { QueryClient, QueryClientProvider, parseQueryArgs } from "react-query";
3232
import { withRouter } from "storybook-addon-remix-react-router";
3333
import "theme/globalFonts";
34+
import { TimeSyncProvider } from "../src/hooks/useTimeSync";
3435
import themes from "../src/theme";
3536

3637
DecoratorHelpers.initializeThemeState(Object.keys(themes), "dark");
3738

3839
/** @type {readonly Decorator[]} */
39-
export const decorators = [withRouter, withQuery, withHelmet, withTheme];
40+
export const decorators = [
41+
withRouter,
42+
withQuery,
43+
withHelmet,
44+
withTheme,
45+
withTimeSyncProvider,
46+
];
4047

4148
/** @type {Preview["parameters"]} */
4249
export const parameters = {
@@ -101,6 +108,22 @@ function withHelmet(Story) {
101108
);
102109
}
103110

111+
const storyDate = new Date("March 15, 2022");
112+
113+
/** @type {Decorator} */
114+
function withTimeSyncProvider(Story) {
115+
return (
116+
<TimeSyncProvider
117+
options={{
118+
initialDatetime: storyDate,
119+
resyncOnNewSubscription: false,
120+
}}
121+
>
122+
<Story />
123+
</TimeSyncProvider>
124+
);
125+
}
126+
104127
/** @type {Decorator} */
105128
function withQuery(Story, { parameters }) {
106129
const queryClient = new QueryClient({

site/src/App.tsx

+1-1
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/components/SignInLayout/SignInLayout.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { FC, PropsWithChildren } from "react";
44

55
export const SignInLayout: FC<PropsWithChildren> = ({ children }) => {
66
const year = useTimeSync({
7-
maxRefreshIntervalMs: Number.POSITIVE_INFINITY,
7+
idealRefreshIntervalMs: Number.POSITIVE_INFINITY,
88
select: (date) => date.getFullYear(),
99
});
1010

site/src/hooks/useTimeSync.tsx

+96-59
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,36 @@
11
/**
22
* @todo Things that still need to be done before this can be called done:
3-
* 1. Finish up the interval reconciliation method
3+
*
4+
* 1. Revamp the entire class definition, and fill out all missing methods
45
* 2. Update the class to respect the resyncOnNewSubscription option
56
* 3. Add tests
67
* 4. See if there's a way to make sure that if you provide a type parameter to
78
* the hook, you must also provide a select function
89
*/
910
import {
11+
type FC,
12+
type PropsWithChildren,
1013
createContext,
1114
useCallback,
1215
useContext,
1316
useId,
1417
useState,
1518
useSyncExternalStore,
16-
type FC,
17-
type PropsWithChildren,
1819
} from "react";
20+
import { useEffectEvent } from "./hookPolyfills";
1921

20-
export const MAX_REFRESH_ONE_SECOND = 1_000;
21-
export const MAX_REFRESH_ONE_MINUTE = 60 * 1_000;
22-
export const MAX_REFRESH_ONE_HOUR = 60 * 60 * 1_000;
23-
export const MAX_REFRESH_ONE_DAY = 24 * 60 * 60 * 1_000;
22+
export const IDEAL_REFRESH_ONE_SECOND = 1_000;
23+
export const IDEAL_REFRESH_ONE_MINUTE = 60 * 1_000;
24+
export const IDEAL_REFRESH_ONE_HOUR = 60 * 60 * 1_000;
25+
export const IDEAL_REFRESH_ONE_DAY = 24 * 60 * 60 * 1_000;
2426

2527
type SetInterval = (fn: () => void, intervalMs: number) => number;
2628
type ClearInterval = (id: number | undefined) => void;
2729

2830
type TimeSyncInitOptions = Readonly<{
2931
/**
30-
* Configures whether adding a new subscription will immediately create a new
31-
* time snapshot and use it to update all other subscriptions.
32+
* Configures whether adding a new subscription will immediately create a
33+
* new time snapshot and use it to update all other subscriptions.
3234
*/
3335
resyncOnNewSubscription: boolean;
3436

@@ -51,7 +53,7 @@ type TimeSyncInitOptions = Readonly<{
5153
/**
5254
* The function to use when clearing intervals.
5355
*
54-
* (i.e., Clearing a previous interval because the TimeSync needs to make a
56+
* (e.g., Clearing a previous interval because the TimeSync needs to make a
5557
* new interval to increase/decrease its update speed.)
5658
*/
5759
clearInterval: ClearInterval;
@@ -67,14 +69,16 @@ const defaultOptions: TimeSyncInitOptions = {
6769

6870
type SubscriptionEntry = Readonly<{
6971
id: string;
70-
maxRefreshIntervalMs: number;
72+
idealRefreshIntervalMs: number;
7173
onUpdate: (newDatetime: Date) => void;
74+
select?: (newSnapshot: Date) => unknown;
7275
}>;
7376

7477
interface TimeSyncApi {
75-
getLatestDatetimeSnapshot: () => Date;
7678
subscribe: (entry: SubscriptionEntry) => () => void;
7779
unsubscribe: (id: string) => void;
80+
getTimeSnapshot: () => Date;
81+
getSelectionSnapshot: <T = unknown>(id: string) => T;
7882
}
7983

8084
/**
@@ -107,8 +111,9 @@ export class TimeSync implements TimeSyncApi {
107111
readonly #createNewDatetime: (prev: Date) => Date;
108112
readonly #setInterval: SetInterval;
109113
readonly #clearInterval: ClearInterval;
114+
readonly #selectionCache: Map<string, unknown>;
110115

111-
#latestSnapshot: Date;
116+
#latestDateSnapshot: Date;
112117
#subscriptions: SubscriptionEntry[];
113118
#latestIntervalId: number | undefined;
114119

@@ -126,8 +131,9 @@ export class TimeSync implements TimeSyncApi {
126131
this.#createNewDatetime = createNewDatetime;
127132
this.#resyncOnNewSubscription = resyncOnNewSubscription;
128133

129-
this.#latestSnapshot = initialDatetime;
134+
this.#latestDateSnapshot = initialDatetime;
130135
this.#subscriptions = [];
136+
this.#selectionCache = new Map();
131137
this.#latestIntervalId = undefined;
132138
}
133139

@@ -138,15 +144,17 @@ export class TimeSync implements TimeSyncApi {
138144
}
139145

140146
const prevFastestInterval =
141-
this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
147+
this.#subscriptions[0]?.idealRefreshIntervalMs ??
148+
Number.POSITIVE_INFINITY;
142149
if (this.#subscriptions.length > 1) {
143150
this.#subscriptions.sort(
144-
(e1, e2) => e1.maxRefreshIntervalMs - e2.maxRefreshIntervalMs,
151+
(e1, e2) => e1.idealRefreshIntervalMs - e2.idealRefreshIntervalMs,
145152
);
146153
}
147154

148155
const newFastestInterval =
149-
this.#subscriptions[0]?.maxRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
156+
this.#subscriptions[0]?.idealRefreshIntervalMs ??
157+
Number.POSITIVE_INFINITY;
150158
if (prevFastestInterval === newFastestInterval) {
151159
return;
152160
}
@@ -160,22 +168,40 @@ export class TimeSync implements TimeSyncApi {
160168
* when/how it should be updated
161169
*/
162170
this.#latestIntervalId = this.#setInterval(() => {
163-
this.#latestSnapshot = this.#createNewDatetime(this.#latestSnapshot);
164-
this.#notifySubscriptions();
171+
this.#latestDateSnapshot = this.#createNewDatetime(
172+
this.#latestDateSnapshot,
173+
);
174+
this.#flushUpdateToSubscriptions();
165175
}, newFastestInterval);
166176
}
167177

168-
#notifySubscriptions(): void {
178+
#flushUpdateToSubscriptions(): void {
169179
for (const subEntry of this.#subscriptions) {
170-
subEntry.onUpdate(this.#latestSnapshot);
180+
if (subEntry.select === undefined) {
181+
subEntry.onUpdate(this.#latestDateSnapshot);
182+
continue;
183+
}
184+
185+
// Keeping things simple by only comparing values React-style with ===.
186+
// If that becomes a problem down the line, we can beef the class up
187+
const prevSelection = this.#selectionCache.get(subEntry.id);
188+
const newSelection = subEntry.select(this.#latestDateSnapshot);
189+
if (prevSelection !== newSelection) {
190+
this.#selectionCache.set(subEntry.id, newSelection);
191+
subEntry.onUpdate(this.#latestDateSnapshot);
192+
}
171193
}
172194
}
173195

174196
// All functions that are part of the public interface must be defined as
175197
// arrow functions, so that they work properly with React
176198

177-
getLatestDatetimeSnapshot = (): Date => {
178-
return this.#latestSnapshot;
199+
getTimeSnapshot = (): Date => {
200+
return this.#latestDateSnapshot;
201+
};
202+
203+
getSelectionSnapshot = <T,>(id: string): T => {
204+
return this.#selectionCache.get(id) as T;
179205
};
180206

181207
unsubscribe = (id: string): void => {
@@ -189,9 +215,9 @@ export class TimeSync implements TimeSyncApi {
189215
};
190216

191217
subscribe = (entry: SubscriptionEntry): (() => void) => {
192-
if (entry.maxRefreshIntervalMs <= 0) {
218+
if (entry.idealRefreshIntervalMs <= 0) {
193219
throw new Error(
194-
`Refresh interval ${entry.maxRefreshIntervalMs} must be a positive integer (or Infinity)`,
220+
`Refresh interval ${entry.idealRefreshIntervalMs} must be a positive integer (or Infinity)`,
195221
);
196222
}
197223

@@ -209,7 +235,7 @@ export class TimeSync implements TimeSyncApi {
209235
}
210236

211237
this.#subscriptions[subIndex] = entry;
212-
if (prev.maxRefreshIntervalMs !== entry.maxRefreshIntervalMs) {
238+
if (prev.idealRefreshIntervalMs !== entry.idealRefreshIntervalMs) {
213239
this.#reconcileRefreshIntervals();
214240
}
215241
return unsub;
@@ -218,15 +244,6 @@ export class TimeSync implements TimeSyncApi {
218244

219245
const timeSyncContext = createContext<TimeSync | null>(null);
220246

221-
function useTimeSyncContext(): TimeSync {
222-
const timeSync = useContext(timeSyncContext);
223-
if (timeSync === null) {
224-
throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
225-
}
226-
227-
return timeSync;
228-
}
229-
230247
type TimeSyncProviderProps = Readonly<
231248
PropsWithChildren<{
232249
options?: Partial<TimeSyncInitOptions>;
@@ -248,7 +265,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
248265
// Making the TimeSync instance be initialized via React State, so that it's
249266
// easy to mock it out for component and story tests. TimeSync itself should
250267
// be treated like a pseudo-ref value, where its values can only be used in
251-
// very specific, React-approved ways (e.g., not directly in a render path)
268+
// very specific, React-approved ways
252269
const [readonlySync] = useState(
253270
() => new TimeSync(options ?? defaultOptions),
254271
);
@@ -261,23 +278,20 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
261278
};
262279

263280
type UseTimeSyncOptions<T = Date> = Readonly<{
264-
maxRefreshIntervalMs: number;
281+
idealRefreshIntervalMs: number;
265282

266283
/**
267284
* Allows you to transform any date values received from the TimeSync class.
268-
*
269-
* Note that select functions are not memoized and will run on every render
270-
* (similar to the ones in React Query and Redux Toolkit's default selectors).
271-
* Select functions should be kept cheap to recalculate.
285+
* Select functions work similarly to the selects from React Query and Redux
286+
* Toolkit – you don't need to memoize them, and they will only run when the
287+
* underlying TimeSync state has changed.
272288
*
273289
* Select functions must not be async. The hook will error out at the type
274290
* level if you provide one by mistake.
275291
*/
276292
select?: (latestDatetime: Date) => T extends Promise<unknown> ? never : T;
277293
}>;
278294

279-
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
280-
281295
/**
282296
* useTimeSync provides React bindings for the TimeSync class, letting a React
283297
* component bind its update lifecycles to interval updates from TimeSync. This
@@ -294,35 +308,58 @@ type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
294308
* interval.
295309
*/
296310
export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
297-
const { select, maxRefreshIntervalMs } = options;
311+
const { select, idealRefreshIntervalMs } = options;
312+
const timeSync = useContext(timeSyncContext);
313+
if (timeSync === null) {
314+
throw new Error("Cannot call useTimeSync outside of a TimeSyncProvider");
315+
}
298316

299317
// Abusing useId a little bit here. It's mainly meant to be used for
300318
// accessibility, but it also gives us a globally unique ID associated with
301319
// whichever component instance is consuming this hook
302320
const hookId = useId();
303-
const timeSync = useTimeSyncContext();
304-
305-
// We need to define this callback using useCallback instead of useEffectEvent
306-
// because we want the memoization to be invaliated when the refresh interval
307-
// changes. (When the subscription callback changes by reference, that causes
308-
// useSyncExternalStore to redo the subscription with the new callback). All
309-
// other values need to be included in the dependency array for correctness,
310-
// but their memory references should always be stable
321+
322+
// This is the one place where we're borderline breaking the React rules.
323+
// useEffectEvent is meant to be used only in useEffect calls, and normally
324+
// shouldn't be called inside a render. But its behavior lines up with
325+
// useSyncExternalStore, letting us cheat a little. useSyncExternalStore's
326+
// state getter callback is called in two scenarios:
327+
// (1) Mid-render on mount
328+
// (2) Whenever React is notified of a state change (outside of React).
329+
//
330+
// Case 2 is basically an effect with extra steps (and single-threaded JS
331+
// gives us assurance about correctness). And for (1), useEffectEvent will be
332+
// initialized with whatever callback you give it on mount. So for the
333+
// mounting render alone, it's safe to call a useEffectEvent callback from
334+
// inside a render.
335+
const stableSelect = useEffectEvent((date: Date): T => {
336+
const recast = date as Date & T;
337+
return select?.(recast) ?? recast;
338+
});
339+
340+
// We need to define this callback using useCallback instead of
341+
// useEffectEvent because we want the memoization to be invaliated when the
342+
// refresh interval changes. (When the subscription callback changes by
343+
// reference, that causes useSyncExternalStore to redo the subscription with
344+
// the new callback). All other values need to be included in the dependency
345+
// array for correctness, but they should always maintain stable memory
346+
// addresses
347+
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
311348
const subscribe = useCallback<ReactSubscriptionCallback>(
312349
(notifyReact) => {
313350
return timeSync.subscribe({
314-
maxRefreshIntervalMs,
315-
onUpdate: notifyReact,
351+
idealRefreshIntervalMs,
316352
id: hookId,
353+
onUpdate: notifyReact,
354+
select: stableSelect,
317355
});
318356
},
319-
[timeSync, hookId, maxRefreshIntervalMs],
357+
[timeSync, hookId, stableSelect, idealRefreshIntervalMs],
320358
);
321359

322-
const currentTime = useSyncExternalStore(subscribe, () =>
323-
timeSync.getLatestDatetimeSnapshot(),
360+
const snapshot = useSyncExternalStore<T>(subscribe, () =>
361+
timeSync.getSelectionSnapshot(hookId),
324362
);
325363

326-
const recast = currentTime as T & Date;
327-
return select?.(recast) ?? recast;
364+
return snapshot;
328365
}

0 commit comments

Comments
 (0)