Skip to content

Commit ff867b2

Browse files
committed
fix: resolve re-subscribe issue
1 parent fc01bfd commit ff867b2

File tree

1 file changed

+73
-30
lines changed

1 file changed

+73
-30
lines changed

site/src/hooks/useTimeSync.tsx

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ type TransformationEntry = {
152152
cachedTransformation: unknown;
153153
};
154154

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+
*/
155163
class ReactTimeSync {
156164
static readonly #staleStateThresholdMs = 100;
157165

@@ -178,6 +186,22 @@ class ReactTimeSync {
178186
});
179187
}
180188

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+
181205
subscribeToTransformations(th: TransformationHandshake): () => void {
182206
if (!this.#isProviderMounted) {
183207
return noOp;
@@ -192,7 +216,7 @@ class ReactTimeSync {
192216
this.#entries.delete(componentId);
193217
}
194218

195-
const unsubscribe = this.#timeSync.subscribe({
219+
const unsubscribeFromRootSync = this.#timeSync.subscribe({
196220
targetRefreshIntervalMs,
197221
onUpdate: (newDate) => {
198222
const entry = this.#entries.get(componentId);
@@ -211,9 +235,21 @@ class ReactTimeSync {
211235
},
212236
});
213237

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+
215251
const newEntry: TransformationEntry = {
216-
unsubscribe,
252+
unsubscribe: unsubscribeFromRootSync,
217253
/**
218254
* @todo 2025-08-29 - There is one unfortunate behavior with the
219255
* current subscription logic. Because of how React lifecycles work,
@@ -231,7 +267,7 @@ class ReactTimeSync {
231267
this.#entries.set(componentId, newEntry);
232268

233269
return () => {
234-
newEntry.unsubscribe();
270+
unsubscribeFromRootSync();
235271
this.#entries.delete(componentId);
236272
};
237273
}
@@ -267,39 +303,33 @@ class ReactTimeSync {
267303
return latestDate as T;
268304
}
269305

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();
282309
}
283310

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 });
289318
}
290319

291320
onComponentMount(componentId: string): void {
321+
if (!this.#isProviderMounted || !this.#hasPendingUpdates) {
322+
return;
323+
}
324+
292325
const entry = this.#entries.get(componentId);
293326
if (entry === undefined) {
294327
throw new Error(
295328
`Trying to call component mount logic before component ID has been added to tracking (received value ${componentId})`,
296329
);
297330
}
298-
299-
if (!this.#isProviderMounted || !this.#hasPendingUpdates) {
300-
return;
301-
}
302331
void this.#timeSync.invalidateStateSnapshot({ notifyAfterUpdate: true });
332+
this.#hasPendingUpdates = false;
303333
}
304334

305335
onProviderUnmount(): void {
@@ -356,15 +386,18 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
356386
*
357387
* This hook shouldn't be necessary the majority of the time. If you need to
358388
* 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.
362389
*/
363-
function _useTimeSync(): TimeSync {
390+
function useTimeSync(): TimeSync {
364391
const reactTs = useReactTimeSync();
365392
return reactTs.getTimeSync();
366393
}
367394

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+
368401
// Even though this is a really simple function, keeping it defined outside the
369402
// hook helps a lot with making sure useSyncExternalStore doesn't re-sync too
370403
// often
@@ -418,13 +451,23 @@ export function useTimeSyncState<T = Date>(options: UseTimeSyncOptions<T>): T {
418451
const { targetIntervalMs, transform } = options;
419452
const activeTransform = (transform ?? identity) as TransformCallback<T>;
420453

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
421458
const hookId = useId();
422459
const reactTs = useReactTimeSync();
423460

424461
// Because of how React lifecycles work, the effect event callback should
425462
// never be called from inside render logic. It will *always* give you
426463
// stale data after the very first render.
427464
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
428471
const subscribe = useCallback<ReactSubscriptionCallback>(
429472
(notifyReact) => {
430473
return reactTs.subscribeToTransformations({
@@ -472,7 +515,7 @@ export function useTimeSyncState<T = Date>(options: UseTimeSyncOptions<T>): T {
472515
// changed, we don't have drastically outdated date state. We could also
473516
// subscribe to the date itself, but that makes it impossible to prevent
474517
// unnecessary re-renders on subscription updates
475-
const latestDate = reactTs.getLatestDate();
518+
const latestDate = reactTs.getNewSourceDateForMemo();
476519
return activeTransform(latestDate);
477520
}, [reactTs, activeTransform]);
478521

0 commit comments

Comments
 (0)