From f15b98950865c01ec6d8d846e3a197bb04e6cf6e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 21 Dec 2025 04:17:32 -0500 Subject: [PATCH] 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 --- ui/src/contexts/privacy-context.tsx | 21 +++-- ui/src/hooks/use-cliproxy-auth-flow.ts | 15 ++-- ui/src/hooks/use-copilot.ts | 116 ++++++++++++++++--------- ui/src/hooks/use-project-selection.ts | 17 ++-- ui/src/hooks/use-websocket.ts | 4 +- ui/src/pages/analytics/hooks.ts | 13 +-- 6 files changed, 120 insertions(+), 66 deletions(-) diff --git a/ui/src/contexts/privacy-context.tsx b/ui/src/contexts/privacy-context.tsx index 98cc98fb..3cf6cbcb 100644 --- a/ui/src/contexts/privacy-context.tsx +++ b/ui/src/contexts/privacy-context.tsx @@ -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 ( - - {children} - + const value = useMemo( + () => ({ privacyMode, togglePrivacyMode }), + [privacyMode, togglePrivacyMode] ); + + return {children}; } export function usePrivacy() { diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 103d98e0..e896c3f3 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -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] + ); } diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts index 3431ffdb..2aed765e 100644 --- a/ui/src/hooks/use-copilot.ts +++ b/ui/src/hooks/use-copilot.ts @@ -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, + ] + ); } diff --git a/ui/src/hooks/use-project-selection.ts b/ui/src/hooks/use-project-selection.ts index 89f16e68..8e466178 100644 --- a/ui/src/hooks/use-project-selection.ts +++ b/ui/src/hooks/use-project-selection.ts @@ -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] + ); } diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index 8ff5de46..16abc65f 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -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]); } diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts index 8f8e39a7..f4fc73ef 100644 --- a/ui/src/pages/analytics/hooks.ts +++ b/ui/src/pages/analytics/hooks.ts @@ -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);