1
1
/**
2
2
* @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
4
5
* 2. Update the class to respect the resyncOnNewSubscription option
5
6
* 3. Add tests
6
7
* 4. See if there's a way to make sure that if you provide a type parameter to
7
8
* the hook, you must also provide a select function
8
9
*/
9
10
import {
11
+ type FC ,
12
+ type PropsWithChildren ,
10
13
createContext ,
11
14
useCallback ,
12
15
useContext ,
13
16
useId ,
14
17
useState ,
15
18
useSyncExternalStore ,
16
- type FC ,
17
- type PropsWithChildren ,
18
19
} from "react" ;
20
+ import { useEffectEvent } from "./hookPolyfills" ;
19
21
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 ;
24
26
25
27
type SetInterval = ( fn : ( ) => void , intervalMs : number ) => number ;
26
28
type ClearInterval = ( id : number | undefined ) => void ;
27
29
28
30
type TimeSyncInitOptions = Readonly < {
29
31
/**
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.
32
34
*/
33
35
resyncOnNewSubscription : boolean ;
34
36
@@ -51,7 +53,7 @@ type TimeSyncInitOptions = Readonly<{
51
53
/**
52
54
* The function to use when clearing intervals.
53
55
*
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
55
57
* new interval to increase/decrease its update speed.)
56
58
*/
57
59
clearInterval : ClearInterval ;
@@ -67,14 +69,16 @@ const defaultOptions: TimeSyncInitOptions = {
67
69
68
70
type SubscriptionEntry = Readonly < {
69
71
id : string ;
70
- maxRefreshIntervalMs : number ;
72
+ idealRefreshIntervalMs : number ;
71
73
onUpdate : ( newDatetime : Date ) => void ;
74
+ select ?: ( newSnapshot : Date ) => unknown ;
72
75
} > ;
73
76
74
77
interface TimeSyncApi {
75
- getLatestDatetimeSnapshot : ( ) => Date ;
76
78
subscribe : ( entry : SubscriptionEntry ) => ( ) => void ;
77
79
unsubscribe : ( id : string ) => void ;
80
+ getTimeSnapshot : ( ) => Date ;
81
+ getSelectionSnapshot : < T = unknown > ( id : string ) => T ;
78
82
}
79
83
80
84
/**
@@ -107,8 +111,9 @@ export class TimeSync implements TimeSyncApi {
107
111
readonly #createNewDatetime: ( prev : Date ) => Date ;
108
112
readonly #setInterval: SetInterval ;
109
113
readonly #clearInterval: ClearInterval ;
114
+ readonly #selectionCache: Map < string , unknown > ;
110
115
111
- #latestSnapshot : Date ;
116
+ #latestDateSnapshot : Date ;
112
117
#subscriptions: SubscriptionEntry [ ] ;
113
118
#latestIntervalId: number | undefined ;
114
119
@@ -126,8 +131,9 @@ export class TimeSync implements TimeSyncApi {
126
131
this . #createNewDatetime = createNewDatetime ;
127
132
this . #resyncOnNewSubscription = resyncOnNewSubscription ;
128
133
129
- this . #latestSnapshot = initialDatetime ;
134
+ this . #latestDateSnapshot = initialDatetime ;
130
135
this . #subscriptions = [ ] ;
136
+ this . #selectionCache = new Map ( ) ;
131
137
this . #latestIntervalId = undefined ;
132
138
}
133
139
@@ -138,15 +144,17 @@ export class TimeSync implements TimeSyncApi {
138
144
}
139
145
140
146
const prevFastestInterval =
141
- this . #subscriptions[ 0 ] ?. maxRefreshIntervalMs ?? Number . POSITIVE_INFINITY ;
147
+ this . #subscriptions[ 0 ] ?. idealRefreshIntervalMs ??
148
+ Number . POSITIVE_INFINITY ;
142
149
if ( this . #subscriptions. length > 1 ) {
143
150
this . #subscriptions. sort (
144
- ( e1 , e2 ) => e1 . maxRefreshIntervalMs - e2 . maxRefreshIntervalMs ,
151
+ ( e1 , e2 ) => e1 . idealRefreshIntervalMs - e2 . idealRefreshIntervalMs ,
145
152
) ;
146
153
}
147
154
148
155
const newFastestInterval =
149
- this . #subscriptions[ 0 ] ?. maxRefreshIntervalMs ?? Number . POSITIVE_INFINITY ;
156
+ this . #subscriptions[ 0 ] ?. idealRefreshIntervalMs ??
157
+ Number . POSITIVE_INFINITY ;
150
158
if ( prevFastestInterval === newFastestInterval ) {
151
159
return ;
152
160
}
@@ -160,22 +168,40 @@ export class TimeSync implements TimeSyncApi {
160
168
* when/how it should be updated
161
169
*/
162
170
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( ) ;
165
175
} , newFastestInterval ) ;
166
176
}
167
177
168
- #notifySubscriptions ( ) : void {
178
+ #flushUpdateToSubscriptions ( ) : void {
169
179
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
+ }
171
193
}
172
194
}
173
195
174
196
// All functions that are part of the public interface must be defined as
175
197
// arrow functions, so that they work properly with React
176
198
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 ;
179
205
} ;
180
206
181
207
unsubscribe = ( id : string ) : void => {
@@ -189,9 +215,9 @@ export class TimeSync implements TimeSyncApi {
189
215
} ;
190
216
191
217
subscribe = ( entry : SubscriptionEntry ) : ( ( ) => void ) => {
192
- if ( entry . maxRefreshIntervalMs <= 0 ) {
218
+ if ( entry . idealRefreshIntervalMs <= 0 ) {
193
219
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)` ,
195
221
) ;
196
222
}
197
223
@@ -209,7 +235,7 @@ export class TimeSync implements TimeSyncApi {
209
235
}
210
236
211
237
this . #subscriptions[ subIndex ] = entry ;
212
- if ( prev . maxRefreshIntervalMs !== entry . maxRefreshIntervalMs ) {
238
+ if ( prev . idealRefreshIntervalMs !== entry . idealRefreshIntervalMs ) {
213
239
this . #reconcileRefreshIntervals( ) ;
214
240
}
215
241
return unsub ;
@@ -218,15 +244,6 @@ export class TimeSync implements TimeSyncApi {
218
244
219
245
const timeSyncContext = createContext < TimeSync | null > ( null ) ;
220
246
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
-
230
247
type TimeSyncProviderProps = Readonly <
231
248
PropsWithChildren < {
232
249
options ?: Partial < TimeSyncInitOptions > ;
@@ -248,7 +265,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
248
265
// Making the TimeSync instance be initialized via React State, so that it's
249
266
// easy to mock it out for component and story tests. TimeSync itself should
250
267
// 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
252
269
const [ readonlySync ] = useState (
253
270
( ) => new TimeSync ( options ?? defaultOptions ) ,
254
271
) ;
@@ -261,23 +278,20 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
261
278
} ;
262
279
263
280
type UseTimeSyncOptions < T = Date > = Readonly < {
264
- maxRefreshIntervalMs : number ;
281
+ idealRefreshIntervalMs : number ;
265
282
266
283
/**
267
284
* 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.
272
288
*
273
289
* Select functions must not be async. The hook will error out at the type
274
290
* level if you provide one by mistake.
275
291
*/
276
292
select ?: ( latestDatetime : Date ) => T extends Promise < unknown > ? never : T ;
277
293
} > ;
278
294
279
- type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
280
-
281
295
/**
282
296
* useTimeSync provides React bindings for the TimeSync class, letting a React
283
297
* component bind its update lifecycles to interval updates from TimeSync. This
@@ -294,35 +308,58 @@ type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
294
308
* interval.
295
309
*/
296
310
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
+ }
298
316
299
317
// Abusing useId a little bit here. It's mainly meant to be used for
300
318
// accessibility, but it also gives us a globally unique ID associated with
301
319
// whichever component instance is consuming this hook
302
320
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 ;
311
348
const subscribe = useCallback < ReactSubscriptionCallback > (
312
349
( notifyReact ) => {
313
350
return timeSync . subscribe ( {
314
- maxRefreshIntervalMs,
315
- onUpdate : notifyReact ,
351
+ idealRefreshIntervalMs,
316
352
id : hookId ,
353
+ onUpdate : notifyReact ,
354
+ select : stableSelect ,
317
355
} ) ;
318
356
} ,
319
- [ timeSync , hookId , maxRefreshIntervalMs ] ,
357
+ [ timeSync , hookId , stableSelect , idealRefreshIntervalMs ] ,
320
358
) ;
321
359
322
- const currentTime = useSyncExternalStore ( subscribe , ( ) =>
323
- timeSync . getLatestDatetimeSnapshot ( ) ,
360
+ const snapshot = useSyncExternalStore < T > ( subscribe , ( ) =>
361
+ timeSync . getSelectionSnapshot ( hookId ) ,
324
362
) ;
325
363
326
- const recast = currentTime as T & Date ;
327
- return select ?.( recast ) ?? recast ;
364
+ return snapshot ;
328
365
}
0 commit comments