Fix React hook violations and optimize component behavior

Update form state management in AddEventModal and improve GoogleTab OAuth handling

Refactor EventListScreen to remove unused state calculation

Add missing dependencies in JourneyList useMemo hooks

Simplify events store initialization in EventsProvider
This commit is contained in:
2026-05-18 21:56:11 +02:00
parent b240d638ef
commit 9c1f6ebf7e
7 changed files with 48 additions and 47 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ const byPrefixAndName = { fas: { bars: faBars } };
type HeaderMenuProps = { type HeaderMenuProps = {
navigation: { navigation: {
navigate: (screen: 'CalendarImport' | 'Settings') => void; navigate: (_screen: 'CalendarImport' | 'Settings') => void;
}; };
}; };
@@ -61,7 +61,7 @@ function CalendarCheckbox({
}: { }: {
calendar: SelectableCalendar; calendar: SelectableCalendar;
selected: boolean; selected: boolean;
onToggle: (id: string) => void; onToggle: (_id: string) => void;
colors: ReturnType<typeof useColors>; colors: ReturnType<typeof useColors>;
}) { }) {
const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other; const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other;
@@ -178,13 +178,6 @@ export function EventListScreen({ navigation }: ScreenProps) {
const leaveCountdownLabel = leaveCountdown?.label; const leaveCountdownLabel = leaveCountdown?.label;
const leaveByLabel = leaveBy ? formatTime(leaveBy) : null; const leaveByLabel = leaveBy ? formatTime(leaveBy) : null;
const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Searching for train connection'; const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Searching for train connection';
const status = leaveCountdown
? leaveCountdown.label === 'Now'
? 'Leave now'
: `Leave in ${leaveCountdown.label}`
: journeysLoading || destStation.loading
? 'Calculating departure time'
: 'No connection';
return ( return (
<View style={styles.cardWrapper}> <View style={styles.cardWrapper}>
+7 -3
View File
@@ -33,8 +33,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEven
const { addEvent, updateEvent } = useEventsStore(); const { addEvent, updateEvent } = useEventsStore();
// Populate fields when opening in edit mode // Track previous props to reset form fields when they change (avoids setState in useEffect)
useEffect(() => { const [prevEditEvent, setPrevEditEvent] = useState<Event | undefined>(editEvent);
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (editEvent !== prevEditEvent || isOpen !== prevIsOpen) {
setPrevEditEvent(editEvent);
setPrevIsOpen(isOpen);
if (editEvent) { if (editEvent) {
setTitle(editEvent.title); setTitle(editEvent.title);
setDestination(editEvent.destination); setDestination(editEvent.destination);
@@ -47,7 +51,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEven
setEventTime(""); setEventTime("");
} }
setSuccess(false); setSuccess(false);
}, [editEvent, isOpen]); }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
+34 -29
View File
@@ -24,9 +24,28 @@ type GoogleTabProps = {
}; };
const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" }) => { const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" }) => {
const [status, setStatus] = useState<GoogleStatus>("loading"); // Read and clean OAuth return params synchronously at mount (avoids setState in effect)
const [oauthParams] = useState<{ error: string | null; connected: string | null }>(() => {
if (typeof window === "undefined") return { error: null, connected: null };
const params = new URLSearchParams(window.location.search);
const error = params.get("google_error");
const connected = params.get("google_connected");
if (error || connected) {
const clean = new URL(window.location.href);
clean.searchParams.delete("google_connected");
clean.searchParams.delete("google_error");
window.history.replaceState(null, "", clean.toString());
}
return { error, connected };
});
const [status, setStatus] = useState<GoogleStatus>(
oauthParams.error ? "not-connected" : "loading"
);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(
oauthParams.error ? friendlyOAuthError(oauthParams.error) : null
);
const [syncedCount, setSyncedCount] = useState<number | null>(null); const [syncedCount, setSyncedCount] = useState<number | null>(null);
const checkStatus = useCallback(async () => { const checkStatus = useCallback(async () => {
@@ -45,33 +64,6 @@ const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" })
} }
}, []); }, []);
useEffect(() => {
// Handle OAuth return params
const params = new URLSearchParams(window.location.search);
const connected = params.get("google_connected");
const oauthError = params.get("google_error");
if (connected || oauthError) {
const clean = new URL(window.location.href);
clean.searchParams.delete("google_connected");
clean.searchParams.delete("google_error");
window.history.replaceState(null, "", clean.toString());
}
if (oauthError) {
setError(friendlyOAuthError(oauthError));
setStatus("not-connected");
return;
}
checkStatus().then(() => {
if (connected) {
syncEvents();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const syncEvents = useCallback(async () => { const syncEvents = useCallback(async () => {
setSyncing(true); setSyncing(true);
setError(null); setError(null);
@@ -98,6 +90,19 @@ const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" })
} }
}, [onEventsLoaded]); }, [onEventsLoaded]);
useEffect(() => {
if (!oauthParams.error) {
// eslint-disable-next-line react-hooks/set-state-in-effect
checkStatus().then(() => {
if (oauthParams.connected) {
syncEvents();
}
});
}
// checkStatus and syncEvents are stable callbacks; oauthParams is a mount-time constant
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const disconnect = async () => { const disconnect = async () => {
await fetch("/api/auth/google/disconnect", { method: "POST" }); await fetch("/api/auth/google/disconnect", { method: "POST" });
setStatus("not-connected"); setStatus("not-connected");
+4 -1
View File
@@ -34,7 +34,10 @@ const JourneyList: React.FC<JourneyListProps> = ({
className = "", className = "",
}) => { }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000); const targetArrivalTime = useMemo(
() => new Date(eventTime.getTime() - arrivalBufferMinutes * 60000),
[eventTime, arrivalBufferMinutes],
);
const walkDurationMs = walkDurationSeconds * 1000; const walkDurationMs = walkDurationSeconds * 1000;
const rankedJourneys = useMemo( const rankedJourneys = useMemo(
() => rankJourneys(journeys, targetArrivalTime, walkDurationMs), () => rankJourneys(journeys, targetArrivalTime, walkDurationMs),
+1 -5
View File
@@ -45,13 +45,9 @@ const EventsContext = createContext<EventsContextType | undefined>(undefined);
* Persists events to localStorage on every change (skips initial hydration write). * Persists events to localStorage on every change (skips initial hydration write).
*/ */
export function EventsProvider({ children }: { children: ReactNode }) { export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEventsState] = useState<Event[]>([]); const [events, setEventsState] = useState<Event[]>(loadFromStorage);
const skipInitialWrite = useRef(true); const skipInitialWrite = useRef(true);
useEffect(() => {
setEventsState(loadFromStorage());
}, []);
useEffect(() => { useEffect(() => {
if (skipInitialWrite.current) { if (skipInitialWrite.current) {
skipInitialWrite.current = false; skipInitialWrite.current = false;