1
1
/**
2
2
* @todo Things that still need to be done before this can be called done:
3
3
*
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
6
6
* 3. Add tests
7
7
* 4. See if there's a way to make sure that if you provide a type parameter to
8
8
* the hook, you must also provide a select function
9
9
*/
10
10
import {
11
+ createContext ,
11
12
type FC ,
12
13
type PropsWithChildren ,
13
- createContext ,
14
14
useCallback ,
15
15
useContext ,
16
16
useId ,
17
17
useState ,
18
18
useSyncExternalStore ,
19
19
} from "react" ;
20
+ import {
21
+ defaultOptions ,
22
+ type SubscriptionEntry ,
23
+ TimeSync ,
24
+ type TimeSyncInitOptions ,
25
+ } from "utils/TimeSync" ;
20
26
import { useEffectEvent } from "./hookPolyfills" ;
21
27
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" ;
41
34
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 ;
47
36
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
+ > ;
52
42
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
+ > ;
61
52
62
- const defaultOptions : TimeSyncInitOptions = {
63
- initialDatetime : new Date ( ) ,
53
+ const defaultReactTimeSyncOptions : ReactTimeSyncInitOptions = {
54
+ ... defaultOptions ,
64
55
resyncOnNewSubscription : true ,
65
- createNewDatetime : ( ) => new Date ( ) ,
66
- setInterval : window . setInterval ,
67
- clearInterval : window . clearInterval ,
68
56
} ;
69
57
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 ;
81
60
getSelectionSnapshot : < T = unknown > ( id : string ) => T ;
82
61
}
83
62
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 ;
110
65
readonly #resyncOnNewSubscription: boolean ;
111
- readonly #createNewDatetime: ( prev : Date ) => Date ;
112
- readonly #setInterval: SetInterval ;
113
- readonly #clearInterval: ClearInterval ;
114
66
readonly #selectionCache: Map < string , unknown > ;
115
67
116
- #latestDateSnapshot: Date ;
117
- #subscriptions: SubscriptionEntry [ ] ;
118
- #latestIntervalId: number | undefined ;
119
-
120
- constructor ( options : Partial < TimeSyncInitOptions > ) {
68
+ constructor ( options : Partial < ReactTimeSyncInitOptions > ) {
121
69
const {
70
+ resyncOnNewSubscription = defaultReactTimeSyncOptions . resyncOnNewSubscription ,
122
71
initialDatetime = defaultOptions . initialDatetime ,
123
- resyncOnNewSubscription = defaultOptions . resyncOnNewSubscription ,
124
72
createNewDatetime = defaultOptions . createNewDatetime ,
125
73
setInterval = defaultOptions . setInterval ,
126
74
clearInterval = defaultOptions . clearInterval ,
127
75
} = options ;
128
76
129
- this . #setInterval = setInterval ;
130
- this . #clearInterval = clearInterval ;
131
- this . #createNewDatetime = createNewDatetime ;
132
- this . #resyncOnNewSubscription = resyncOnNewSubscription ;
133
-
134
- this . #latestDateSnapshot = initialDatetime ;
135
- this . #subscriptions = [ ] ;
136
77
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
+ } ) ;
194
85
}
195
86
196
87
// All functions that are part of the public interface must be defined as
197
88
// arrow functions, so that they work properly with React
198
89
199
- getTimeSnapshot = ( ) : Date => {
200
- return this . #latestDateSnapshot;
90
+ subscribe = ( entry : ReactTimeSyncSubscriptionEntry ) : ( ( ) => void ) => {
91
+ this . #timeSync. subscribe ( entry ) ;
92
+ return ( ) => this . #timeSync. unsubscribe ( entry . id ) ;
201
93
} ;
202
94
203
95
getSelectionSnapshot = < T , > ( id : string ) : T => {
204
96
return this . #selectionCache. get ( id ) as T ;
205
97
} ;
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
- } ;
243
98
}
244
99
245
- const timeSyncContext = createContext < TimeSync | null > ( null ) ;
100
+ const timeSyncContext = createContext < ReactTimeSync | null > ( null ) ;
246
101
247
102
type TimeSyncProviderProps = Readonly <
248
103
PropsWithChildren < {
249
- options ?: Partial < TimeSyncInitOptions > ;
104
+ options ?: Partial < ReactTimeSyncInitOptions > ;
250
105
} >
251
106
> ;
252
107
@@ -267,7 +122,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
267
122
// be treated like a pseudo-ref value, where its values can only be used in
268
123
// very specific, React-approved ways
269
124
const [ readonlySync ] = useState (
270
- ( ) => new TimeSync ( options ?? defaultOptions ) ,
125
+ ( ) => new ReactTimeSync ( options ?? defaultReactTimeSyncOptions ) ,
271
126
) ;
272
127
273
128
return (
@@ -328,8 +183,8 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
328
183
// (2) Whenever React is notified of a state change (outside of React).
329
184
//
330
185
// 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
333
188
// mounting render alone, it's safe to call a useEffectEvent callback from
334
189
// inside a render.
335
190
const stableSelect = useEffectEvent ( ( date : Date ) : T => {
@@ -344,7 +199,6 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
344
199
// the new callback). All other values need to be included in the dependency
345
200
// array for correctness, but they should always maintain stable memory
346
201
// addresses
347
- type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
348
202
const subscribe = useCallback < ReactSubscriptionCallback > (
349
203
( notifyReact ) => {
350
204
return timeSync . subscribe ( {
0 commit comments