Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d7c70e2

Browse files
committed
fix: resolve bug when syncing new interval
1 parent 10fbdfc commit d7c70e2

File tree

2 files changed

+31
-57
lines changed

2 files changed

+31
-57
lines changed

site/src/hooks/useTimeSync.tsx

Lines changed: 20 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
/**
22
* @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
134
*/
145
import {
156
type FC,
@@ -77,9 +68,6 @@ class ReactTimeSync implements ReactTimeSyncApi {
7768
subscribe = (entry: ReactSubscriptionEntry): (() => void) => {
7869
const { select, id, targetRefreshInterval, onUpdate } = entry;
7970

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
8371
const patchedEntry: SubscriptionEntry = {
8472
id,
8573
targetRefreshInterval,
@@ -94,15 +82,8 @@ class ReactTimeSync implements ReactTimeSyncApi {
9482
onUpdate(newDate);
9583
},
9684
};
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 });
10585

86+
this.#timeSync.subscribe(patchedEntry);
10687
return () => this.#timeSync.unsubscribe(id);
10788
};
10889

@@ -125,18 +106,20 @@ class ReactTimeSync implements ReactTimeSyncApi {
125106
};
126107

127108
invalidateSelection = (id: string, select?: SelectCallback): void => {
128-
const prevSelection = this.#selectionCache.get(id);
129-
if (prevSelection === undefined) {
130-
return;
131-
}
132-
133109
// It is very, VERY important that we only change the value in the
134110
// selection cache when it changes by value. If the state getter
135111
// function for useSyncExternalStore always receives a new value by
136112
// reference on every call, that will create an infinite render loop in
137113
// dev mode
138114
const dateSnapshot = this.#timeSync.getTimeSnapshot();
139115
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+
140123
if (!areValuesDeepEqual(newSelection, prevSelection.value)) {
141124
this.#selectionCache.set(id, { value: newSelection });
142125
}
@@ -310,42 +293,32 @@ export function useTimeSyncSelect<T>(options: UseTimeSyncSelectOptions<T>): T {
310293
const hookId = useId();
311294
const timeSync = useTimeSyncContext();
312295

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 => {
327300
const recast = date as Date & T;
328301
return select?.(recast) ?? recast;
329302
});
330303

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
333306
// useSyncExternalStore receives a new callback by reference, it will
334307
// automatically unsubscribe with the previous callback and re-subscribe
335308
// 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
339312
const subscribe = useCallback<ReactSubscriptionCallback>(
340313
(notifyReact) => {
341314
return timeSync.subscribe({
342315
targetRefreshInterval,
343316
id: hookId,
344317
onUpdate: notifyReact,
345-
select: externalSelect,
318+
select: externalStableSelect,
346319
});
347320
},
348-
[timeSync, hookId, externalSelect, targetRefreshInterval],
321+
[timeSync, hookId, externalStableSelect, targetRefreshInterval],
349322
);
350323

351324
const selection = useSyncExternalStore<T>(subscribe, () => {

site/src/utils/TimeSync.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ export class TimeSync implements TimeSyncApi {
9191
readonly #clearInterval: ClearInterval;
9292

9393
#latestDateSnapshot: Date;
94-
#subscriptions: SubscriptionEntry[];
95-
#latestIntervalId: number | undefined;
96-
#currentRefreshInterval: number;
94+
#subscriptions: SubscriptionEntry[] = [];
95+
#latestIntervalId: number | undefined = undefined;
96+
#currentRefreshInterval: number = Number.POSITIVE_INFINITY;
9797

9898
constructor(options: Partial<TimeSyncInitOptions>) {
9999
const {
@@ -108,11 +108,7 @@ export class TimeSync implements TimeSyncApi {
108108
this.#clearInterval = clearInterval;
109109
this.#createNewDatetime = createNewDatetime;
110110
this.#resyncOnNewSubscription = resyncOnNewSubscription;
111-
112111
this.#latestDateSnapshot = initialDatetime;
113-
this.#subscriptions = [];
114-
this.#latestIntervalId = undefined;
115-
this.#currentRefreshInterval = Number.POSITIVE_INFINITY;
116112
}
117113

118114
#onSubscriptionAdd(): void {
@@ -185,9 +181,14 @@ export class TimeSync implements TimeSyncApi {
185181
}
186182

187183
#updateSnapshot(): void {
188-
this.#latestDateSnapshot = this.#createNewDatetime(
189-
this.#latestDateSnapshot,
190-
);
184+
// It's assumed that TimeSync will be used with subscribers that will
185+
// treat whatever Date they receive as an immutable value. But because
186+
// the entire class breaks if someone does mutate it, we need to freeze
187+
// the value
188+
const newRawDate = this.#createNewDatetime(this.#latestDateSnapshot);
189+
Object.freeze(newRawDate);
190+
this.#latestDateSnapshot = newRawDate;
191+
191192
for (const subEntry of this.#subscriptions) {
192193
subEntry.onUpdate(this.#latestDateSnapshot);
193194
}

0 commit comments

Comments
 (0)