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:
@@ -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}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user