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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user