From d51256df6fa29923f3131f933dba7614decc5ca8 Mon Sep 17 00:00:00 2001 From: OpenClaude Worker 3 Date: Wed, 8 Apr 2026 13:58:18 +0530 Subject: [PATCH] fix: replace isDeepStrictEqual with navigation-aware options comparison The select cursor highlight was broken because isDeepStrictEqual in use-select-navigation.ts and use-multi-select-state.ts would fail when options contained identity-unstable properties (JSX label elements, function onChange callbacks, computed disabled booleans). This caused the reset logic to fire on every re-render, resetting focusedValue back to the first option. Replace isDeepStrictEqual with optionsNavigateEqual which only compares properties that affect navigation behavior: value, disabled, and type. ReactNode labels and function callbacks are intentionally excluded as they are identity-unstable but don't change navigation semantics. Fixes #472 --- .../CustomSelect/use-multi-select-state.ts | 4 +-- .../CustomSelect/use-select-navigation.ts | 28 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/CustomSelect/use-multi-select-state.ts b/src/components/CustomSelect/use-multi-select-state.ts index bf2bd8b3..f8d5a042 100644 --- a/src/components/CustomSelect/use-multi-select-state.ts +++ b/src/components/CustomSelect/use-multi-select-state.ts @@ -1,5 +1,4 @@ import { useCallback, useState } from 'react' -import { isDeepStrictEqual } from 'util' import { useRegisterOverlay } from '../../context/overlayContext.js' import type { InputEvent } from '../../ink/events/input-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw space/arrow multiselect input @@ -9,6 +8,7 @@ import { normalizeFullWidthSpace, } from '../../utils/stringUtils.js' import type { OptionWithDescription } from './select.js' +import { optionsNavigateEqual } from './use-select-navigation.js' import { useSelectNavigation } from './use-select-navigation.js' export type UseMultiSelectStateProps = { @@ -174,7 +174,7 @@ export function useMultiSelectState({ // and the deleted ui/useMultiSelectState.ts — without this, MCPServerDesktopImportDialog // keeps colliding servers checked after getAllMcpConfigs() resolves. const [lastOptions, setLastOptions] = useState(options) - if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) { + if (options !== lastOptions && !optionsNavigateEqual(options, lastOptions)) { setSelectedValues(defaultValue) setLastOptions(options) } diff --git a/src/components/CustomSelect/use-select-navigation.ts b/src/components/CustomSelect/use-select-navigation.ts index 544bbfa7..441895a0 100644 --- a/src/components/CustomSelect/use-select-navigation.ts +++ b/src/components/CustomSelect/use-select-navigation.ts @@ -6,10 +6,34 @@ import { useRef, useState, } from 'react' -import { isDeepStrictEqual } from 'util' import OptionMap from './option-map.js' import type { OptionWithDescription } from './select.js' +/** + * Compare two option arrays for structural equality on properties that + * affect navigation behavior. ReactNode `label` and function `onChange` + * are intentionally excluded — they are identity-unstable (new reference + * each render) but don't change navigation semantics. + */ +export function optionsNavigateEqual( + a: OptionWithDescription[], + b: OptionWithDescription[], +): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + const ao = a[i]! + const bo = b[i]! + if ( + ao.value !== bo.value || + ao.disabled !== bo.disabled || + ao.type !== bo.type + ) { + return false + } + } + return true +} + type State = { /** * Map where key is option's value and value is option's index. @@ -524,7 +548,7 @@ export function useSelectNavigation({ const [lastOptions, setLastOptions] = useState(options) - if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) { + if (options !== lastOptions && !optionsNavigateEqual(options, lastOptions)) { dispatch({ type: 'reset', state: createDefaultState({