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 <ali.alakbarli@users.noreply.github.com>
This commit is contained in:
emsanakhchivan
2026-04-21 13:00:58 +04:00
committed by GitHub
parent 4d4fb2880e
commit 85eab2751e

View File

@@ -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<ProviderProfile[]>([])
const [activeProfileId, setActiveProfileId] = React.useState<string | undefined>()
const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(
() => isGithubProviderAvailable(initialGithubCredentialSource),
)
@@ -393,11 +395,86 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
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<void> {
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<void>(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 (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
@@ -1256,7 +1320,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
)}
</Box>
<Select
options={options}
options={menuOptions}
onChange={(value: string) => {
setErrorMessage(undefined)
switch (value) {
@@ -1269,7 +1333,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}
break
case 'edit':
if (profiles.length > 0) {
if (hasProfiles) {
setScreen('select-edit')
}
break
@@ -1326,7 +1390,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}}
onCancel={() => closeWithCancelled('Provider manager closed')}
defaultFocusValue={menuFocusValue}
visibleOptionCount={options.length}
visibleOptionCount={menuOptions.length}
/>
</Box>
)
@@ -1562,5 +1626,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
break
}
return <Pane color="permission">{content}</Pane>
return (
<Pane color="permission">
{isInitializing ? (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>Loading providers...</Text>
<Text dimColor>Reading provider profiles from disk.</Text>
</Box>
) : isActivating ? (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>Activating provider...</Text>
<Text dimColor>Please wait while the provider is being configured.</Text>
</Box>
) : (
content
)}
</Pane>
)
}