From ac441460c709ec0d44632aeaf7ef4d6151717e28 Mon Sep 17 00:00:00 2001 From: ypatios <21248330+ypatios@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:49:32 +0100 Subject: [PATCH 1/5] fix dropdown menu jumping during table polling Fixes #1506 Solution description: stable useMemo/useCallback + restore fast synchronous Select path for static lists. Removed artificial 1000 ms timeout. --- src/components/shared/DropDown.tsx | 67 ++++++++++++++++++------------ 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 66971b59ac..f82805d64c 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -10,6 +10,7 @@ import { ParseKeys } from "i18next"; import { FixedSizeList, ListChildComponentProps } from "react-window"; import AsyncSelect from "react-select/async"; import AsyncCreatableSelect from "react-select/async-creatable"; +import Select from "react-select"; // ← NEW (only used for static dropdowns) export type DropDownOption = { label: string, @@ -74,6 +75,32 @@ const DropDown = ({ const style = dropDownStyle(customCSS ?? {}); + // ────────────────────────────────────────────────────────────── + // STABLE REFERENCES (the fix for #1506) + // Prevents react-select from remounting the menu on every table poll. + // ────────────────────────────────────────────────────────────── + const formattedOptions = useMemo(() => { + return options ? formatOptions(options, required) : []; + }, [options, required]); + + const loadOptions = useCallback(( + _inputValue: string, + callback: (options: DropDownOption[]) => void, + ) => { + callback(formatOptions(filterOptions(_inputValue), required)); + }, [options, required]); + + const loadOptionsAsync = useCallback(( + inputValue: string, + callback: (options: DropDownOption[]) => void, + ) => { + if (!fetchOptions) return; + fetchOptions(inputValue).then((fetched) => { + callback(formatOptions(fetched, required)); + }); + }, [fetchOptions, required]); + // ────────────────────────────────────────────────────────────── + useEffect(() => { // Ensure menu has focus when opened programmatically if (menuIsOpen) { @@ -166,23 +193,6 @@ const DropDown = ({ return []; }; - const loadOptionsAsync = (inputValue: string, callback: (options: DropDownOption[]) => void) => { - setTimeout(async () => { - callback(formatOptions( - fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), - required, - )); - }, 1000); - }; - - const loadOptions = ( - _inputValue: string, - callback: (options: DropDownOption[]) => void, - ) => { - callback(formatOptions(filterOptions(_inputValue), required)); - }; - - const commonProps: Props = { tabIndex: tabIndex, theme: theme => (dropDownSpacingTheme(theme)), @@ -191,14 +201,9 @@ const DropDown = ({ autoFocus: autoFocus, isSearchable: true, value: { value: value, label: text === "" ? placeholder : text }, - defaultOptions: options - ? formatOptions( - options, - required, - ) - : true, + defaultOptions: formattedOptions, // ← now stable (was calling formatOptions every render) cacheOptions: true, - loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, + loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, // ← now stable placeholder: placeholder, onChange: element => handleChange(element as {value: T, label: string}), menuIsOpen: menuIsOpen, @@ -217,13 +222,23 @@ const DropDown = ({ ref={selectRef} {...commonProps} /> - ) : ( + ) : fetchOptions ? ( + // Async path – ONLY used for Series (unchanged behaviour) t("SELECT_NO_MATCHING_RESULTS")} /> + ) : ( + // Synchronous path for static dropdowns (Workflow, License, Language, etc.) + Date: Sun, 8 Mar 2026 13:33:34 +0100 Subject: [PATCH 3/5] fix additional warnings --- src/components/shared/DropDown.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 3997b6af1f..56d42ecf96 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -10,7 +10,7 @@ import { ParseKeys } from "i18next"; import { FixedSizeList, ListChildComponentProps } from "react-window"; import AsyncSelect from "react-select/async"; import AsyncCreatableSelect from "react-select/async-creatable"; -import Select from "react-select"; // ← NEW (only for static dropdowns) +import Select from "react-select"; export type DropDownOption = { label: string, @@ -76,14 +76,13 @@ const DropDown = ({ const style = dropDownStyle(customCSS ?? {}); // ────────────────────────────────────────────────────────────── - // Helper functions (moved BEFORE hooks so ESLint can see them) + // Memoized helpers (required by exhaustive-deps) // ────────────────────────────────────────────────────────────── - const formatOptions = ( + const formatOptions = useCallback(( unformattedOptions: DropDownOption[], required: boolean, ) => { - // Translate - // Translating is expensive, skip it if it is not required + // Translate (expensive, skip if not required) if (!skipTranslate) { unformattedOptions = unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); } @@ -122,20 +121,20 @@ const DropDown = ({ } return unformattedOptions; - }; + }, [t, skipTranslate]); - const filterOptions = (inputValue: string) => { + const filterOptions = useCallback((inputValue: string) => { if (options) { return options.filter(option => option.label.toLowerCase().includes(inputValue.toLowerCase()), ); } return []; - }; + }, [options]); // ────────────────────────────────────────────────────────────── // STABLE REFERENCES — the fix for #1506 (no more jumping) - // All exhaustive-deps now satisfied + // All exhaustive-deps satisfied, CI-clean // ────────────────────────────────────────────────────────────── const formattedOptions = useMemo(() => { return options ? formatOptions(options, required) : []; @@ -155,7 +154,7 @@ const DropDown = ({ if (!fetchOptions) { return; } - fetchOptions(inputValue).then((fetched) => { + fetchOptions(inputValue).then(fetched => { // ← arrow-parens fixed callback(formatOptions(fetched, required)); }); }, [fetchOptions, required, formatOptions]); @@ -198,7 +197,7 @@ const DropDown = ({ ) : null; }; - const commonProps: Props = { + const commonProps: Props = { tabIndex: tabIndex, theme: theme => (dropDownSpacingTheme(theme)), styles: style, @@ -206,9 +205,9 @@ const DropDown = ({ autoFocus: autoFocus, isSearchable: true, value: { value: value, label: text === "" ? placeholder : text }, - defaultOptions: formattedOptions, // ← now stable + defaultOptions: formattedOptions, cacheOptions: true, - loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, // ← now stable + loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, placeholder: placeholder, onChange: element => handleChange(element as {value: T, label: string}), menuIsOpen: menuIsOpen, From 62d698f149d830b6b54655ac7e9212aacd6d22b1 Mon Sep 17 00:00:00 2001 From: ypatios <21248330+ypatios@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:31:07 +0100 Subject: [PATCH 4/5] expand component stabilisation --- src/components/shared/DropDown.tsx | 106 ++++++++++++++++++----------- 1 file changed, 66 insertions(+), 40 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 56d42ecf96..32a557f9ef 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -76,7 +76,7 @@ const DropDown = ({ const style = dropDownStyle(customCSS ?? {}); // ────────────────────────────────────────────────────────────── - // Memoized helpers (required by exhaustive-deps) + // Stable helpers // ────────────────────────────────────────────────────────────── const formatOptions = useCallback(( unformattedOptions: DropDownOption[], @@ -132,10 +132,6 @@ const DropDown = ({ return []; }, [options]); - // ────────────────────────────────────────────────────────────── - // STABLE REFERENCES — the fix for #1506 (no more jumping) - // All exhaustive-deps satisfied, CI-clean - // ────────────────────────────────────────────────────────────── const formattedOptions = useMemo(() => { return options ? formatOptions(options, required) : []; }, [options, required, formatOptions]); @@ -151,33 +147,20 @@ const DropDown = ({ inputValue: string, callback: (options: DropDownOption[]) => void, ) => { - if (!fetchOptions) { - return; - } - fetchOptions(inputValue).then(fetched => { // ← arrow-parens fixed + if (!fetchOptions) return; + fetchOptions(inputValue).then(fetched => { callback(formatOptions(fetched, required)); }); }, [fetchOptions, required, formatOptions]); - // ────────────────────────────────────────────────────────────── - - useEffect(() => { - // Ensure menu has focus when opened programmatically - if (menuIsOpen) { - selectRef.current?.focus(); - } - }, [menuIsOpen, selectRef]); - - const openMenu = (open: boolean) => { - if (handleMenuIsOpen !== undefined) { - handleMenuIsOpen(open); - } - }; + // ────────────────────────────────────────────────────────────── + // Full stabilization — this stops the jump (MenuList, commonProps, value, callbacks) + // ────────────────────────────────────────────────────────────── const itemHeight = optionHeight; /** * Custom component for list virtualization */ - const MenuList = (props: MenuListProps) => { + const MenuList = useMemo(() => (props: MenuListProps) => { const { children, maxHeight } = props; console.log("Menu List render"); @@ -195,31 +178,76 @@ const DropDown = ({ ) : null; - }; + }, [itemHeight]); + + const selectValue = useMemo(() => ({ + value, + label: text === "" ? placeholder : text, + }), [value, text, placeholder]); - const commonProps: Props = { - tabIndex: tabIndex, - theme: theme => (dropDownSpacingTheme(theme)), + const onChangeCallback = useCallback((element: any) => { // kept internal cast pattern from original file + handleChange(element as {value: T, label: string}); + }, [handleChange]); + + const openMenuCallback = useCallback((open: boolean) => { + if (handleMenuIsOpen !== undefined) { + handleMenuIsOpen(open); + } + }, [handleMenuIsOpen]); + + const commonProps = useMemo(() => ({ + tabIndex, + theme: theme => dropDownSpacingTheme(theme), // no type — matches original file exactly styles: style, defaultMenuIsOpen: defaultOpen, - autoFocus: autoFocus, + autoFocus, isSearchable: true, - value: { value: value, label: text === "" ? placeholder : text }, + value: selectValue, defaultOptions: formattedOptions, cacheOptions: true, loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, - placeholder: placeholder, - onChange: element => handleChange(element as {value: T, label: string}), - menuIsOpen: menuIsOpen, - onMenuOpen: () => openMenu(true), - onMenuClose: () => openMenu(false), + placeholder, + onChange: onChangeCallback, + menuIsOpen, + onMenuOpen: openMenuCallback, + onMenuClose: openMenuCallback, isDisabled: disabled, - openMenuOnFocus: openMenuOnFocus, + openMenuOnFocus, menuPlacement: menuPlacement ?? "auto", + components: { MenuList }, + }), [ + tabIndex, style, defaultOpen, autoFocus, selectValue, formattedOptions, + placeholder, onChangeCallback, menuIsOpen, openMenuCallback, disabled, + openMenuOnFocus, menuPlacement, fetchOptions, loadOptionsAsync, loadOptions, MenuList, + ]); + + useEffect(() => { + if (menuIsOpen) { + selectRef.current?.focus(); + } + }, [menuIsOpen, selectRef]); - // @ts-expect-error: React-Select typing does not account for the typing of option it itself requires + const commonProps = useMemo(() => ({ + tabIndex, + theme: theme => dropDownSpacingTheme(theme), + styles: style, + defaultMenuIsOpen: defaultOpen, + autoFocus, + isSearchable: true, + value: selectValue, + defaultOptions: formattedOptions, + cacheOptions: true, + loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, + placeholder, + onChange: onChangeCallback, + menuIsOpen, + onMenuOpen: openMenuCallback, + onMenuClose: openMenuCallback, + isDisabled: disabled, + openMenuOnFocus, + menuPlacement: menuPlacement ?? "auto", components: { MenuList }, - }; + }), [tabIndex, style, defaultOpen, autoFocus, selectValue, formattedOptions, placeholder, onChangeCallback, menuIsOpen, openMenuCallback, disabled, openMenuOnFocus, menuPlacement, fetchOptions, loadOptionsAsync, loadOptions, MenuList]); return creatable ? ( ({ {...commonProps} /> ) : fetchOptions ? ( - // Async path – ONLY used for Series ({ noOptionsMessage={() => t("SELECT_NO_MATCHING_RESULTS")} /> ) : ( - // Synchronous path – stops the jump during table polling