1
1
/**
2
2
* @todo Things that still need to be done before this can be called done:
3
3
*
4
- * 1. Finish up the interval reconciliation method
4
+ * 1. Revamp the entire class definition, and fill out all missing methods
5
5
* 2. Update the class to respect the resyncOnNewSubscription option
6
6
* 3. Make it so that if you provide an explicit type parameter to the hook
7
7
* call, you must provide a runtime select function
8
8
* 4. Add tests
9
9
*/
10
10
import {
11
+ type FC ,
12
+ type PropsWithChildren ,
11
13
createContext ,
12
14
useCallback ,
13
15
useContext ,
14
16
useId ,
15
17
useState ,
16
18
useSyncExternalStore ,
17
- type FC ,
18
- type PropsWithChildren ,
19
19
} from "react" ;
20
+ import { useEffectEvent } from "./hookPolyfills" ;
20
21
21
22
export const IDEAL_REFRESH_ONE_SECOND = 1_000 ;
22
23
export const IDEAL_REFRESH_ONE_MINUTE = 60 * 1_000 ;
@@ -70,12 +71,14 @@ type SubscriptionEntry = Readonly<{
70
71
id : string ;
71
72
idealRefreshIntervalMs : number ;
72
73
onUpdate : ( newDatetime : Date ) => void ;
74
+ select ?: ( newSnapshot : Date ) => unknown ;
73
75
} > ;
74
76
75
77
interface TimeSyncApi {
76
- getLatestDatetimeSnapshot : ( ) => Date ;
77
78
subscribe : ( entry : SubscriptionEntry ) => ( ) => void ;
78
79
unsubscribe : ( id : string ) => void ;
80
+ getTimeSnapshot : ( ) => Date ;
81
+ getSelectionSnapshot : < T = unknown > ( id : string ) => T ;
79
82
}
80
83
81
84
/**
@@ -109,8 +112,9 @@ export class TimeSync implements TimeSyncApi {
109
112
readonly #setInterval: SetInterval ;
110
113
readonly #clearInterval: ClearInterval ;
111
114
112
- #latestSnapshot : Date ;
115
+ #latestDateSnapshot : Date ;
113
116
#subscriptions: SubscriptionEntry [ ] ;
117
+ #selectionCache: Map < string , unknown > ;
114
118
#latestIntervalId: number | undefined ;
115
119
116
120
constructor ( options : Partial < TimeSyncInitOptions > ) {
@@ -127,8 +131,9 @@ export class TimeSync implements TimeSyncApi {
127
131
this . #createNewDatetime = createNewDatetime ;
128
132
this . #resyncOnNewSubscription = resyncOnNewSubscription ;
129
133
130
- this . #latestSnapshot = initialDatetime ;
134
+ this . #latestDateSnapshot = initialDatetime ;
131
135
this . #subscriptions = [ ] ;
136
+ this . #selectionCache = new Map ( ) ;
132
137
this . #latestIntervalId = undefined ;
133
138
}
134
139
@@ -139,15 +144,17 @@ export class TimeSync implements TimeSyncApi {
139
144
}
140
145
141
146
const prevFastestInterval =
142
- this . #subscriptions[ 0 ] ?. idealRefreshIntervalMs ?? Number . POSITIVE_INFINITY ;
147
+ this . #subscriptions[ 0 ] ?. idealRefreshIntervalMs ??
148
+ Number . POSITIVE_INFINITY ;
143
149
if ( this . #subscriptions. length > 1 ) {
144
150
this . #subscriptions. sort (
145
151
( e1 , e2 ) => e1 . idealRefreshIntervalMs - e2 . idealRefreshIntervalMs ,
146
152
) ;
147
153
}
148
154
149
155
const newFastestInterval =
150
- this . #subscriptions[ 0 ] ?. idealRefreshIntervalMs ?? Number . POSITIVE_INFINITY ;
156
+ this . #subscriptions[ 0 ] ?. idealRefreshIntervalMs ??
157
+ Number . POSITIVE_INFINITY ;
151
158
if ( prevFastestInterval === newFastestInterval ) {
152
159
return ;
153
160
}
@@ -161,22 +168,28 @@ export class TimeSync implements TimeSyncApi {
161
168
* when/how it should be updated
162
169
*/
163
170
this . #latestIntervalId = this . #setInterval( ( ) => {
164
- this . #latestSnapshot = this . #createNewDatetime( this . #latestSnapshot) ;
171
+ this . #latestDateSnapshot = this . #createNewDatetime(
172
+ this . #latestDateSnapshot,
173
+ ) ;
165
174
this . #notifySubscriptions( ) ;
166
175
} , newFastestInterval ) ;
167
176
}
168
177
169
178
#notifySubscriptions( ) : void {
170
179
for ( const subEntry of this . #subscriptions) {
171
- subEntry . onUpdate ( this . #latestSnapshot ) ;
180
+ subEntry . onUpdate ( this . #latestDateSnapshot ) ;
172
181
}
173
182
}
174
183
175
184
// All functions that are part of the public interface must be defined as
176
185
// arrow functions, so that they work properly with React
177
186
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 ;
180
193
} ;
181
194
182
195
unsubscribe = ( id : string ) : void => {
@@ -267,8 +280,6 @@ type UseTimeSyncOptions<T = Date> = Readonly<{
267
280
select ?: ( latestDatetime : Date ) => T extends Promise < unknown > ? never : T ;
268
281
} > ;
269
282
270
- type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
271
-
272
283
/**
273
284
* useTimeSync provides React bindings for the TimeSync class, letting a React
274
285
* component bind its update lifecycles to interval updates from TimeSync. This
@@ -286,43 +297,57 @@ type ReactSubscriptionCallback = (notifyReact: () => void) => () => void;
286
297
*/
287
298
export function useTimeSync < T = Date > ( options : UseTimeSyncOptions < T > ) : T {
288
299
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
+ }
289
304
290
305
// Abusing useId a little bit here. It's mainly meant to be used for
291
306
// accessibility, but it also gives us a globally unique ID associated with
292
307
// whichever component instance is consuming this hook
293
308
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
+ } ) ;
298
327
299
328
// We need to define this callback using useCallback instead of
300
329
// useEffectEvent because we want the memoization to be invaliated when the
301
330
// refresh interval changes. (When the subscription callback changes by
302
331
// reference, that causes useSyncExternalStore to redo the subscription with
303
332
// 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 ;
306
336
const subscribe = useCallback < ReactSubscriptionCallback > (
307
337
( notifyReact ) => {
308
338
return timeSync . subscribe ( {
309
339
idealRefreshIntervalMs,
310
- onUpdate : notifyReact ,
311
340
id : hookId ,
341
+ onUpdate : notifyReact ,
342
+ select : stableSelect ,
312
343
} ) ;
313
344
} ,
314
- [ timeSync , hookId , idealRefreshIntervalMs ] ,
345
+ [ timeSync , hookId , stableSelect , idealRefreshIntervalMs ] ,
315
346
) ;
316
347
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
+ ) ;
326
351
327
- return useSyncExternalStore < T > ( subscribe , getNewState ) ;
352
+ return snapshot ;
328
353
}
0 commit comments