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

Skip to content

Commit 62e5f8e

Browse files
committed
refactor: split up TimeSync into vanilla file and React file
1 parent 9f258a2 commit 62e5f8e

File tree

2 files changed

+248
-199
lines changed

2 files changed

+248
-199
lines changed

site/src/hooks/useTimeSync.tsx

+53-199
Original file line numberDiff line numberDiff line change
@@ -1,252 +1,107 @@
11
/**
22
* @todo Things that still need to be done before this can be called done:
33
*
4-
* 1. Revamp the entire class definition, and fill out all missing methods
5-
* 2. Update the class to respect the resyncOnNewSubscription option
4+
* 1. Fill out all incomplete methods
5+
* 2. Make sure the class respects the resyncOnNewSubscription option
66
* 3. Add tests
77
* 4. See if there's a way to make sure that if you provide a type parameter to
88
* the hook, you must also provide a select function
99
*/
1010
import {
11+
createContext,
1112
type FC,
1213
type PropsWithChildren,
13-
createContext,
1414
useCallback,
1515
useContext,
1616
useId,
1717
useState,
1818
useSyncExternalStore,
1919
} from "react";
20+
import {
21+
defaultOptions,
22+
type SubscriptionEntry,
23+
TimeSync,
24+
type TimeSyncInitOptions,
25+
} from "utils/TimeSync";
2026
import { useEffectEvent } from "./hookPolyfills";
2127

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;
26-
27-
type SetInterval = (fn: () => void, intervalMs: number) => number;
28-
type ClearInterval = (id: number | undefined) => void;
29-
30-
type TimeSyncInitOptions = Readonly<{
31-
/**
32-
* Configures whether adding a new subscription will immediately create a
33-
* new time snapshot and use it to update all other subscriptions.
34-
*/
35-
resyncOnNewSubscription: boolean;
36-
37-
/**
38-
* The Date value to use when initializing a TimeSync instance.
39-
*/
40-
initialDatetime: Date;
28+
export {
29+
IDEAL_REFRESH_ONE_DAY,
30+
IDEAL_REFRESH_ONE_HOUR,
31+
IDEAL_REFRESH_ONE_MINUTE,
32+
IDEAL_REFRESH_ONE_SECOND,
33+
} from "utils/TimeSync";
4134

42-
/**
43-
* The function to use when creating a new datetime snapshot when a TimeSync
44-
* needs to update on an interval.
45-
*/
46-
createNewDatetime: (prevDatetime: Date) => Date;
35+
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
4736

48-
/**
49-
* The function to use when creating new intervals.
50-
*/
51-
setInterval: SetInterval;
37+
type ReactTimeSyncSubscriptionEntry = Readonly<
38+
SubscriptionEntry & {
39+
select?: (newSnapshot: Date) => unknown;
40+
}
41+
>;
5242

53-
/**
54-
* The function to use when clearing intervals.
55-
*
56-
* (e.g., Clearing a previous interval because the TimeSync needs to make a
57-
* new interval to increase/decrease its update speed.)
58-
*/
59-
clearInterval: ClearInterval;
60-
}>;
43+
type ReactTimeSyncInitOptions = Readonly<
44+
TimeSyncInitOptions & {
45+
/**
46+
* Configures whether adding a new subscription will immediately create
47+
* a new time snapshot and use it to update all other subscriptions.
48+
*/
49+
resyncOnNewSubscription: boolean;
50+
}
51+
>;
6152

