refactor: stabilize calendar loading effects and menubar redirect

- Memoize calendar range strings and stabilize effect deps
- Guard updateDarkColor to avoid dispatch loops
- Add resilient cache-clear handling with refs
- Memoize calendar/temp ids to prevent rerenders
- Update event handler hooks with missing deps
- Add guards and dependency fixes across Calendar effects
This commit is contained in:
lenhanphung
2025-11-07 16:42:06 +07:00
committed by Benoit TELLIER
parent 990e290066
commit ffe204d60b
13 changed files with 650 additions and 163 deletions
+184 -107
View File
@@ -5,7 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import "./Calendar.styl";
import "./CustomCalendar.styl";
import { useEffect, useMemo, useRef, useState } from "react";
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal";
import { CalendarEvent } from "../../features/Events/EventsTypes";
@@ -44,7 +44,7 @@ import { updateDarkColor } from "./utils/calendarColorsUtils";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>;
calendarRef: MutableRefObject<CalendarApi | null>;
onDateChange?: (date: Date) => void;
onViewChange?: (view: string) => void;
}
@@ -65,13 +65,31 @@ export default function CalendarApp({
if (!tokens || !userId) {
dispatch(push("/"));
}
}, [tokens, userId]);
}, [dispatch, tokens, userId]);
const calendars = useAppSelector((state) => state.calendars.list);
const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {};
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
const calendarLightSignature = useMemo(() => {
return Object.values(calendars || {})
.map((cal) => `${cal.id}:${cal.color?.light ?? ""}`)
.sort()
.join("|");
}, [calendars]);
const calendarIdsString = useMemo(
() =>
Object.keys(calendars || {})
.sort()
.join(","),
[calendars]
);
const calendarIds = useMemo(
() => (calendarIdsString ? calendarIdsString.split(",") : []),
[calendarIdsString]
);
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => {
const calendar = calendars[calId];
if (!calendar?.events) return [];
@@ -100,65 +118,99 @@ export default function CalendarApp({
};
useEffect(() => {
if (initialLoadRef.current && Object.keys(calendars || {}).length > 0 && userId) {
if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem("selectedCalendars");
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[];
const valid = parsed.filter((id) => calendars[id]);
setSelectedCalendars(valid);
} else {
const personalCalendarIds = Object.keys(calendars).filter(
const personalCalendarIds = calendarIds.filter(
(id) => id.split("/")[0] === userId
);
setSelectedCalendars(personalCalendarIds);
}
initialLoadRef.current = false;
}
}, [calendars, userId]);
}, [calendarIds, calendars, userId]);
// Save selected cals to cache
useEffect(() => {
if (Object.keys(calendars || {}).length > 0) {
if (calendarIds.length > 0) {
localStorage.setItem(
"selectedCalendars",
JSON.stringify(selectedCalendars)
);
}
}, [selectedCalendars]);
}, [selectedCalendars, calendarIds.length]);
const prevCalendarLightSignature = useRef<string | null>(null);
useEffect(() => {
if (!calendarLightSignature) {
prevCalendarLightSignature.current = calendarLightSignature;
return;
}
if (prevCalendarLightSignature.current === calendarLightSignature) {
return;
}
prevCalendarLightSignature.current = calendarLightSignature;
updateDarkColor(calendars || {}, theme, dispatch);
}, [
theme,
Object.values(calendars || {})
.map((c) => c.color?.dark)
.join(","),
]);
}, [calendarLightSignature, calendars, theme, dispatch]);
useEffect(() => {
const validCalendarIds = new Set(Object.keys(calendars || {}));
setSelectedCalendars((prev) =>
prev.filter((calId) => validCalendarIds.has(calId))
);
}, [calendars]);
if (calendarIds.length === 0) return;
const validCalendarIds = new Set(calendarIds);
setSelectedCalendars((prev) => {
const filtered = prev.filter((calId) => validCalendarIds.has(calId));
if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index]);
if (unchanged) {
return prev;
}
}
return filtered;
});
}, [calendarIds]);
const calendarRange = useMemo(
() => getCalendarRange(selectedDate),
[selectedDate]
);
const calendarRangeStart = calendarRange.start.getTime();
const calendarRangeEnd = calendarRange.end.getTime();
const rangeStart = useMemo(
() => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeStart)),
[calendarRangeStart]
);
const rangeEnd = useMemo(
() => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeEnd)),
[calendarRangeEnd]
);
// Create a stable string key for the range
const rangeKey = `${formatDateToYYYYMMDDTHHMMSS(
calendarRange.start
)}_${formatDateToYYYYMMDDTHHMMSS(calendarRange.end)}`;
const rangeKey = useMemo(
() => `${rangeStart}_${rangeEnd}`,
[rangeStart, rangeEnd]
);
let filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars,
calendars || {}
);
const tempCalendarIds = useMemo(
() => Object.keys(tempcalendars || {}).sort(),
[tempcalendars]
);
let filteredTempEvents: CalendarEvent[] = extractEvents(
Object.keys(tempcalendars || {}),
tempCalendarIds,
tempcalendars || {}
);
@@ -169,55 +221,73 @@ export default function CalendarApp({
const prefetchedCalendarsRef = useRef<Record<string, string>>({});
const tempFetchedRangesRef = useRef<Record<string, string>>({});
const activeLoadCompletedRef = useRef<boolean>(false);
const [activeLoadCompleted, setActiveLoadCompleted] =
useState<boolean>(false);
useEffect(() => {
if (!rangeKey || sortedSelectedCalendars.length === 0) return;
activeLoadCompletedRef.current = false;
setActiveLoadCompleted(false);
}, [rangeKey]);
useEffect(() => {
if (!rangeKey || sortedSelectedCalendars.length === 0) {
activeLoadCompletedRef.current = true;
setActiveLoadCompleted(true);
return;
}
let cancelled = false;
const ACTIVE_BATCH_SIZE = 5;
const loadCalendars = async () => {
const pendingIds = sortedSelectedCalendars.filter(
(id) => fetchedRangesRef.current[id] !== rangeKey
);
try {
const pendingIds = sortedSelectedCalendars.filter(
(id) => fetchedRangesRef.current[id] !== rangeKey
);
if (pendingIds.length === 0) {
return;
}
if (pendingIds.length === 0) {
activeLoadCompletedRef.current = true;
setActiveLoadCompleted(true);
return;
}
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
for (
let i = 0;
i < pendingIds.length && !cancelled;
i += ACTIVE_BATCH_SIZE
) {
const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
for (
let i = 0;
i < pendingIds.length && !cancelled;
i += ACTIVE_BATCH_SIZE
) {
const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
chunk.forEach((id) => {
fetchedRangesRef.current[id] = rangeKey;
prefetchedCalendarsRef.current[id] = "active";
});
chunk.forEach((id) => {
fetchedRangesRef.current[id] = rangeKey;
prefetchedCalendarsRef.current[id] = "active";
});
const requests = chunk.map(async (id) => {
try {
await dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
})
).unwrap();
} catch (error) {
console.error(`Failed to load calendar ${id}:`, error);
fetchedRangesRef.current[id] = "";
}
});
const requests = chunk.map(async (id) => {
try {
await dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
})
).unwrap();
} catch (error) {
console.error(`Failed to load calendar ${id}:`, error);
fetchedRangesRef.current[id] = "";
}
});
await Promise.all(requests);
await Promise.all(requests);
}
} finally {
if (!cancelled) {
activeLoadCompletedRef.current = true;
setActiveLoadCompleted(true);
}
}
};
@@ -226,22 +296,19 @@ export default function CalendarApp({
return () => {
cancelled = true;
};
}, [
dispatch,
rangeKey,
sortedSelectedCalendars,
calendarRange,
]);
}, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]);
useEffect(() => {
if (!rangeKey) return;
if (!rangeKey || !activeLoadCompleted) return;
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
const hiddenCalendars = Object.keys(calendars || {})
const hiddenCalendars = calendarIds
.filter((id) => !selectedCalendars.includes(id))
.filter((id) => prefetchedCalendarsRef.current[id] !== rangeKey);
.filter((id) => {
const prefetched = prefetchedCalendarsRef.current[id];
return prefetched !== rangeKey && prefetched !== "active";
});
if (hiddenCalendars.length === 0) return;
hiddenCalendars.forEach((id) => {
prefetchedCalendarsRef.current[id] = rangeKey;
@@ -261,43 +328,56 @@ export default function CalendarApp({
});
});
}, [
calendars,
calendarIdsString,
selectedCalendars,
rangeKey,
calendarRange,
dispatch,
rangeStart,
rangeEnd,
activeLoadCompleted,
]);
const calendarsWithClearedCache = useMemo(() => {
return selectedCalendars
.map((id) => {
const cleared = calendars[id]?.lastCacheCleared;
return cleared ? { id, cleared } : null;
})
.filter(Boolean) as { id: string; cleared: number }[];
}, [selectedCalendars, calendars]);
const processedCacheClearRef = useRef<Record<string, number>>({});
useEffect(() => {
selectedCalendars.forEach((calId) => {
const calendar = calendars[calId];
if (calendar?.lastCacheCleared) {
delete fetchedRangesRef.current[calId];
prefetchedCalendarsRef.current[calId] = "";
dispatch(
getCalendarDetailAsync({
calId,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
})
);
fetchedRangesRef.current[calId] = rangeKey;
prefetchedCalendarsRef.current[calId] = rangeKey;
calendarsWithClearedCache.forEach(({ id, cleared }) => {
if (processedCacheClearRef.current[id] === cleared) {
return;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","),
]);
const tempCalendarIds = useMemo(
() => Object.keys(tempcalendars || {}).sort(),
[tempcalendars]
);
processedCacheClearRef.current[id] = cleared;
delete fetchedRangesRef.current[id];
prefetchedCalendarsRef.current[id] = "";
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
})
)
.unwrap()
.then(() => {
fetchedRangesRef.current[id] = rangeKey;
prefetchedCalendarsRef.current[id] = rangeKey;
})
.catch(() => {
fetchedRangesRef.current[id] = "";
prefetchedCalendarsRef.current[id] = "";
});
});
}, [calendarsWithClearedCache, dispatch, rangeKey, rangeStart, rangeEnd]);
const tempCalendarControllersRef = useRef<Map<string, AbortController>>(
new Map()
@@ -317,9 +397,6 @@ export default function CalendarApp({
let cancelled = false;
const TEMP_BATCH_SIZE = 5;
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
const loadTempCalendars = async () => {
const pendingIds = tempCalendarIds.filter(
(id) => tempFetchedRangesRef.current[id] !== rangeKey
@@ -366,7 +443,7 @@ export default function CalendarApp({
return () => {
cancelled = true;
};
}, [dispatch, rangeKey, tempCalendarIds, calendarRange]);
}, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -106,14 +106,14 @@ export default function CalendarSelection({
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
const personalCalendars = Object.keys(calendars).filter(
const personalCalendars = Object.keys(calendars || {}).filter(
(id) => id.split("/")[0] === userId
);
const delegatedCalendars = Object.keys(calendars).filter(
(id) => id.split("/")[0] !== userId && calendars[id].delegated
const delegatedCalendars = Object.keys(calendars || {}).filter(
(id) => id.split("/")[0] !== userId && calendars[id]?.delegated
);
const sharedCalendars = Object.keys(calendars).filter(
(id) => id.split("/")[0] !== userId && !calendars[id].delegated
const sharedCalendars = Object.keys(calendars || {}).filter(
(id) => id.split("/")[0] !== userId && !calendars?.[id]?.delegated
);
const handleCalendarToggle = (name: string) => {
@@ -178,7 +178,7 @@ export default function CalendarSelection({
</div>
<CalendarPopover
open={Boolean(anchorElCal)}
calendar={calendars[selectedCalId] ?? undefined}
calendar={calendars?.[selectedCalId] ?? undefined}
onClose={() => {
setSelectedCalId("");
setAnchorElCal(null);
+1 -1
View File
@@ -51,7 +51,7 @@ export function MiniCalendar({
onMonthChange={(month) => {
const calendarRange = getCalendarRange(month.toDate());
setVisibleDate(month.toDate());
Object.values(calendars.list).forEach((cal) =>
Object.values(calendars.list || {}).forEach((cal) =>
dispatch(
getCalendarDetailAsync({
calId: cal.id,
@@ -34,6 +34,7 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setEventDisplayedCalId,
props.setEventDisplayedTemp,
props.calendars,
props.dispatch,
]),
handleEventAllow: useCallback(eventHandlers.handleEventAllow, []),
handleEventDrop: useCallback(eventHandlers.handleEventDrop, [
@@ -42,10 +43,14 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setSelectedEvent,
props.setOpenEditModePopup,
props.setAfterChoiceFunc,
props.tempcalendars,
props.calendarRange,
]),
handleEventResize: useCallback(eventHandlers.handleEventResize, [
props.calendars,
props.dispatch,
props.tempcalendars,
props.calendarRange,
]),
};
};
@@ -19,6 +19,10 @@ export function updateDarkColor(
? isDefault.dark
: getAccessiblePair(baseColor, theme);
if (cal.color?.dark === darkColor) {
return;
}
dispatch(
updateCalColor({
id: cal.id,
+8 -2
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import logo from "../../static/header-logo.svg";
import AppsIcon from "@mui/icons-material/Apps";
import RefreshIcon from "@mui/icons-material/Refresh";
@@ -53,8 +53,14 @@ export function Menubar({
const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null);
const dispatch = useAppDispatch();
useEffect(() => {
if (!user) {
dispatch(push("/"));
}
}, [dispatch, user]);
if (!user) {
dispatch(push("/"));
return null;
}
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);