fix(hooks): memoize return objects to prevent infinite render loops

Apply useMemo to hook returns that create new object references on each
render. This prevents downstream useEffect/useCallback dependencies from
detecting false changes and triggering re-execution.

Fixed hooks:
- PrivacyProvider: memoize context value and toggle callback
- useCopilot: wrap large return object with all dependencies
- useWebSocket: memoize status/connect/disconnect object
- useProjectSelection: memoize state and handlers object
- useCliproxyAuthFlow: memoize spread state with auth functions
- useAnalyticsPage: memoize apiOptions to stabilize query params
This commit is contained in:
kaitranntt committed 2025-12-21 04:17:32 -05:00
1 parent b9e69965e2
commit f15b989508
6 files changed
+120 -66

No files matched your search

+15 -6
View File
@@ -5,7 +5,15 @@
*/ */
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import {
createContext,
useContext,
useState,
useEffect,
useMemo,
useCallback,
type ReactNode,
} from 'react';
interface PrivacyContextValue { interface PrivacyContextValue {
/** Whether privacy/demo mode is enabled */ /** Whether privacy/demo mode is enabled */
@@ -32,13 +40,14 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, String(privacyMode)); localStorage.setItem(STORAGE_KEY, String(privacyMode));
}, [privacyMode]); }, [privacyMode]);
const togglePrivacyMode = () => setPrivacyMode((prev) => !prev); const togglePrivacyMode = useCallback(() => setPrivacyMode((prev) => !prev), []);
return ( const value = useMemo(
<PrivacyContext.Provider value={{ privacyMode, togglePrivacyMode }}> () => ({ privacyMode, togglePrivacyMode }),
{children} [privacyMode, togglePrivacyMode]
</PrivacyContext.Provider>
); );
return <PrivacyContext.Provider value={value}>{children}</PrivacyContext.Provider>;
} }
export function usePrivacy() { export function usePrivacy() {
+9 -6
View File
@@ -3,7 +3,7 @@
* Manages popup-based OAuth authentication flows * Manages popup-based OAuth authentication flows
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -180,9 +180,12 @@ export function useCliproxyAuthFlow() {
setState({ provider: null, isAuthenticating: false, error: null }); setState({ provider: null, isAuthenticating: false, error: null });
}, [cleanup]); }, [cleanup]);
return { return useMemo(
...state, () => ({
startAuth, ...state,
cancelAuth, startAuth,
}; cancelAuth,
}),
[state, startAuth, cancelAuth]
);
} }
+76 -40
View File
@@ -4,6 +4,7 @@
* React hook for managing GitHub Copilot integration state. * React hook for managing GitHub Copilot integration state.
*/ */
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api'; const API_BASE = '/api';
@@ -247,54 +248,89 @@ export function useCopilot() {
}, },
}); });
return { return useMemo(
// Status () => ({
status: statusQuery.data, // Status
statusLoading: statusQuery.isLoading, status: statusQuery.data,
statusError: statusQuery.error, statusLoading: statusQuery.isLoading,
refetchStatus: statusQuery.refetch, statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
// Config // Config
config: configQuery.data, config: configQuery.data,
configLoading: configQuery.isLoading, configLoading: configQuery.isLoading,
// Models // Models
models: modelsQuery.data?.models ?? [], models: modelsQuery.data?.models ?? [],
currentModel: modelsQuery.data?.current, currentModel: modelsQuery.data?.current,
modelsLoading: modelsQuery.isLoading, modelsLoading: modelsQuery.isLoading,
// Raw Settings // Raw Settings
rawSettings: rawSettingsQuery.data, rawSettings: rawSettingsQuery.data,
rawSettingsLoading: rawSettingsQuery.isLoading, rawSettingsLoading: rawSettingsQuery.isLoading,
refetchRawSettings: rawSettingsQuery.refetch, refetchRawSettings: rawSettingsQuery.refetch,
// Mutations // Mutations
updateConfig: updateConfigMutation.mutate, updateConfig: updateConfigMutation.mutate,
updateConfigAsync: updateConfigMutation.mutateAsync, updateConfigAsync: updateConfigMutation.mutateAsync,
isUpdating: updateConfigMutation.isPending, isUpdating: updateConfigMutation.isPending,
saveRawSettings: saveRawSettingsMutation.mutate, saveRawSettings: saveRawSettingsMutation.mutate,
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
isSavingRawSettings: saveRawSettingsMutation.isPending, isSavingRawSettings: saveRawSettingsMutation.isPending,
startAuth: startAuthMutation.mutate, startAuth: startAuthMutation.mutate,
startAuthAsync: startAuthMutation.mutateAsync, startAuthAsync: startAuthMutation.mutateAsync,
isAuthenticating: startAuthMutation.isPending, isAuthenticating: startAuthMutation.isPending,
authResult: startAuthMutation.data, authResult: startAuthMutation.data,
startDaemon: startDaemonMutation.mutate, startDaemon: startDaemonMutation.mutate,
isStartingDaemon: startDaemonMutation.isPending, isStartingDaemon: startDaemonMutation.isPending,
stopDaemon: stopDaemonMutation.mutate, stopDaemon: stopDaemonMutation.mutate,
isStoppingDaemon: stopDaemonMutation.isPending, isStoppingDaemon: stopDaemonMutation.isPending,
// Install // Install
info: infoQuery.data, info: infoQuery.data,
infoLoading: infoQuery.isLoading, infoLoading: infoQuery.isLoading,
refetchInfo: infoQuery.refetch, refetchInfo: infoQuery.refetch,
install: installMutation.mutate, install: installMutation.mutate,
installAsync: installMutation.mutateAsync, installAsync: installMutation.mutateAsync,
isInstalling: installMutation.isPending, isInstalling: installMutation.isPending,
}; }),
[
statusQuery.data,
statusQuery.isLoading,
statusQuery.error,
statusQuery.refetch,
configQuery.data,
configQuery.isLoading,
modelsQuery.data,
modelsQuery.isLoading,
rawSettingsQuery.data,
rawSettingsQuery.isLoading,
rawSettingsQuery.refetch,
updateConfigMutation.mutate,
updateConfigMutation.mutateAsync,
updateConfigMutation.isPending,
saveRawSettingsMutation.mutate,
saveRawSettingsMutation.mutateAsync,
saveRawSettingsMutation.isPending,
startAuthMutation.mutate,
startAuthMutation.mutateAsync,
startAuthMutation.isPending,
startAuthMutation.data,
startDaemonMutation.mutate,
startDaemonMutation.isPending,
stopDaemonMutation.mutate,
stopDaemonMutation.isPending,
infoQuery.data,
infoQuery.isLoading,
infoQuery.refetch,
installMutation.mutate,
installMutation.mutateAsync,
installMutation.isPending,
]
);
} }
+10 -7
View File
@@ -5,7 +5,7 @@
* Used in conjunction with ProjectSelectionDialog component. * Used in conjunction with ProjectSelectionDialog component.
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
interface GCloudProject { interface GCloudProject {
id: string; id: string;
@@ -102,10 +102,13 @@ export function useProjectSelection() {
setState({ isOpen: false, prompt: null }); setState({ isOpen: false, prompt: null });
}, []); }, []);
return { return useMemo(
isOpen: state.isOpen, () => ({
prompt: state.prompt, isOpen: state.isOpen,
onSelect: handleSelect, prompt: state.prompt,
onClose: handleClose, onSelect: handleSelect,
}; onClose: handleClose,
}),
[state.isOpen, state.prompt, handleSelect, handleClose]
);
} }
+2 -2
View File
@@ -4,7 +4,7 @@
* Manages WebSocket connection, auto-reconnect, and React Query invalidation. * Manages WebSocket connection, auto-reconnect, and React Query invalidation.
*/ */
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -141,5 +141,5 @@ export function useWebSocket() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
return { status, connect, disconnect }; return useMemo(() => ({ status, connect, disconnect }), [status, connect, disconnect]);
} }
+8 -5
View File
@@ -41,11 +41,14 @@ export function useAnalyticsPage() {
} }
}, [refreshUsage]); }, [refreshUsage]);
// Convert dates to API format // Convert dates to API format - memoized to prevent unnecessary re-renders
const apiOptions = { const apiOptions = useMemo(
startDate: dateRange?.from, () => ({
endDate: dateRange?.to, startDate: dateRange?.from,
}; endDate: dateRange?.to,
}),
[dateRange?.from, dateRange?.to]
);
// Fetch data // Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions); const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);