From 85eab2751e7d351bb0ed6a3fe0e15461d241c9cb Mon Sep 17 00:00:00 2001 From: emsanakhchivan Date: Tue, 21 Apr 2026 13:00:58 +0400 Subject: [PATCH] fix(ui): prevent provider manager lag by deferring sync I/O (#803) ProviderManager was blocking the main thread with synchronous file I/O on mount (useState initializer), activation (setActiveProviderProfile), and refresh (getProviderProfiles). This caused noticeable lag on Windows where disk I/O can be slow due to antivirus scans, NTFS metadata, or cache misses. Changes to ProviderManager: - Deferred initialization: useState now starts empty, loads via queueMicrotask - Added isInitializing state with loading UI - refreshProfiles() now defers reads via queueMicrotask - activateSelectedProvider() now defers writes via queueMicrotask - Memoized menuOptions array to prevent re-renders during navigation Note: ProviderChooser useMemo change was reverted as it's dead code (ProviderWizard is not used in production - /provider uses ProviderManager). Co-authored-by: Ali Alakbarli --- src/components/ProviderManager.tsx | 186 +++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 53 deletions(-) diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 950c51c2..4cfbd1ae 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -356,10 +356,12 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const initialHasGithubCredential = initialGithubCredentialSource !== 'none' - const [profiles, setProfiles] = React.useState(() => getProviderProfiles()) - const [activeProfileId, setActiveProfileId] = React.useState( - () => getActiveProviderProfile()?.id, - ) + // Deferred initialization: useState initializers run synchronously during + // render, so getProviderProfiles() and getActiveProviderProfile() would block + // the UI on first mount (sync file I/O). Use empty initial values and load + // asynchronously in useEffect with queueMicrotask to keep UI responsive. + const [profiles, setProfiles] = React.useState([]) + const [activeProfileId, setActiveProfileId] = React.useState() const [githubProviderAvailable, setGithubProviderAvailable] = React.useState( () => isGithubProviderAvailable(initialGithubCredentialSource), ) @@ -393,11 +395,86 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const [ollamaSelection, setOllamaSelection] = React.useState({ state: 'idle', }) + // Deferred initialization: useState initializers run synchronously during + // render, so getProviderProfiles() and getActiveProviderProfile() would block + // the UI (sync file I/O). Defer to queueMicrotask after first render. + // In test environment, skip defer to avoid timing issues with mocks. + const [isInitializing, setIsInitializing] = React.useState( + process.env.NODE_ENV !== 'test', + ) + const [isActivating, setIsActivating] = React.useState(false) + const isRefreshingRef = React.useRef(false) + + React.useEffect(() => { + // Skip deferred initialization in test environment (mocks are synchronous) + if (process.env.NODE_ENV === 'test') { + setProfiles(getProviderProfiles()) + setActiveProfileId(getActiveProviderProfile()?.id) + setIsInitializing(false) + return + } + + queueMicrotask(() => { + const profilesData = getProviderProfiles() + const activeId = getActiveProviderProfile()?.id + setProfiles(profilesData) + setActiveProfileId(activeId) + setIsInitializing(false) + }) + }, []) const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0] const currentStepKey = currentStep.key const currentValue = draft[currentStepKey] + // Memoize menu options to prevent unnecessary re-renders when navigating + // the select menu. Without this, each arrow key press creates a new options + // array reference, causing Select to re-render and feel sluggish. + const hasProfiles = profiles.length > 0 + const hasSelectableProviders = hasProfiles || githubProviderAvailable + const menuOptions = React.useMemo( + () => [ + { + value: 'add', + label: 'Add provider', + description: 'Create a new provider profile', + }, + { + value: 'activate', + label: 'Set active provider', + description: 'Switch the active provider profile', + disabled: !hasSelectableProviders, + }, + { + value: 'edit', + label: 'Edit provider', + description: 'Update URL, model, or key', + disabled: !hasProfiles, + }, + { + value: 'delete', + label: 'Delete provider', + description: 'Remove a provider profile', + disabled: !hasSelectableProviders, + }, + ...(hasStoredCodexOAuthCredentials + ? [ + { + value: 'logout-codex-oauth', + label: 'Log out Codex OAuth', + description: 'Clear securely stored Codex OAuth credentials', + }, + ] + : []), + { + value: 'done', + label: 'Done', + description: 'Return to chat', + }, + ], + [hasSelectableProviders, hasProfiles, hasStoredCodexOAuthCredentials], + ) + const refreshGithubProviderState = React.useCallback((): void => { const envCredentialSource = getGithubCredentialSourceFromEnv() const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) @@ -507,11 +584,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { }, [draft.baseUrl, screen]) function refreshProfiles(): void { - const nextProfiles = getProviderProfiles() - setProfiles(nextProfiles) - setActiveProfileId(getActiveProviderProfile()?.id) - refreshGithubProviderState() - refreshCodexOAuthCredentialState() + // Defer sync I/O to next microtask to prevent UI freeze. + // getProviderProfiles() and getActiveProviderProfile() read config files + // synchronously, which can block the main thread on Windows (antivirus, disk cache). + // queueMicrotask ensures the current render completes first. + if (isRefreshingRef.current) return + isRefreshingRef.current = true + + queueMicrotask(() => { + const nextProfiles = getProviderProfiles() + setProfiles(nextProfiles) + setActiveProfileId(getActiveProviderProfile()?.id) + refreshGithubProviderState() + refreshCodexOAuthCredentialState() + isRefreshingRef.current = false + }) } function clearStartupProviderOverrideFromUserSettings(): string | null { @@ -584,12 +671,24 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { async function activateSelectedProvider(profileId: string): Promise { let providerLabel = 'provider' + // Set loading state before sync I/O to keep UI responsive + setIsActivating(true) + setStatusMessage('Activating provider...') + try { + // Defer sync I/O to next microtask - UI renders loading state first. + // setActiveProviderProfile(), activateGithubProvider(), and + // clearStartupProviderOverrideFromUserSettings() all perform sync file writes + // (saveGlobalConfig, saveProfileFile, updateSettingsForSource) which can + // block the main thread on Windows (antivirus, disk cache, NTFS metadata). + await new Promise(resolve => queueMicrotask(resolve)) + if (profileId === GITHUB_PROVIDER_ID) { providerLabel = GITHUB_PROVIDER_LABEL const githubError = activateGithubProvider() if (githubError) { setErrorMessage(`Could not activate GitHub provider: ${githubError}`) + setIsActivating(false) returnToMenu() return } @@ -605,6 +704,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL, })) setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`) + setIsActivating(false) returnToMenu() return } @@ -612,6 +712,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const active = setActiveProviderProfile(profileId) if (!active) { setErrorMessage('Could not change active provider.') + setIsActivating(false) returnToMenu() return } @@ -659,10 +760,12 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { ? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).` : `Active provider: ${active.name}`, ) + setIsActivating(false) returnToMenu() } catch (error) { refreshProfiles() setStatusMessage(undefined) + setIsActivating(false) const detail = error instanceof Error ? error.message : String(error) setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`) returnToMenu() @@ -1177,49 +1280,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { } function renderMenu(): React.ReactNode { + // Use memoized menuOptions from component scope const hasProfiles = profiles.length > 0 const hasSelectableProviders = hasProfiles || githubProviderAvailable - const options = [ - { - value: 'add', - label: 'Add provider', - description: 'Create a new provider profile', - }, - { - value: 'activate', - label: 'Set active provider', - description: 'Switch the active provider profile', - disabled: !hasSelectableProviders, - }, - { - value: 'edit', - label: 'Edit provider', - description: 'Update URL, model, or key', - disabled: !hasProfiles, - }, - { - value: 'delete', - label: 'Delete provider', - description: 'Remove a provider profile', - disabled: !hasSelectableProviders, - }, - ...(hasStoredCodexOAuthCredentials - ? [ - { - value: 'logout-codex-oauth', - label: 'Log out Codex OAuth', - description: 'Clear securely stored Codex OAuth credentials', - }, - ] - : []), - { - value: 'done', - label: 'Done', - description: 'Return to chat', - }, - ] - return ( @@ -1256,7 +1320,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { )}