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

Skip to content

Commit fe8b3ef

Browse files
committed
fix: get initial solution in place for fixing stale closure issues
1 parent 62b6f6e commit fe8b3ef

File tree

3 files changed

+102
-28
lines changed

3 files changed

+102
-28
lines changed

site/src/hooks/useTimeSync.tsx

+92-21
Original file line numberDiff line numberDiff line change
@@ -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

4851
interface 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

5357
class 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

113140
const timeSyncContext = createContext<ReactTimeSync | null>(null);
@@ -147,6 +174,7 @@ export const TimeSyncProvider: FC<TimeSyncProviderProps> = ({
147174

148175
type 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
*/
178206
export 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
}

site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
startOfHour,
1818
subDays,
1919
} from "date-fns";
20-
import { useTimeSync } from "hooks/useTimeSync";
20+
import { IDEAL_REFRESH_ONE_MINUTE, useTimeSync } from "hooks/useTimeSync";
2121
import { type ComponentProps, type FC, useRef, useState } from "react";
2222
import { DateRangePicker, createStaticRanges } from "react-date-range";
2323

@@ -43,7 +43,9 @@ interface DateRangeProps {
4343
}
4444

4545
export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
46-
const currentTime = useTimeSync({ idealRefreshIntervalMs: 60_000 });
46+
const currentTime = useTimeSync({
47+
idealRefreshIntervalMs: IDEAL_REFRESH_ONE_MINUTE,
48+
});
4749
const selectionStatusRef = useRef<"idle" | "selecting">("idle");
4850
const [ranges, setRanges] = useState<RangesState>([
4951
{

site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx

+6-5
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,17 @@ export default function TemplateInsightsPage() {
7070
const { template } = useTemplateLayoutContext();
7171
const [searchParams, setSearchParams] = useSearchParams();
7272

73+
const paramsInterval = searchParams.get("interval");
7374
const insightsInterval = useTimeSync<InsightsInterval>({
7475
idealRefreshIntervalMs: IDEAL_REFRESH_ONE_DAY,
75-
select: (newDatetime) => {
76+
selectDeps: [paramsInterval],
77+
select: (newDate) => {
7678
const templateCreateDate = new Date(template.created_at);
77-
const hasFiveWeeksOrMore = addWeeks(templateCreateDate, 5) < newDatetime;
79+
const hasFiveWeeksOrMore = addWeeks(templateCreateDate, 5) < newDate;
7880
const defaultInterval = hasFiveWeeksOrMore ? "week" : "day";
7981

80-
const paramsValue = searchParams.get("interval");
81-
if (paramsValue === "week" || paramsValue === "day") {
82-
return paramsValue;
82+
if (paramsInterval === "week" || paramsInterval === "day") {
83+
return paramsInterval;
8384
}
8485
return defaultInterval;
8586
},

0 commit comments

Comments
 (0)