fix custom provider edit gating and form reset on re-render

Gate Edit/incomplete-auth on config-layer sources so catalog providers
are not treated as editable custom overrides. Snapshot form initial
values when Edit is clicked and ignore same-id initialValues identity
changes so parent re-renders cannot wipe in-progress edits.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-02 12:42:23 +00:00
co-authored by Serhii Dziupin
parent d40bb9e5a0
commit b7c09ee137
4 changed files with 77 additions and 16 deletions
@@ -58,15 +58,23 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
const [err, setErr] = React.useState<FieldErrors>({});
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
const seededEditProviderIdRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (initialValues) {
setForm(initialValues);
setErr({});
setModelErrors([]);
setHeaderErrors([]);
if (!initialValues) {
return;
}
}, [initialValues]);
// Edit mode: seed once per provider id so parent re-renders (new object
// identity for the same snapshot) do not wipe in-progress edits.
if (isEdit && seededEditProviderIdRef.current === initialValues.providerID) {
return;
}
seededEditProviderIdRef.current = isEdit ? initialValues.providerID : null;
setForm(initialValues);
setErr({});
setModelErrors([]);
setHeaderErrors([]);
}, [initialValues, isEdit]);
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -31,8 +31,9 @@ import {
buildAuthSetRequest,
buildProviderUpsertRequest,
CUSTOM_PROVIDER_ID,
isCustomOpenAICompatibleProvider,
isConfigDefinedCustomProvider,
providerToCustomFormState,
type CustomProviderFormState,
type CustomProviderPersistPlan,
} from './custom-provider-form';
@@ -182,6 +183,7 @@ export const ProvidersPage: React.FC = () => {
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
@@ -303,6 +305,7 @@ export const ProvidersPage: React.FC = () => {
if (selectedProviderId === ADD_PROVIDER_ID) {
setShowAuthPanel(true);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setCustomAuthFailureHint(null);
return;
}
@@ -310,9 +313,10 @@ export const ProvidersPage: React.FC = () => {
setShowAuthPanel(false);
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setCustomAuthFailureHint(null);
}
}, [selectedProviderId, editingCustomProviderId, t]);
}, [selectedProviderId, editingCustomProviderId]);
React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
@@ -427,6 +431,7 @@ export const ProvidersPage: React.FC = () => {
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
setCandidateProviderId('');
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
@@ -587,6 +592,7 @@ export const ProvidersPage: React.FC = () => {
}
await handleDisconnectProvider(providerId);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
setCandidateProviderId('');
@@ -902,15 +908,16 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
const isCustomProvider = isCustomOpenAICompatibleProvider(selectedProvider);
const sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
const providerEnv = Array.isArray(selectedProvider.env)
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
const sourcesLoaded = Boolean(selectedSources);
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials;
const authStatusIncomplete = isCustomProvider && sourcesLoaded && !hasCredentials;
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : '';
@@ -920,8 +927,7 @@ export const ProvidersPage: React.FC = () => {
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
if (isCustomEditMode && isCustomProvider) {
const initialValues = providerToCustomFormState(selectedProvider);
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
return (
<SettingsPageLayout
title={selectedProvider.name || selectedProvider.id}
@@ -932,12 +938,13 @@ export const ProvidersPage: React.FC = () => {
<CustomProviderForm
mode="edit"
existingProviderIDs={connectedProviderIds}
initialValues={initialValues}
allowExistingAuth={hasCredentials}
initialValues={editingCustomFormInitial}
allowExistingAuth={hasCredentials || !sourcesLoaded}
busy={authBusyKey?.startsWith('custom:') ?? false}
authFailureHint={customAuthFailureHint}
onCancel={() => {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
@@ -960,13 +967,14 @@ export const ProvidersPage: React.FC = () => {
divider={false}
headerAction={(
<div className="flex items-center gap-1">
{isCustomProvider ? (
{isEditableCustomProvider ? (
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setCustomAuthFailureHint(null);
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
setEditingCustomProviderId(selectedProvider.id);
}}
>
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
isConfigDefinedCustomProvider,
isCustomOpenAICompatibleProvider,
providerToCustomFormState,
validateCustomProvider,
@@ -288,4 +289,24 @@ describe('provider edit helpers', () => {
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
options: { baseURL: 'https://api.openai.com/v1' },
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
};
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
expect(isConfigDefinedCustomProvider(catalogLike, {
user: { exists: false },
project: { exists: false },
custom: { exists: false },
})).toBe(false);
expect(isConfigDefinedCustomProvider(catalogLike, {
user: { exists: true },
project: { exists: false },
})).toBe(true);
});
});
@@ -163,6 +163,30 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom
});
}
export type ProviderConfigSourcesLike = {
user?: { exists?: boolean };
project?: { exists?: boolean };
custom?: { exists?: boolean };
};
/**
* True when a provider both looks OpenAI-compatible-custom and is defined in a
* user/project/custom OpenCode config layer. Catalog-only providers often share
* the same npm/baseURL signals and must not get Edit / config overrides.
*/
export function isConfigDefinedCustomProvider(
provider: ProviderLikeForCustomForm,
sources: ProviderConfigSourcesLike | null | undefined,
): boolean {
if (!sources) {
return false;
}
const inConfigLayer = Boolean(
sources.user?.exists || sources.project?.exists || sources.custom?.exists,
);
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
}
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';