@@ -31,23 +31,27 @@ export {
31
31
IDEAL_REFRESH_ONE_SECOND ,
32
32
} from "utils/TimeSync" ;
33
33
34
- type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
34
+ type SelectCallback = ( newSnapshot : Date ) => unknown ;
35
35
36
- type ReactTimeSyncSubscriptionEntry = Readonly <
36
+ type ReactSubscriptionEntry = Readonly <
37
37
SubscriptionEntry & {
38
- select ?: ( newSnapshot : Date ) => unknown ;
38
+ select ?: SelectCallback ;
39
39
}
40
40
> ;
41
41
42
42
// Need to wrap each value that we put in the selection cache, so that when we
43
43
// try to retrieve a value, it's easy to differentiate between a value being
44
44
// undefined because that's an explicit selection value, versus it being
45
45
// undefined because we forgot to set it in the cache
46
- type SelectionCacheEntry = Readonly < { value : unknown } > ;
46
+ type SelectionCacheEntry = Readonly < {
47
+ value : unknown ;
48
+ select ?: SelectCallback ;
49
+ } > ;
47
50
48
51
interface ReactTimeSyncApi {
49
- subscribe : ( entry : ReactTimeSyncSubscriptionEntry ) => ( ) => void ;
52
+ subscribe : ( entry : ReactSubscriptionEntry ) => ( ) => void ;
50
53
getSelectionSnapshot : < T = unknown > ( id : string ) => T ;
54
+ invalidateSelection : ( id : string , select ?: SelectCallback ) => void ;
51
55
}
52
56
53
57
class ReactTimeSync implements ReactTimeSyncApi {
@@ -62,9 +66,18 @@ class ReactTimeSync implements ReactTimeSyncApi {
62
66
// All functions that are part of the public interface must be defined as
63
67
// arrow functions, so that they work properly with React
64
68
65
- subscribe = ( entry : ReactTimeSyncSubscriptionEntry ) : ( ( ) => void ) => {
69
+ subscribe = ( entry : ReactSubscriptionEntry ) : ( ( ) => void ) => {
66
70
const { select, id, idealRefreshIntervalMs, onUpdate } = entry ;
67
71
72
+ const updateCacheEntry = ( ) => {
73
+ const date = this . #timeSync. getTimeSnapshot ( ) ;
74
+ const cacheValue = select ?.( date ) ?? date ;
75
+ this . #selectionCache. set ( id , {
76
+ value : cacheValue ,
77
+ select,
78
+ } ) ;
79
+ } ;
80
+
68
81
// Make sure that we subscribe first, in case TimeSync is configured to
69
82
// invalidate the snapshot on a new subscription. Want to remove risk of
70
83
// stale data
@@ -73,21 +86,18 @@ class ReactTimeSync implements ReactTimeSyncApi {
73
86
idealRefreshIntervalMs,
74
87
onUpdate : ( newDate ) => {
75
88
const prevSelection = this . getSelectionSnapshot ( id ) ;
76
- const newSelection : unknown = select ?.( newDate ) ?? newDate ;
89
+ const newSelection = select ?.( newDate ) ?? newDate ;
77
90
if ( newSelection === prevSelection ) {
78
91
return ;
79
92
}
80
93
81
- this . #selectionCache . set ( id , { value : newSelection } ) ;
94
+ updateCacheEntry ( ) ;
82
95
onUpdate ( newDate ) ;
83
96
} ,
84
97
} ;
85
98
this . #timeSync. subscribe ( patchedEntry ) ;
86
99
87
- const date = this . #timeSync. getTimeSnapshot ( ) ;
88
- const cacheValue = select ?.( date ) ?? date ;
89
- this . #selectionCache. set ( id , { value : cacheValue } ) ;
90
-
100
+ updateCacheEntry ( ) ;
91
101
return ( ) => this . #timeSync. unsubscribe ( id ) ;
92
102
} ;
93
103
@@ -108,6 +118,23 @@ class ReactTimeSync implements ReactTimeSyncApi {
108
118
109
119
return cacheEntry . value as T ;
110
120
} ;
121
+
122
+ invalidateSelection = ( id : string , select ?: SelectCallback ) : void => {
123
+ const cacheEntry = this . #selectionCache. get ( id ) ;
124
+ if ( cacheEntry === undefined ) {
125
+ return ;
126
+ }
127
+
128
+ const dateSnapshot = this . #timeSync. getTimeSnapshot ( ) ;
129
+ const newSelection = select ?.( dateSnapshot ) ?? dateSnapshot ;
130
+
131
+ // Keep the old select callback, because only that version will be
132
+ // memoized via useEffectEvent
133
+ this . #selectionCache. set ( id , {
134
+ value : newSelection ,
135
+ select : cacheEntry . select ,
136
+ } ) ;
137
+ } ;
111
138
}
112
139
113
140
const timeSyncContext = createContext < ReactTimeSync | null > ( null ) ;
@@ -147,6 +174,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
147
174
148
175
type UseTimeSyncOptions < T = Date > = Readonly < {
149
176
idealRefreshIntervalMs : number ;
177
+ selectDeps ?: readonly unknown [ ] ;
150
178
151
179
/**
152
180
* Allows you to transform any date values received from the TimeSync class.
@@ -162,7 +190,7 @@ type UseTimeSyncOptions<T = Date> = Readonly<{
162
190
163
191
/**
164
192
* useTimeSync provides React bindings for the TimeSync class, letting a React
165
- * component bind its update lifecycles to interval updates from TimeSync. This
193
+ * component bind its update life cycles to interval updates from TimeSync. This
166
194
* hook should be used anytime you would want to use a Date instance directly in
167
195
* a component render path.
168
196
*
@@ -176,7 +204,7 @@ type UseTimeSyncOptions<T = Date> = Readonly<{
176
204
* interval.
177
205
*/
178
206
export function useTimeSync < T = Date > ( options : UseTimeSyncOptions < T > ) : T {
179
- const { select, idealRefreshIntervalMs } = options ;
207
+ const { select, selectDeps , idealRefreshIntervalMs } = options ;
180
208
const timeSync = useContext ( timeSyncContext ) ;
181
209
if ( timeSync === null ) {
182
210
throw new Error ( "Cannot call useTimeSync outside of a TimeSyncProvider" ) ;
@@ -200,7 +228,7 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
200
228
// be initialized with whatever callback you give it on mount. So for the
201
229
// mounting render alone, it's safe to call a useEffectEvent callback from
202
230
// inside a render.
203
- const stableSelect = useEffectEvent ( ( date : Date ) : T => {
231
+ const selectForOutsideReact = useEffectEvent ( ( date : Date ) : T => {
204
232
const recast = date as Date & T ;
205
233
return select ?.( recast ) ?? recast ;
206
234
} ) ;
@@ -212,21 +240,64 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
212
240
// the new callback). All other values need to be included in the dependency
213
241
// array for correctness, but they should always maintain stable memory
214
242
// addresses
243
+ type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
215
244
const subscribe = useCallback < ReactSubscriptionCallback > (
216
245
( notifyReact ) => {
217
246
return timeSync . subscribe ( {
218
247
idealRefreshIntervalMs,
219
248
id : hookId ,
220
249
onUpdate : notifyReact ,
221
- select : stableSelect ,
250
+ select : selectForOutsideReact ,
222
251
} ) ;
223
252
} ,
224
- [ timeSync , hookId , stableSelect , idealRefreshIntervalMs ] ,
253
+ [ timeSync , hookId , selectForOutsideReact , idealRefreshIntervalMs ] ,
225
254
) ;
226
255
227
- const snapshot = useSyncExternalStore < T > ( subscribe , ( ) =>
228
- timeSync . getSelectionSnapshot ( hookId ) ,
229
- ) ;
256
+ const [ prevDeps , setPrevDeps ] = useState ( selectDeps ) ;
257
+ const depsAreInvalidated = areDepsInvalidated ( prevDeps , selectDeps ) ;
258
+
259
+ const selection = useSyncExternalStore < T > ( subscribe , ( ) => {
260
+ if ( depsAreInvalidated ) {
261
+ // Need to make sure that we use the un-memoized version of select
262
+ // here because we need to call select callback mid-render to
263
+ // guarantee no stale data. The memoized version only syncs AFTER
264
+ // the current render has finished in full.
265
+ timeSync . invalidateSelection ( hookId , select ) ;
266
+ }
267
+ return timeSync . getSelectionSnapshot ( hookId ) ;
268
+ } ) ;
269
+
270
+ // Setting state mid-render like this is valid, but we just need to make
271
+ // sure that we wait until after the useSyncExternalStore state getter runs
272
+ if ( depsAreInvalidated ) {
273
+ setPrevDeps ( selectDeps ) ;
274
+ }
275
+
276
+ return selection ;
277
+ }
278
+
279
+ function areDepsInvalidated (
280
+ oldDeps : readonly unknown [ ] | undefined ,
281
+ newDeps : readonly unknown [ ] | undefined ,
282
+ ) : boolean {
283
+ if ( oldDeps === undefined ) {
284
+ if ( newDeps === undefined ) {
285
+ return false ;
286
+ }
287
+ return true ;
288
+ }
289
+
290
+ const oldRecast = oldDeps as readonly unknown [ ] ;
291
+ const newRecast = oldDeps as readonly unknown [ ] ;
292
+ if ( oldRecast . length !== newRecast . length ) {
293
+ return true ;
294
+ }
295
+
296
+ for ( const [ index , el ] of oldRecast . entries ( ) ) {
297
+ if ( el !== newRecast [ index ] ) {
298
+ return true ;
299
+ }
300
+ }
230
301
231
- return snapshot ;
302
+ return false ;
232
303
}
0 commit comments