62-
const defaultOptions: TimeSyncInitOptions = {
63-
initialDatetime: new Date(),
53+
const defaultReactTimeSyncOptions: ReactTimeSyncInitOptions = {
54+
...defaultOptions,
6455
resyncOnNewSubscription: true,
65-
createNewDatetime: () => new Date(),
66-
setInterval: window.setInterval,
67-
clearInterval: window.clearInterval,
6856
};
6957

70-
type SubscriptionEntry = Readonly<{
71-
id: string;
72-
idealRefreshIntervalMs: number;
73-
onUpdate: (newDatetime: Date) => void;
74-
select?: (newSnapshot: Date) => unknown;
75-
}>;
76-
77-
interface TimeSyncApi {
78-
subscribe: (entry: SubscriptionEntry) => () => void;
79-
unsubscribe: (id: string) => void;
80-
getTimeSnapshot: () => Date;
58+
interface ReactTimeSyncApi {
59+
subscribe: (entry: ReactTimeSyncSubscriptionEntry) => () => void;
8160
getSelectionSnapshot: <T = unknown>(id: string) => T;
8261
}
8362

84-
/**
85-
* TimeSync provides a centralized authority for working with time values in a
86-
* more structured, "pure function-ish" way, where all dependents for the time
87-
* values must stay in sync with each other. (e.g., in a React codebase, you
88-
* want multiple components that rely on time values to update together, to
89-
* avoid screen tearing and stale data for only some parts of the screen).
90-
*
91-
* It lets any number of consumers subscribe to it, requiring that subscribers
92-
* define the slowest possible update interval they need to receive new time
93-
* values for. A value of positive Infinity indicates that a subscriber doesn't
94-
* need updates; if all subscriptions have an update interval of Infinity, the
95-
* class may not dispatch updates.
96-
*
97-
* The class aggregates all the update intervals, and will dispatch updates to
98-
* all consumers based on the fastest refresh interval needed. (e.g., if
99-
* subscriber A needs no updates, but subscriber B needs updates every second,
100-
* BOTH will update every second until subscriber B unsubscribes. After that,
101-
* TimeSync will stop dispatching updates until subscription C gets added, and C
102-
* has a non-Infinite update interval).
103-
*
104-
* By design, there is no way to make one subscriber disable updates. That
105-
* defeats the goal of needing to keep everything in sync with each other. If
106-
* updates are happening too frequently in React, restructure how you're
107-
* composing your components to minimize the costs of re-renders.
108-
*/
109-
export class TimeSync implements TimeSyncApi {
63+
class ReactTimeSync implements ReactTimeSyncApi {
64+
readonly #timeSync: TimeSync;
11065
readonly #resyncOnNewSubscription: boolean;
111-
readonly #createNewDatetime: (prev: Date) => Date;
112-
readonly #setInterval: SetInterval;
113-
readonly #clearInterval: ClearInterval;
11466
readonly #selectionCache: Map<string, unknown>;
11567

116-
#latestDateSnapshot: Date;
117-
#subscriptions: SubscriptionEntry[];
118-
#latestIntervalId: number | undefined;
119-
120-
constructor(options: Partial<TimeSyncInitOptions>) {
68+
constructor(options: Partial<ReactTimeSyncInitOptions>) {
12169
const {
70+
resyncOnNewSubscription = defaultReactTimeSyncOptions.resyncOnNewSubscription,
12271
initialDatetime = defaultOptions.initialDatetime,
123-
resyncOnNewSubscription = defaultOptions.resyncOnNewSubscription,
12472
createNewDatetime = defaultOptions.createNewDatetime,
12573
setInterval = defaultOptions.setInterval,
12674
clearInterval = defaultOptions.clearInterval,
12775
} = options;
12876

129-
this.#setInterval = setInterval;
130-
this.#clearInterval = clearInterval;
131-
this.#createNewDatetime = createNewDatetime;
132-
this.#resyncOnNewSubscription = resyncOnNewSubscription;
133-
134-
this.#latestDateSnapshot = initialDatetime;
135-
this.#subscriptions = [];
13677
this.#selectionCache = new Map();
137-
this.#latestIntervalId = undefined;
138-
}
139-
140-
#reconcileRefreshIntervals(): void {
141-
if (this.#subscriptions.length === 0) {
142-
this.#clearInterval(this.#latestIntervalId);
143-
return;
144-
}
145-
146-
const prevFastestInterval =
147-
this.#subscriptions[0]?.idealRefreshIntervalMs ??
148-
Number.POSITIVE_INFINITY;
149-
if (this.#subscriptions.length > 1) {
150-
this.#subscriptions.sort(
151-
(e1, e2) => e1.idealRefreshIntervalMs - e2.idealRefreshIntervalMs,
152-
);
153-
}
154-
155-
const newFastestInterval =
156-
this.#subscriptions[0]?.idealRefreshIntervalMs ??
157-
Number.POSITIVE_INFINITY;
158-
if (prevFastestInterval === newFastestInterval) {
159-
return;
160-
}
161-
if (newFastestInterval === Number.POSITIVE_INFINITY) {
162-
this.#clearInterval(this.#latestIntervalId);
163-
return;
164-
}
165-
166-
/**
167-
* @todo Figure out the conditions when the interval should be set up, and
168-
* when/how it should be updated
169-
*/
170-
this.#latestIntervalId = this.#setInterval(() => {
171-
this.#latestDateSnapshot = this.#createNewDatetime(
172-
this.#latestDateSnapshot,
173-
);
174-
this.#flushUpdateToSubscriptions();
175-
}, newFastestInterval);
176-
}
177-
178-
#flushUpdateToSubscriptions(): void {
179-
for (const subEntry of this.#subscriptions) {
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-
}
193-
}
78+
this.#resyncOnNewSubscription = resyncOnNewSubscription;
79+
this.#timeSync = new TimeSync({
80+
initialDatetime,
81+
createNewDatetime,
82+
setInterval,
83+
clearInterval,
84+
});
19485
}
19586

