@@ -152,6 +152,14 @@ type TransformationEntry = {
152
152
cachedTransformation : unknown ;
153
153
} ;
154
154
155
+ /**
156
+ * The main conceit for this file is that all of the core state is stored in a
157
+ * global-ish instance of ReactTimeSync, and then useTimeSync and
158
+ * useTimeSyncState control the class via React hooks and lifecycle behavior.
159
+ *
160
+ * Because you can't share generics at a module level, the class uses a bunch
161
+ * of `unknown` types to handle storing arbitrary data.
162
+ */
155
163
class ReactTimeSync {
156
164
static readonly #staleStateThresholdMs = 100 ;
157
165
@@ -178,6 +186,22 @@ class ReactTimeSync {
178
186
} ) ;
179
187
}
180
188
189
+ #shouldInvalidateDate( ) : boolean {
190
+ if ( ! this . #isProviderMounted) {
191
+ return false ;
192
+ }
193
+
194
+ const snap = this . #timeSync. getStateSnapshot ( ) ;
195
+ return (
196
+ newReadonlyDate ( ) . getTime ( ) - snap . getTime ( ) >
197
+ ReactTimeSync . #staleStateThresholdMs
198
+ ) ;
199
+ }
200
+
201
+ getTimeSync ( ) : TimeSync {
202
+ return this . #timeSync;
203
+ }
204
+
181
205
subscribeToTransformations ( th : TransformationHandshake ) : ( ) => void {
182
206
if ( ! this . #isProviderMounted) {
183
207
return noOp ;
@@ -192,7 +216,7 @@ class ReactTimeSync {
192
216
this . #entries. delete ( componentId ) ;
193
217
}
194
218
195
- const unsubscribe = this . #timeSync. subscribe ( {
219
+ const unsubscribeFromRootSync = this . #timeSync. subscribe ( {
196
220
targetRefreshIntervalMs,
197
221
onUpdate : ( newDate ) => {
198
222
const entry = this . #entries. get ( componentId ) ;
@@ -211,9 +235,21 @@ class ReactTimeSync {
211
235
} ,
212
236
} ) ;
213
237
214
- const latestSyncState = this . #timeSync. getStateSnapshot ( ) ;
238
+ // While each component should already invalidate the Date on mount,
239
+ // we still need to take care of the case where a subscription got torn
240
+ // down and re-added because a target interval invalidated the
241
+ // dependency array for useTimeSyncState's subscribe function
242
+ let latestSyncState : Date ;
243
+ if ( this . #shouldInvalidateDate( ) ) {
244
+ latestSyncState = this . #timeSync. invalidateStateSnapshot ( {
245
+ notifyAfterUpdate : true ,
246
+ } ) ;
247
+ } else {
248
+ latestSyncState = this . #timeSync. getStateSnapshot ( ) ;
249
+ }
250
+
215
251
const newEntry : TransformationEntry = {
216
- unsubscribe,
252
+ unsubscribe : unsubscribeFromRootSync ,
217
253
/**
218
254
* @todo 2025-08-29 - There is one unfortunate behavior with the
219
255
* current subscription logic. Because of how React lifecycles work,
@@ -231,7 +267,7 @@ class ReactTimeSync {
231
267
this . #entries. set ( componentId , newEntry ) ;
232
268
233
269
return ( ) => {
234
- newEntry . unsubscribe ( ) ;
270
+ unsubscribeFromRootSync ( ) ;
235
271
this . #entries. delete ( componentId ) ;
236
272
} ;
237
273
}
@@ -267,39 +303,33 @@ class ReactTimeSync {
267
303
return latestDate as T ;
268
304
}
269
305
270
- getLatestDate ( ) : Date {
271
- let snap = this . #timeSync. getStateSnapshot ( ) ;
272
-
273
- const shouldInvalidate =
274
- this . #isProviderMounted &&
275
- newReadonlyDate ( ) . getTime ( ) - snap . getTime ( ) >
276
- ReactTimeSync . #staleStateThresholdMs;
277
- if ( shouldInvalidate ) {
278
- snap = this . #timeSync. invalidateStateSnapshot ( {
279
- notifyAfterUpdate : false ,
280
- } ) ;
281
- this . #hasPendingUpdates = true ;
306
+ getNewSourceDateForMemo ( ) : Date {
307
+ if ( ! this . #shouldInvalidateDate( ) ) {
308
+ return this . #timeSync. getStateSnapshot ( ) ;
282
309
}
283
310
284
- return snap ;
285
- }
286
-
287
- getTimeSync ( ) : TimeSync {
288
- return this . #timeSync;
311
+ // Have to disable notifications, because while this method is used to
312
+ // bend the React rules, the one thing we absolutely can't do is notify
313
+ // subscribers mid-render. The notify function for useSyncExternalStore
314
+ // uses the same core system as useState's dispatch, and React does not
315
+ // let you call a mid-render dispatch for any component that is not
316
+ // actively being rendered in the fiber tree
317
+ return this . #timeSync. invalidateStateSnapshot ( { notifyAfterUpdate : false } ) ;
289
318
}
290
319
291
320
onComponentMount ( componentId : string ) : void {
321
+ if ( ! this . #isProviderMounted || ! this . #hasPendingUpdates) {
322
+ return ;
323
+ }
324
+
292
325
const entry = this . #entries. get ( componentId ) ;
293
326
if ( entry === undefined ) {
294
327
throw new Error (
295
328
`Trying to call component mount logic before component ID has been added to tracking (received value ${ componentId } )` ,
296
329
) ;
297
330
}
298
-
299
- if ( ! this . #isProviderMounted || ! this . #hasPendingUpdates) {
300
- return ;
301
- }
302
331
void this . #timeSync. invalidateStateSnapshot ( { notifyAfterUpdate : true } ) ;
332
+ this . #hasPendingUpdates = false ;
303
333
}
304
334
305
335
onProviderUnmount ( ) : void {
@@ -356,15 +386,18 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
356
386
*
357
387
* This hook shouldn't be necessary the majority of the time. If you need to
358
388
* bind state updates to TimeSync, consider using `useTimeSyncState` instead.
359
- *
360
- * This hook is a core part of the design for TimeSync, but because no
361
- * components need it yet, it's defined with an _ to make Knip happy.
362
389
*/
363
- function _useTimeSync ( ) : TimeSync {
390
+ function useTimeSync ( ) : TimeSync {
364
391
const reactTs = useReactTimeSync ( ) ;
365
392
return reactTs . getTimeSync ( ) ;
366
393
}
367
394
395
+ /**
396
+ * This hook is a core part of the design for TimeSync, but because no
397
+ * components need it yet, we're doing this to suppress Knip warnings.
398
+ */
399
+ const _unused = useTimeSync ;
400
+
368
401
// Even though this is a really simple function, keeping it defined outside the
369
402
// hook helps a lot with making sure useSyncExternalStore doesn't re-sync too
370
403
// often
@@ -418,13 +451,23 @@ export function useTimeSyncState<T = Date>(options: UseTimeSyncOptions<T>): T {
418
451
const { targetIntervalMs, transform } = options ;
419
452
const activeTransform = ( transform ?? identity ) as TransformCallback < T > ;
420
453
454
+ // This is an abuse of the useId API, but because it gives us an ID that is
455
+ // uniquely associated with the current component instance, we can use it to
456
+ // differentiate between multiple instances of the same function component
457
+ // subscribing to useTimeSyncState
421
458
const hookId = useId ( ) ;
422
459
const reactTs = useReactTimeSync ( ) ;
423
460
424
461
// Because of how React lifecycles work, the effect event callback should
425
462
// never be called from inside render logic. It will *always* give you
426
463
// stale data after the very first render.
427
464
const externalTransform = useEffectEvent ( activeTransform ) ;
465
+
466
+ // Memozing both callbacks for useSyncExternalStore to avoid too much
467
+ // thrashing on re-renders. useSyncExternalStore will re-call either
468
+ // function if their reference changes. getSnap should be 100% stable for
469
+ // the component's lifecycle, while subscribe should only get invalidated
470
+ // when the target interval changes
428
471
const subscribe = useCallback < ReactSubscriptionCallback > (
429
472
( notifyReact ) => {
430
473
return reactTs . subscribeToTransformations ( {
@@ -472,7 +515,7 @@ export function useTimeSyncState<T = Date>(options: UseTimeSyncOptions<T>): T {
472
515
// changed, we don't have drastically outdated date state. We could also
473
516
// subscribe to the date itself, but that makes it impossible to prevent
474
517
// unnecessary re-renders on subscription updates
475
- const latestDate = reactTs . getLatestDate ( ) ;
518
+ const latestDate = reactTs . getNewSourceDateForMemo ( ) ;
476
519
return activeTransform ( latestDate ) ;
477
520
} , [ reactTs , activeTransform ] ) ;
478
521
0 commit comments