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
2025-12-21 04:17:32 -05:00
parent b9e69965e2
commit f15b989508
6 changed files with 120 additions and 66 deletions
+15 -6
View File
@@ -5,7 +5,15 @@
*/
/* 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 {
/** Whether privacy/demo mode is enabled */
@@ -32,13 +40,14 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, String(privacyMode));
}, [privacyMode]);
const togglePrivacyMode = () => setPrivacyMode((prev) => !prev);
const togglePrivacyMode = useCallback(() => setPrivacyMode((prev) => !prev), []);
return (
<PrivacyContext.Provider value={{ privacyMode, togglePrivacyMode }}>
{children}
</PrivacyContext.Provider>
const value = useMemo(
() => ({ privacyMode, togglePrivacyMode }),
[privacyMode, togglePrivacyMode]
);
return <PrivacyContext.Provider value={value}>{children}</PrivacyContext.Provider>;
}
export function usePrivacy() {
+9 -6
View File
@@ -3,7 +3,7 @@
* 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 { toast } from 'sonner';
@@ -180,9 +180,12 @@ export function useCliproxyAuthFlow() {
setState({ provider: null, isAuthenticating: false, error: null });
}, [cleanup]);
return {
...state,
startAuth,
cancelAuth,
};
return useMemo(
() => ({
...state,
startAuth,
cancelAuth,
}),
[state, startAuth, cancelAuth]
);
}
+76 -40
View File
@@ -4,6 +4,7 @@
* React hook for managing GitHub Copilot integration state.
*/
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
@@ -247,54 +248,89 @@ export function useCopilot() {
},
});
return {
// Status
status: statusQuery.data,
statusLoading: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
return useMemo(
() => ({
// Status
status: statusQuery.data,
statusLoading: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
// Config
config: configQuery.data,
configLoading: configQuery.isLoading,
// Config
config: configQuery.data,
configLoading: configQuery.isLoading,
// Models
models: modelsQuery.data?.models ?? [],
currentModel: modelsQuery.data?.current,
modelsLoading: modelsQuery.isLoading,
// Models
models: modelsQuery.data?.models ?? [],
currentModel: modelsQuery.data?.current,
modelsLoading: modelsQuery.isLoading,
// Raw Settings
rawSettings: rawSettingsQuery.data,
rawSettingsLoading: rawSettingsQuery.isLoading,
refetchRawSettings: rawSettingsQuery.refetch,
// Raw Settings
rawSettings: rawSettingsQuery.data,
rawSettingsLoading: rawSettingsQuery.isLoading,
refetchRawSettings: rawSettingsQuery.refetch,
// Mutations
updateConfig: updateConfigMutation.mutate,
updateConfigAsync: updateConfigMutation.mutateAsync,
isUpdating: updateConfigMutation.isPending,
// Mutations
updateConfig: updateConfigMutation.mutate,
updateConfigAsync: updateConfigMutation.mutateAsync,
isUpdating: updateConfigMutation.isPending,
saveRawSettings: saveRawSettingsMutation.mutate,
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
isSavingRawSettings: saveRawSettingsMutation.isPending,
saveRawSettings: saveRawSettingsMutation.mutate,
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
isSavingRawSettings: saveRawSettingsMutation.isPending,
startAuth: startAuthMutation.mutate,
startAuthAsync: startAuthMutation.mutateAsync,
isAuthenticating: startAuthMutation.isPending,
authResult: startAuthMutation.data,
startAuth: startAuthMutation.mutate,
startAuthAsync: startAuthMutation.mutateAsync,
isAuthenticating: startAuthMutation.isPending,
authResult: startAuthMutation.data,
startDaemon: startDaemonMutation.mutate,
isStartingDaemon: startDaemonMutation.isPending,
startDaemon: startDaemonMutation.mutate,
isStartingDaemon: startDaemonMutation.isPending,
stopDaemon: stopDaemonMutation.mutate,
isStoppingDaemon: stopDaemonMutation.isPending,
stopDaemon: stopDaemonMutation.mutate,
isStoppingDaemon: stopDaemonMutation.isPending,
// Install
info: infoQuery.data,
infoLoading: infoQuery.isLoading,
refetchInfo: infoQuery.refetch,
// Install
info: infoQuery.data,
infoLoading: infoQuery.isLoading,
refetchInfo: infoQuery.refetch,
install: installMutation.mutate,
installAsync: installMutation.mutateAsync,
isInstalling: installMutation.isPending,
};
install: installMutation.mutate,
installAsync: installMutation.mutateAsync,
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.
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
interface GCloudProject {
id: string;
@@ -102,10 +102,13 @@ export function useProjectSelection() {
setState({ isOpen: false, prompt: null });
}, []);
return {
isOpen: state.isOpen,
prompt: state.prompt,
onSelect: handleSelect,
onClose: handleClose,
};
return useMemo(
() => ({
isOpen: state.isOpen,
prompt: state.prompt,
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.
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -141,5 +141,5 @@ export function useWebSocket() {
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]);
// Convert dates to API format
const apiOptions = {
startDate: dateRange?.from,
endDate: dateRange?.to,
};
// Convert dates to API format - memoized to prevent unnecessary re-renders
const apiOptions = useMemo(
() => ({
startDate: dateRange?.from,
endDate: dateRange?.to,
}),
[dateRange?.from, dateRange?.to]
);
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);