19687
// All functions that are part of the public interface must be defined as
19788
// arrow functions, so that they work properly with React
19889

199-
getTimeSnapshot = (): Date => {
200-
return this.#latestDateSnapshot;
90+
subscribe = (entry: ReactTimeSyncSubscriptionEntry): (() => void) => {
91+
this.#timeSync.subscribe(entry);
92+
return () => this.#timeSync.unsubscribe(entry.id);
20193
};
20294

20395
getSelectionSnapshot = <T,>(id: string): T => {
20496
return this.#selectionCache.get(id) as T;
20597
};
206-
207-
unsubscribe = (id: string): void => {
208-
const updated = this.#subscriptions.filter((s) => s.id !== id);
209-
if (updated.length === this.#subscriptions.length) {
210-
return;
211-
}
212-
213-
this.#subscriptions = updated;
214-
this.#reconcileRefreshIntervals();
215-
};
216-
217-
subscribe = (entry: SubscriptionEntry): (() => void) => {
218-
if (entry.idealRefreshIntervalMs <= 0) {
219-
throw new Error(
220-
`Refresh interval ${entry.idealRefreshIntervalMs} must be a positive integer (or Infinity)`,
221-
);
222-
}
223-
224-
const unsub = () => this.unsubscribe(entry.id);
225-
const subIndex = this.#subscriptions.findIndex((s) => s.id === entry.id);
226-
if (subIndex === -1) {
227-
this.#subscriptions.push(entry);
228-
this.#reconcileRefreshIntervals();
229-
return unsub;
230-
}
231-
232-
const prev = this.#subscriptions[subIndex];
233-
if (prev === undefined) {
234-
throw new Error("Went out of bounds");
235-
}
236-
237-
this.#subscriptions[subIndex] = entry;
238-
if (prev.idealRefreshIntervalMs !== entry.idealRefreshIntervalMs) {
239-
this.#reconcileRefreshIntervals();
240-
}
241-
return unsub;
242-
};
24398
}
24499

245-
const timeSyncContext = createContext<TimeSync | null>(null);
100+
const timeSyncContext = createContext<ReactTimeSync | null>(null);
246101

247102
type TimeSyncProviderProps = Readonly<
248103
PropsWithChildren<{
249-
options?: Partial<TimeSyncInitOptions>;
104+
options?: Partial<ReactTimeSyncInitOptions>;
250105
}>
251106
>;
252107

@@ -267,7 +122,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
267122
// be treated like a pseudo-ref value, where its values can only be used in
268123
// very specific, React-approved ways
269124
const [readonlySync] = useState(
270-
() => new TimeSync(options ?? defaultOptions),
125+
() => new ReactTimeSync(options ?? defaultReactTimeSyncOptions),
271126
);
272127

273128
return (
@@ -328,8 +183,8 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
328183
// (2) Whenever React is notified of a state change (outside of React).
329184
//
330185
// 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
186+
// gives us assurance about correctness). And for (1), useEffectEvent will
187+
// be initialized with whatever callback you give it on mount. So for the
333188
// mounting render alone, it's safe to call a useEffectEvent callback from
334189
// inside a render.
335190
const stableSelect = useEffectEvent((date: Date): T => {
@@ -344,7 +199,6 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
344199
// the new callback). All other values need to be included in the dependency
345200
// array for correctness, but they should always maintain stable memory
346201
// addresses
347-
type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
348202
const subscribe = useCallback<ReactSubscriptionCallback>(
349203
(notifyReact) => {
350204
return timeSync.subscribe({

0 commit comments

Comments
 (0)