1
1
/**
2
2
* @todo Things that still need to be done before this can be called done:
3
- * 1. Explore an idea for handling selectors without forcing someone to specify
4
- * data dependencies for what values are accessed via closure:
5
- * - A selector is ALWAYS run synchronously in a render. There is no way to
6
- * opt out of this behavior, not even memoization
7
- * - The selector is still used to determine whether a component should
8
- * re-render via a time update. It will be assumed that whatever function
9
- * is currently available will always be the most up-to-date
10
- * - This approach might make it more viable to combine useTimeSync and
11
- * useTimeSyncSelector again
12
- * 2. Add tests and address any bugs
3
+ * 1. Add tests and address any bugs
13
4
*/
14
5
import {
15
6
type FC ,
@@ -77,9 +68,6 @@ class ReactTimeSync implements ReactTimeSyncApi {
77
68
subscribe = ( entry : ReactSubscriptionEntry ) : ( ( ) => void ) => {
78
69
const { select, id, targetRefreshInterval, onUpdate } = entry ;
79
70
80
- // Make sure that we subscribe first, in case TimeSync is configured to
81
- // invalidate the snapshot on a new subscription. Want to remove risk of
82
- // stale data
83
71
const patchedEntry : SubscriptionEntry = {
84
72
id,
85
73
targetRefreshInterval,
@@ -94,15 +82,8 @@ class ReactTimeSync implements ReactTimeSyncApi {
94
82
onUpdate ( newDate ) ;
95
83
} ,
96
84
} ;
97
- this . #timeSync. subscribe ( patchedEntry ) ;
98
-
99
- // Have to seed the selection cache with an initial value so that it's
100
- // safe for React to get the value immediately after the subscription
101
- // gets registered
102
- const date = this . #timeSync. getTimeSnapshot ( ) ;
103
- const cacheValue = select ?.( date ) ?? date ;
104
- this . #selectionCache. set ( id , { value : cacheValue } ) ;
105
85
86
+ this . #timeSync. subscribe ( patchedEntry ) ;
106
87
return ( ) => this . #timeSync. unsubscribe ( id ) ;
107
88
} ;
108
89
@@ -125,18 +106,20 @@ class ReactTimeSync implements ReactTimeSyncApi {
125
106
} ;
126
107
127
108
invalidateSelection = ( id : string , select ?: SelectCallback ) : void => {
128
- const prevSelection = this . #selectionCache. get ( id ) ;
129
- if ( prevSelection === undefined ) {
130
- return ;
131
- }
132
-
133
109
// It is very, VERY important that we only change the value in the
134
110
// selection cache when it changes by value. If the state getter
135
111
// function for useSyncExternalStore always receives a new value by
136
112
// reference on every call, that will create an infinite render loop in
137
113
// dev mode
138
114
const dateSnapshot = this . #timeSync. getTimeSnapshot ( ) ;
139
115
const newSelection = select ?.( dateSnapshot ) ?? dateSnapshot ;
116
+
117
+ const prevSelection = this . #selectionCache. get ( id ) ;
118
+ if ( prevSelection === undefined ) {
119
+ this . #selectionCache. set ( id , { value : newSelection } ) ;
120
+ return ;
121
+ }
122
+
140
123
if ( ! areValuesDeepEqual ( newSelection , prevSelection . value ) ) {
141
124
this . #selectionCache. set ( id , { value : newSelection } ) ;
142
125
}
@@ -310,42 +293,32 @@ export function useTimeSyncSelect<T>(options: UseTimeSyncSelectOptions<T>): T {
310
293
const hookId = useId ( ) ;
311
294
const timeSync = useTimeSyncContext ( ) ;
312
295
313
- // This is the one place where we're borderline breaking the React rules.
314
- // useEffectEvent is meant to be used only in useEffect calls, and normally
315
- // shouldn't be called inside a render. But its behavior lines up with
316
- // useSyncExternalStore, letting us cheat a little. useSyncExternalStore's
317
- // state getter callback is called in two scenarios:
318
- // (1) Mid-render on mount
319
- // (2) Whenever React is notified of a state change (outside of React).
320
- //
321
- // Case 2 is basically an effect with extra steps (and single-threaded JS
322
- // gives us assurance about correctness). And for (1), useEffectEvent will
323
- // be initialized with whatever callback you give it on mount. So for the
324
- // mounting render alone, it's safe to call a useEffectEvent callback from
325
- // inside a render.
326
- const externalSelect = useEffectEvent ( ( date : Date ) : T => {
296
+ // externalSelect is passed to the ReactTimeSync class for use when the
297
+ // class dispatches a new Date update. It should not be called inside the
298
+ // render logic whatsoever.
299
+ const externalStableSelect = useEffectEvent ( ( date : Date ) : T => {
327
300
const recast = date as Date & T ;
328
301
return select ?.( recast ) ?? recast ;
329
302
} ) ;
330
303
331
- // We're leaning into the React hook API behavior to simplify needing to
332
- // resync the refresh intervals when they change during re-renders. Whenever
304
+ // We're leaning into the React hook API behavior to simplify syncing the
305
+ // refresh intervals when they change during re-renders. Whenever
333
306
// useSyncExternalStore receives a new callback by reference, it will
334
307
// automatically unsubscribe with the previous callback and re-subscribe
335
308
// with the new one. useCallback stabilizes the reference, with the ability
336
- // to invalidate it whenever the interval changes. Have to include all other
337
- // elements in the dependency array for correctness, but they should remain
338
- // 100% stable for the entire lifetime of the component
309
+ // to invalidate it whenever the interval changes. We have to include all
310
+ // other data dependencies in the dependency array for correctness, but they
311
+ // should remain 100% stable by reference for the lifetime of the component
339
312
const subscribe = useCallback < ReactSubscriptionCallback > (
340
313
( notifyReact ) => {
341
314
return timeSync . subscribe ( {
342
315
targetRefreshInterval,
343
316
id : hookId ,
344
317
onUpdate : notifyReact ,
345
- select : externalSelect ,
318
+ select : externalStableSelect ,
346
319
} ) ;
347
320
} ,
348
- [ timeSync , hookId , externalSelect , targetRefreshInterval ] ,
321
+ [ timeSync , hookId , externalStableSelect , targetRefreshInterval ] ,
349
322
) ;
350
323
351
324
const selection = useSyncExternalStore < T > ( subscribe , ( ) => {
0 commit comments