fix(onboarding): stabilize connector-model state so native <select> stays open

The CEO-runtime model dropdown (and the team-model pool) collapsed the
instant a user clicked to open it whenever the app received a background
snapshot refresh. Root cause:

  useEffect(executionReadyConnectors, ...)
    → loadModels() loads catalog for every ready connector
    → setConnectorModelsById(Object.fromEntries(entries))
      — always a brand-new object reference, even if the catalog
        returned from the IPC is byte-identical to the previous load
    → connectorModels useMemo picks up a new reference
    → modelOptions useMemo picks up a new reference
    → <Select> re-renders its options children
    → Chromium's native <select> treats DOM mutation of <option>
      children as "popup stale" and closes the dropdown.

Because snapshot refreshes fire on every domain-changed event
(credentials-changed, run-status, heartbeat-status, etc.), the user
effectively could not hold the dropdown open long enough to pick a
model.

Fix: structurally compare the freshly-loaded catalog against the
previous state and bail out of setState when the shape is identical
(same connector ids, same per-connector model lists in order, same
id/label/enabled flags). Same guard on the error-path reset so an
already-empty map does not trigger a re-render either.

typecheck 0 / lint 0-0 / test:unit 508/508 / build 0-0.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 10:28:19 -04:00
1 parent 683dded501
commit 7fc707ab8c
1 file changed
+37 -2
@@ -9,6 +9,33 @@ import type { ConnectorId, ProfileSnapshot } from "@shared/types";
import { unwrap } from "../../lib/desktop";
import type { TranslationFn } from "../../i18n";
/**
* Structural equality over the per-connector model catalog. Returns true
* when the two maps hold the same connector ids, each list in the same
* order, and each entry has the same id/label/enabled flag. Used to keep
* React state references stable across snapshot refreshes so an open
* native <select> dropdown is not torn down mid-interaction.
*/
function connectorCatalogsEqual(
a: Record<string, ConnectorModelEntry[]>,
b: Record<string, ConnectorModelEntry[]>,
): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const listA = a[key];
const listB = b[key];
if (!listB || listA.length !== listB.length) return false;
for (let i = 0; i < listA.length; i++) {
const x = listA[i];
const y = listB[i];
if (x.id !== y.id || x.label !== y.label || x.enabled !== y.enabled) return false;
}
}
return true;
}
interface UseConnectorModelSelectionResult {
executionReadyConnectors: ProfileSnapshot["connectors"];
hasAtLeastOneReady: boolean;
@@ -100,10 +127,18 @@ export function useConnectorModelSelection(
executionReadyConnectors.map(async (connector) => [connector.id, await loadConnectorModels(connector)] as const),
);
if (cancelled) return;
setConnectorModelsById(Object.fromEntries(entries));
const next = Object.fromEntries(entries);
// Guard against no-op state updates: snapshot refreshes re-run this
// effect with the same CONNECTORS data, and returning a new object
// reference here cascades through `connectorModels` → `modelOptions`
// and collapses any open native <select> dropdown. Only call
// setConnectorModelsById when the catalog actually changed.
setConnectorModelsById((previous) =>
connectorCatalogsEqual(previous, next) ? previous : next,
);
} catch {
if (cancelled) return;
setConnectorModelsById({});
setConnectorModelsById((previous) => (Object.keys(previous).length === 0 ? previous : {}));
setSelectedModel("");
} finally {
if (!cancelled) {