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 initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
|
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
|
||||||
|
|
||||||
const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
|
// Deferred initialization: useState initializers run synchronously during
|
||||||
const [activeProfileId, setActiveProfileId] = React.useState(
|
// render, so getProviderProfiles() and getActiveProviderProfile() would block
|
||||||
() => getActiveProviderProfile()?.id,
|
// 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(
|
const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(
|
||||||
() => isGithubProviderAvailable(initialGithubCredentialSource),
|
() => isGithubProviderAvailable(initialGithubCredentialSource),
|
||||||
)
|
)
|
||||||
@@ -393,11 +395,86 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
|
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
|
||||||
state: 'idle',
|
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 currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0]
|
||||||
const currentStepKey = currentStep.key
|
const currentStepKey = currentStep.key
|
||||||
const currentValue = draft[currentStepKey]
|
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 refreshGithubProviderState = React.useCallback((): void => {
|
||||||
const envCredentialSource = getGithubCredentialSourceFromEnv()
|
const envCredentialSource = getGithubCredentialSourceFromEnv()
|
||||||
const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
@@ -507,11 +584,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}, [draft.baseUrl, screen])
|
}, [draft.baseUrl, screen])
|
||||||
|
|
||||||
function refreshProfiles(): void {
|
function refreshProfiles(): void {
|
||||||
|
// 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()
|
const nextProfiles = getProviderProfiles()
|
||||||
setProfiles(nextProfiles)
|
setProfiles(nextProfiles)
|
||||||
setActiveProfileId(getActiveProviderProfile()?.id)
|
setActiveProfileId(getActiveProviderProfile()?.id)
|
||||||
refreshGithubProviderState()
|
refreshGithubProviderState()
|
||||||
refreshCodexOAuthCredentialState()
|
refreshCodexOAuthCredentialState()
|
||||||
|
isRefreshingRef.current = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearStartupProviderOverrideFromUserSettings(): string | null {
|
function clearStartupProviderOverrideFromUserSettings(): string | null {
|
||||||
@@ -584,12 +671,24 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
async function activateSelectedProvider(profileId: string): Promise<void> {
|
async function activateSelectedProvider(profileId: string): Promise<void> {
|
||||||
let providerLabel = 'provider'
|
let providerLabel = 'provider'
|
||||||
|
|
||||||
|
// Set loading state before sync I/O to keep UI responsive
|
||||||
|
setIsActivating(true)
|
||||||
|
setStatusMessage('Activating provider...')
|
||||||
|
|
||||||
try {
|
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) {
|
if (profileId === GITHUB_PROVIDER_ID) {
|
||||||
providerLabel = GITHUB_PROVIDER_LABEL
|
providerLabel = GITHUB_PROVIDER_LABEL
|
||||||
const githubError = activateGithubProvider()
|
const githubError = activateGithubProvider()
|
||||||
if (githubError) {
|
if (githubError) {
|
||||||
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
||||||
|
setIsActivating(false)
|
||||||
returnToMenu()
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -605,6 +704,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
|
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
|
||||||
}))
|
}))
|
||||||
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
||||||
|
setIsActivating(false)
|
||||||
returnToMenu()
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -612,6 +712,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
const active = setActiveProviderProfile(profileId)
|
const active = setActiveProviderProfile(profileId)
|
||||||
if (!active) {
|
if (!active) {
|
||||||
setErrorMessage('Could not change active provider.')
|
setErrorMessage('Could not change active provider.')
|
||||||
|
setIsActivating(false)
|
||||||
returnToMenu()
|
returnToMenu()
|
||||||
return
|
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}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||||
: `Active provider: ${active.name}`,
|
: `Active provider: ${active.name}`,
|
||||||
)
|
)
|
||||||
|
setIsActivating(false)
|
||||||
returnToMenu()
|
returnToMenu()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
setStatusMessage(undefined)
|
setStatusMessage(undefined)
|
||||||
|
setIsActivating(false)
|
||||||
const detail = error instanceof Error ? error.message : String(error)
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
|
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
|
||||||
returnToMenu()
|
returnToMenu()
|
||||||
@@ -1177,49 +1280,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMenu(): React.ReactNode {
|
function renderMenu(): React.ReactNode {
|
||||||
|
// Use memoized menuOptions from component scope
|
||||||
const hasProfiles = profiles.length > 0
|
const hasProfiles = profiles.length > 0
|
||||||
const hasSelectableProviders = hasProfiles || githubProviderAvailable
|
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 (
|
return (
|
||||||
<Box flexDirection="column" gap={1}>
|
<Box flexDirection="column" gap={1}>
|
||||||
<Text color="remember" bold>
|
<Text color="remember" bold>
|
||||||
@@ -1256,7 +1320,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Select
|
<Select
|
||||||
options={options}
|
options={menuOptions}
|
||||||
onChange={(value: string) => {
|
onChange={(value: string) => {
|
||||||
setErrorMessage(undefined)
|
setErrorMessage(undefined)
|
||||||
switch (value) {
|
switch (value) {
|
||||||
@@ -1269,7 +1333,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'edit':
|
case 'edit':
|
||||||
if (profiles.length > 0) {
|
if (hasProfiles) {
|
||||||
setScreen('select-edit')
|
setScreen('select-edit')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -1326,7 +1390,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}}
|
}}
|
||||||
onCancel={() => closeWithCancelled('Provider manager closed')}
|
onCancel={() => closeWithCancelled('Provider manager closed')}
|
||||||
defaultFocusValue={menuFocusValue}
|
defaultFocusValue={menuFocusValue}
|
||||||
visibleOptionCount={options.length}
|
visibleOptionCount={menuOptions.length}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -1562,5 +1626,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
break
|
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