fix(extensions): preserve mcp dialog state on runtime hydration

This commit is contained in:
777genius 2026-04-17 15:06:36 +03:00
parent 1b1c27e9c6
commit 88ac0acdf8
5 changed files with 195 additions and 11 deletions

View file

@ -29,6 +29,7 @@ import {
getDefaultMcpSharedScope,
getMcpScopeLabel,
isProjectScopedMcpScope,
isSharedMcpScope,
} from '@shared/utils/mcpScopes';
import { Plus, Server, Trash2 } from 'lucide-react';
@ -95,6 +96,8 @@ export const CustomMcpServerDialog = ({
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const autoFilledValuesRef = useRef<Record<string, string>>({});
const wasOpenRef = useRef(false);
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
const envVarLookupNames = envVars
.map((entry) => entry.key.trim())
.filter(Boolean)
@ -112,7 +115,8 @@ export const CustomMcpServerDialog = ({
// Reset on open
useEffect(() => {
if (open) {
const justOpened = open && !wasOpenRef.current;
if (justOpened) {
setServerName('');
setTransportMode('stdio');
setScope(defaultSharedScope);
@ -126,8 +130,30 @@ export const CustomMcpServerDialog = ({
setInstalling(false);
autoFilledValuesRef.current = {};
}
wasOpenRef.current = open;
if (!open) {
previousDefaultSharedScopeRef.current = defaultSharedScope;
}
}, [defaultSharedScope, open]);
useEffect(() => {
if (!open) {
previousDefaultSharedScopeRef.current = defaultSharedScope;
return;
}
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
if (
previousDefaultSharedScope !== defaultSharedScope &&
scope === previousDefaultSharedScope &&
isSharedMcpScope(scope)
) {
setScope(defaultSharedScope);
}
previousDefaultSharedScopeRef.current = defaultSharedScope;
}, [defaultSharedScope, open, scope]);
useEffect(() => {
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
setScope(defaultSharedScope);

View file

@ -29,6 +29,7 @@ import {
getDefaultMcpSharedScope,
getMcpScopeLabel,
isProjectScopedMcpScope,
isSharedMcpScope,
} from '@shared/utils/mcpScopes';
import {
getMcpInstallationSummaryLabel,
@ -93,6 +94,7 @@ export const McpServerDetailDialog = ({
const [imgError, setImgError] = useState(false);
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
const autoFilledValuesRef = useRef<Record<string, string>>({});
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
const normalizedInstalledEntries = installedEntries.length
? installedEntries
: installedEntry
@ -143,21 +145,34 @@ export const McpServerDetailDialog = ({
setImgError(false);
setAutoFilledFields(new Set());
autoFilledValuesRef.current = {};
}, [
defaultSharedScope,
open,
preferredInstalledEntry?.name,
preferredInstalledEntry?.scope,
server?.id,
]);
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
useEffect(() => {
if (!server || !open) {
if (!open) {
previousDefaultSharedScopeRef.current = defaultSharedScope;
return;
}
setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
}, [open, scope, selectedInstalledEntry?.name, server]);
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
if (
previousDefaultSharedScope !== defaultSharedScope &&
!preferredInstalledEntry &&
scope === previousDefaultSharedScope &&
isSharedMcpScope(scope)
) {
setScope(defaultSharedScope);
}
previousDefaultSharedScopeRef.current = defaultSharedScope;
}, [defaultSharedScope, open, preferredInstalledEntry, scope]);
useEffect(() => {
if (!server || !open || !selectedInstalledEntry) {
return;
}
setServerName(selectedInstalledEntry.name);
}, [open, selectedInstalledEntry, server]);
useEffect(() => {
if (open && isProjectScopedMcpScope(scope) && !projectPath) {

View file

@ -8,6 +8,10 @@ export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedSc
return flavor === 'agent_teams_orchestrator' ? 'global' : 'user';
}
export function isSharedMcpScope(scope?: string): scope is McpSharedScope {
return scope === 'user' || scope === 'global';
}
export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' {
return scope === 'project' || scope === 'local';
}

View file

@ -189,6 +189,63 @@ describe('CustomMcpServerDialog project scope', () => {
});
});
it('preserves entered values when multimodel scope metadata loads after open', async () => {
storeState.cliStatus = null;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(CustomMcpServerDialog, {
open: true,
onClose: vi.fn(),
projectPath: null,
})
);
await Promise.resolve();
});
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
setNativeValue(nameInput, 'late-hydration-server', 'input');
setNativeValue(packageInput, '@example/late-hydration', 'input');
await Promise.resolve();
});
expect(scopeSelect.value).toBe('user');
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
await act(async () => {
root.render(
React.createElement(CustomMcpServerDialog, {
open: true,
onClose: vi.fn(),
projectPath: null,
})
);
await Promise.resolve();
});
expect((host.querySelector('#custom-name') as HTMLInputElement).value).toBe(
'late-hydration-server'
);
expect((host.querySelector('#custom-npm') as HTMLInputElement).value).toBe(
'@example/late-hydration'
);
expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe(
'global'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('disables installation when the runtime declares MCP writes unavailable', async () => {
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',

View file

@ -165,6 +165,15 @@ function makeServer(): McpCatalogItem {
};
}
function setInputValue(element: HTMLInputElement | HTMLSelectElement, value: string): void {
const prototype =
element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
descriptor?.set?.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}
describe('McpServerDetailDialog installed entry handling', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@ -416,6 +425,79 @@ describe('McpServerDetailDialog installed entry handling', () => {
});
});
it('preserves edited fields when multimodel scope metadata loads after open', async () => {
storeState.cliStatus = null;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const server = makeServer();
server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }];
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server,
isInstalled: false,
installedEntry: null,
installedEntries: [],
diagnostic: null,
diagnosticsLoading: false,
projectPath: null,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement;
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
setInputValue(serverNameInput, 'late-hydration-context7');
setInputValue(envValueInput, 'secret');
await Promise.resolve();
});
expect(scopeSelect.value).toBe('user');
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server,
isInstalled: false,
installedEntry: null,
installedEntries: [],
diagnostic: null,
diagnosticsLoading: false,
projectPath: null,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect((host.querySelector('#server-name') as HTMLInputElement).value).toBe(
'late-hydration-context7'
);
expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(
'secret'
);
expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe(
'global'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes project path for project-scoped installs and uninstalls', async () => {
const host = document.createElement('div');
document.body.appendChild(host);