@@ -31,23 +31,27 @@ export {
3131 IDEAL_REFRESH_ONE_SECOND ,
3232} from "utils/TimeSync" ;
3333
34- type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
34+ type SelectCallback = ( newSnapshot : Date ) => unknown ;
3535
36- type ReactTimeSyncSubscriptionEntry = Readonly <
36+ type ReactSubscriptionEntry = Readonly <
3737 SubscriptionEntry & {
38- select ?: ( newSnapshot : Date ) => unknown ;
38+ select ?: SelectCallback ;
3939 }
4040> ;
4141
4242// Need to wrap each value that we put in the selection cache, so that when we
4343// try to retrieve a value, it's easy to differentiate between a value being
4444// undefined because that's an explicit selection value, versus it being
4545// 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+ } > ;
4750
4851interface ReactTimeSyncApi {
49- subscribe : ( entry : ReactTimeSyncSubscriptionEntry ) => ( ) => void ;
52+ subscribe : ( entry : ReactSubscriptionEntry ) => ( ) => void ;
5053 getSelectionSnapshot : < T = unknown > ( id : string ) => T ;
54+ invalidateSelection : ( id : string , select ?: SelectCallback ) => void ;
5155}
5256
5357class ReactTimeSync implements ReactTimeSyncApi {
@@ -62,9 +66,18 @@ class ReactTimeSync implements ReactTimeSyncApi {
6266 // All functions that are part of the public interface must be defined as
6367 // arrow functions, so that they work properly with React
6468
65- subscribe = ( entry : ReactTimeSyncSubscriptionEntry ) : ( ( ) => void ) => {
69+ subscribe = ( entry : ReactSubscriptionEntry ) : ( ( ) => void ) => {
6670 const { select, id, idealRefreshIntervalMs, onUpdate } = entry ;
6771
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+
6881 // Make sure that we subscribe first, in case TimeSync is configured to
6982 // invalidate the snapshot on a new subscription. Want to remove risk of
7083 // stale data
@@ -73,21 +86,18 @@ class ReactTimeSync implements ReactTimeSyncApi {
7386 idealRefreshIntervalMs,
7487 onUpdate : ( newDate ) => {
7588 const prevSelection = this . getSelectionSnapshot ( id ) ;
76- const newSelection : unknown = select ?.( newDate ) ?? newDate ;
89+ const newSelection = select ?.( newDate ) ?? newDate ;
7790 if ( newSelection === prevSelection ) {
7891 return ;
7992 }
8093
81- this . #selectionCache . set ( id , { value : newSelection } ) ;
94+ updateCacheEntry ( ) ;
8295 onUpdate ( newDate ) ;
8396 } ,
8497 } ;
8598 this . #timeSync. subscribe ( patchedEntry ) ;
8699
87- const date = this . #timeSync. getTimeSnapshot ( ) ;
88- const cacheValue = select ?.( date ) ?? date ;
89- this . #selectionCache. set ( id , { value : cacheValue } ) ;
90-
100+ updateCacheEntry ( ) ;
91101 return ( ) => this . #timeSync. unsubscribe ( id ) ;
92102 } ;
93103
@@ -108,6 +118,23 @@ class ReactTimeSync implements ReactTimeSyncApi {
108118
109119 return cacheEntry . value as T ;
110120 } ;
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+ } ;
111138}
112139
113140const timeSyncContext = createContext < ReactTimeSync | null > ( null ) ;
@@ -147,6 +174,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
147174
148175type UseTimeSyncOptions < T = Date > = Readonly < {
149176 idealRefreshIntervalMs : number ;
177+ selectDeps ?: readonly unknown [ ] ;
150178
151179 /**
152180 * Allows you to transform any date values received from the TimeSync class.
@@ -162,7 +190,7 @@ type UseTimeSyncOptions<T = Date> = Readonly<{
162190
163191/**
164192 * 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
166194 * hook should be used anytime you would want to use a Date instance directly in
167195 * a component render path.
168196 *
@@ -176,7 +204,7 @@ type UseTimeSyncOptions<T = Date> = Readonly<{
176204 * interval.
177205 */
178206export function useTimeSync < T = Date > ( options : UseTimeSyncOptions < T > ) : T {
179- const { select, idealRefreshIntervalMs } = options ;
207+ const { select, selectDeps , idealRefreshIntervalMs } = options ;
180208 const timeSync = useContext ( timeSyncContext ) ;
181209 if ( timeSync === null ) {
182210 throw new Error ( "Cannot call useTimeSync outside of a TimeSyncProvider" ) ;
@@ -200,7 +228,7 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
200228 // be initialized with whatever callback you give it on mount. So for the
201229 // mounting render alone, it's safe to call a useEffectEvent callback from
202230 // inside a render.
203- const stableSelect = useEffectEvent ( ( date : Date ) : T => {
231+ const selectForOutsideReact = useEffectEvent ( ( date : Date ) : T => {
204232 const recast = date as Date & T ;
205233 return select ?.( recast ) ?? recast ;
206234 } ) ;
@@ -212,21 +240,64 @@ export function useTimeSync<T = Date>(options: UseTimeSyncOptions<T>): T {
212240 // the new callback). All other values need to be included in the dependency
213241 // array for correctness, but they should always maintain stable memory
214242 // addresses
243+ type ReactSubscriptionCallback = ( notifyReact : ( ) => void ) => ( ) => void ;
215244 const subscribe = useCallback < ReactSubscriptionCallback > (
216245 ( notifyReact ) => {
217246 return timeSync . subscribe ( {
218247 idealRefreshIntervalMs,
219248 id : hookId ,
220249 onUpdate : notifyReact ,
221- select : stableSelect ,
250+ select : selectForOutsideReact ,
222251 } ) ;
223252 } ,
224- [ timeSync , hookId , stableSelect , idealRefreshIntervalMs ] ,
253+ [ timeSync , hookId , selectForOutsideReact , idealRefreshIntervalMs ] ,
225254 ) ;
226255
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+ }
230301
231- return snapshot ;
302+ return false ;
232303}
0 commit comments