@trackunit/react-form-components 1.8.128 → 1.8.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -1577,10 +1577,10 @@ autoComplete, // see https://github.com/JedWatson/react-select/issues/758
1577
1577
  return (jsxRuntime.jsx(ReactSelect.components.ClearIndicator, { ...selectClearIndicator, className: cvaSelectClearIndicator({ className: selectClearIndicator.className }), getStyles: getNoStyles, children: jsxRuntime.jsx(reactComponents.Icon, { ariaLabel: t("clearIndicator.icon.tooltip.clearAll"), "data-testid": `${dataTestId}-XMarkIcon`, name: "XCircle", size: "medium" }) }));
1578
1578
  },
1579
1579
  LoadingMessage: selectLoadingMessage => {
1580
- return (jsxRuntime.jsx(ReactSelect.components.LoadingMessage, { ...selectLoadingMessage, className: cvaSelectLoadingMessage({ className: selectLoadingMessage.className }), getStyles: getNoStyles, children: t("select.loadingMessage") }));
1580
+ return (jsxRuntime.jsx(ReactSelect.components.LoadingMessage, { ...selectLoadingMessage, className: cvaSelectLoadingMessage({ className: selectLoadingMessage.className }), getStyles: getNoStyles, children: selectLoadingMessage.children ?? t("select.loadingMessage") }));
1581
1581
  },
1582
1582
  NoOptionsMessage: selectNoOptionsMessage => {
1583
- return (jsxRuntime.jsx(ReactSelect.components.NoOptionsMessage, { ...selectNoOptionsMessage, className: cvaSelectNoOptionsMessage({ className: selectNoOptionsMessage.className }), getStyles: getNoStyles, children: t("select.noOptionsMessage") }));
1583
+ return (jsxRuntime.jsx(ReactSelect.components.NoOptionsMessage, { ...selectNoOptionsMessage, className: cvaSelectNoOptionsMessage({ className: selectNoOptionsMessage.className }), getStyles: getNoStyles, children: selectNoOptionsMessage.children ?? t("select.noOptionsMessage") }));
1584
1584
  },
1585
1585
  SingleValue: selectSingleValue => {
1586
1586
  const optionPrefix = getOptionPrefix ? getOptionPrefix(selectSingleValue.data) : null;
@@ -1696,34 +1696,55 @@ const getPlaceholderElement = (children) => {
1696
1696
  * @param {SelectProps} props - The props for the Select component
1697
1697
  * @returns {ReactSelectProps} Props for react-select component
1698
1698
  */
1699
- const useSelect = ({ disabled = false, //renaming to isDisabled, so not directly passed to react-select
1700
- "data-testid": dataTestId,
1701
- // Extract critical props that must trigger recalculation when they change
1702
- onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1703
- // restProps are pur in a ref to keep dependencies stable while always having the latest values
1704
- // See explanation on restPropsRef below for more details 👇
1705
- ...restProps }) => {
1699
+ const useSelect = (props) => {
1700
+ const { disabled = false, //renaming to isDisabled, so not directly passed to react-select
1701
+ "data-testid": dataTestId,
1702
+ // Extract critical props that must trigger recalculation when they change
1703
+ options, value, defaultValue,
1704
+ // Extract callbacks - most are passed through directly to allow recalculation when changed
1705
+ // Only onMenuOpen and onMenuClose get wrapped with ref pattern (see below)
1706
+ onMenuOpen, onMenuClose, onChange, onInputChange, onFocus, onBlur, getOptionLabel, getOptionValue, filterOption, formatOptionLabel, formatGroupLabel, isOptionDisabled, isOptionSelected, onKeyDown, onMenuScrollToTop, onMenuScrollToBottom,
1707
+ // Extract other props used in final useMemo
1708
+ label, isSearchable, openMenuOnClick, openMenuOnFocus, isMulti, isClearable, menuPlacement, placeholder, hideSelectedOptions, readOnly,
1709
+ // Extract commonly-used props to prevent them from ending up in restProps
1710
+ // This makes dependency tracking more explicit and improves stability
1711
+ id, name, inputId, isLoading, async: asyncProp, required, tabIndex, closeMenuOnScroll, escapeClearsValue, backspaceRemovesValue, noOptionsMessage, loadingMessage, controlShouldRenderValue, ...restProps } = props;
1712
+ // Deep equality memoization for restProps to achieve hook stability
1713
+ // Problem: restProps is a new object on every render, causing unnecessary recalculations
1714
+ // Solution: Use deep equality check with isEqual to only update when actual values change
1715
+ // Trade-off: Small performance cost of deep comparison vs stability benefits
1716
+ const [stableRestProps, setStableRestProps] = react.useState(restProps);
1717
+ react.useEffect(() => {
1718
+ if (!esToolkit.isEqual(stableRestProps, restProps)) {
1719
+ // setState in useEffect is intentional here for deep equality memoization
1720
+ // We only update stableRestProps when the actual values change, not just the object reference
1721
+ queueMicrotask(() => {
1722
+ setStableRestProps(restProps);
1723
+ });
1724
+ }
1725
+ }, [restProps, stableRestProps]);
1706
1726
  const customComponents = useCustomComponents({
1707
1727
  disabled, // intentionally not evaluated as boolean, since it can be object too!
1708
- readOnly: restProps.readOnly ?? false, // intentionally not evaluated as boolean, since it can be object too!
1728
+ readOnly: readOnly ?? false, // intentionally not evaluated as boolean, since it can be object too!
1709
1729
  "data-testid": dataTestId ?? "select",
1710
- prefix: restProps.prefix,
1711
- hasError: restProps.hasError,
1712
- fieldSize: restProps.fieldSize,
1713
- getOptionLabelDescription: restProps.getOptionLabelDescription,
1714
- getOptionPrefix: restProps.getOptionPrefix,
1715
- isMulti: restProps.isMulti ?? false,
1716
- className: restProps.className,
1717
- autoComplete: restProps.autoComplete,
1730
+ prefix: stableRestProps.prefix,
1731
+ hasError: stableRestProps.hasError,
1732
+ fieldSize: stableRestProps.fieldSize,
1733
+ getOptionLabelDescription: stableRestProps.getOptionLabelDescription,
1734
+ getOptionPrefix: stableRestProps.getOptionPrefix,
1735
+ isMulti: isMulti ?? false,
1736
+ className: stableRestProps.className,
1737
+ autoComplete: stableRestProps.autoComplete,
1718
1738
  });
1719
- const interactable = react.useMemo(() => !Boolean(disabled) && !Boolean(restProps.readOnly), [disabled, restProps.readOnly]);
1739
+ const interactable = react.useMemo(() => !Boolean(disabled) && !Boolean(readOnly), [disabled, readOnly]);
1720
1740
  // Determine the portal target for the menu
1721
- const portalTarget = react.useMemo(() => (restProps.menuPortalTarget !== undefined ? restProps.menuPortalTarget : document.body), [restProps.menuPortalTarget]);
1741
+ const portalTarget = react.useMemo(() => (stableRestProps.menuPortalTarget !== undefined ? stableRestProps.menuPortalTarget : document.body), [stableRestProps.menuPortalTarget]);
1722
1742
  // Use custom scroll blocking hook to prevent layout shifts
1723
1743
  // Pass the portal target so we only block scroll when menu is portaled to document.body
1724
1744
  const { blockScroll, restoreScroll } = reactComponents.useScrollBlock(portalTarget);
1725
- // Store callbacks in refs to keep wrapper callbacks stable
1726
- // This prevents unnecessary re-renders when parent components don't memoize these callbacks
1745
+ // Store only the wrapped callbacks in refs to keep them stable
1746
+ // We only do this for onMenuOpen and onMenuClose because we wrap them with additional logic
1747
+ // Other callbacks are passed through directly and should trigger recalculation when they change
1727
1748
  const onMenuOpenRef = react.useRef(onMenuOpen);
1728
1749
  const onMenuCloseRef = react.useRef(onMenuClose);
1729
1750
  react.useEffect(() => {
@@ -1735,7 +1756,7 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1735
1756
  // Wrap user's onMenuOpen callback to block scrolling
1736
1757
  // We apply scroll blocking directly instead of using react-select's menuShouldBlockScroll
1737
1758
  // because it doesn't properly account for existing body padding, causing layout shifts
1738
- // See commeont next to menuShouldBlockScroll below for more
1759
+ // See comment next to menuShouldBlockScroll below for more
1739
1760
  const handleMenuOpen = react.useCallback(() => {
1740
1761
  blockScroll();
1741
1762
  onMenuOpenRef.current?.();
@@ -1745,28 +1766,36 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1745
1766
  restoreScroll();
1746
1767
  onMenuCloseRef.current?.();
1747
1768
  }, [restoreScroll]);
1748
- // Store restProps in a ref to keep dependencies stable while always having the latest values
1749
- // This allows us to access restProps in useMemo without including them in the dependency array
1750
- // Criteria for props in restProps:
1751
- // - Props used in computations are already tracked via derived values (interactable, portalTarget, etc.)
1752
- // - Other props are accessed via ref - they'll always be latest but won't trigger recalculation
1753
- // This is a trade-off: we prioritize stability over perfect reactivity for less critical props
1754
- const restPropsRef = react.useRef(restProps);
1755
- react.useEffect(() => {
1756
- restPropsRef.current = restProps;
1757
- }, [restProps]);
1758
- // eslint-disable-next-line react-hooks/refs
1769
+ // Final memoized props object for react-select
1770
+ // Stability strategy:
1771
+ // 1. stableRestProps - deep-equality memoized to prevent reference changes
1772
+ // 2. Extracted props - explicitly listed in dependency array for clear tracking
1773
+ // 3. Callbacks - most passed through directly to allow recalculation when changed
1774
+ // 4. handleMenuOpen/handleMenuClose - wrapped with ref pattern because they contain custom logic
1759
1775
  return react.useMemo(() => {
1760
- const currentRestProps = restPropsRef.current;
1761
1776
  return {
1762
- ...currentRestProps,
1777
+ ...stableRestProps,
1778
+ // Explicitly set extracted props
1779
+ id,
1780
+ name,
1781
+ inputId,
1782
+ isLoading,
1783
+ async: asyncProp,
1784
+ required,
1785
+ tabIndex,
1786
+ closeMenuOnScroll,
1787
+ escapeClearsValue,
1788
+ backspaceRemovesValue,
1789
+ noOptionsMessage,
1790
+ loadingMessage,
1791
+ controlShouldRenderValue,
1792
+ // Core props
1763
1793
  options,
1764
1794
  value,
1765
- onChange,
1766
1795
  defaultValue,
1767
1796
  components: customComponents,
1768
1797
  unstyled: true,
1769
- "aria-label": currentRestProps.label,
1798
+ "aria-label": label,
1770
1799
  "data-testid": dataTestId ?? "select",
1771
1800
  tabSelectsValue: false,
1772
1801
  blurInputOnSelect: false,
@@ -1774,22 +1803,38 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1774
1803
  // Setting menuPortalTarget to 'null' specifies that the dropdown should be rendered within
1775
1804
  // the parent element instead of 'document.body'.
1776
1805
  menuPortalTarget: portalTarget,
1777
- isSearchable: interactable ? (currentRestProps.isSearchable ?? true) : false,
1806
+ isSearchable: interactable ? (isSearchable ?? true) : false,
1778
1807
  // Disable react-select's built-in scroll blocking as we handle it ourselves in onMenuOpen/onMenuClose
1779
1808
  // to prevent layout shifts caused by not accounting for existing body padding in the react-select implementation.
1780
1809
  // See: https://github.com/JedWatson/react-select/issues/5342 AND https://github.com/JedWatson/react-select/issues/5020
1781
1810
  menuShouldBlockScroll: false,
1782
1811
  menuShouldScrollIntoView: true,
1783
- openMenuOnClick: interactable ? (currentRestProps.openMenuOnClick ?? true) : false,
1784
- openMenuOnFocus: Boolean(currentRestProps.openMenuOnFocus),
1785
- closeMenuOnSelect: !Boolean(currentRestProps.isMulti),
1812
+ openMenuOnClick: interactable ? (openMenuOnClick ?? true) : false,
1813
+ openMenuOnFocus: Boolean(openMenuOnFocus),
1814
+ isMulti,
1815
+ closeMenuOnSelect: !Boolean(isMulti),
1786
1816
  isDisabled: Boolean(disabled),
1787
- isClearable: Boolean(currentRestProps.isClearable),
1788
- menuPlacement: currentRestProps.menuPlacement ?? "auto",
1789
- placeholder: interactable ? currentRestProps.placeholder : undefined,
1790
- hideSelectedOptions: Boolean(currentRestProps.hideSelectedOptions),
1817
+ isClearable: Boolean(isClearable),
1818
+ menuPlacement: menuPlacement ?? "auto",
1819
+ placeholder: interactable ? placeholder : undefined,
1820
+ hideSelectedOptions: Boolean(hideSelectedOptions),
1791
1821
  menuIsOpen: interactable ? undefined : false, // close it if not interactable, otherwise leave state to react-select
1792
- // Wire up our custom menu open/close handlers
1822
+ // Pass through callbacks directly (they should trigger recalculation when changed)
1823
+ onChange,
1824
+ onInputChange,
1825
+ onFocus,
1826
+ onBlur,
1827
+ getOptionLabel,
1828
+ getOptionValue,
1829
+ filterOption,
1830
+ formatOptionLabel,
1831
+ formatGroupLabel,
1832
+ isOptionDisabled,
1833
+ isOptionSelected,
1834
+ onKeyDown,
1835
+ onMenuScrollToTop,
1836
+ onMenuScrollToBottom,
1837
+ // Wire up our wrapped menu open/close handlers (with scroll blocking)
1793
1838
  onMenuOpen: handleMenuOpen,
1794
1839
  onMenuClose: handleMenuClose,
1795
1840
  // 👇 putting these here to avoid them _accidentally_ being overwritten in the future👇
@@ -1800,20 +1845,55 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1800
1845
  styles: undefined,
1801
1846
  };
1802
1847
  }, [
1848
+ stableRestProps,
1849
+ // Explicitly extracted props
1850
+ id,
1851
+ name,
1852
+ inputId,
1853
+ isLoading,
1854
+ asyncProp,
1855
+ required,
1856
+ tabIndex,
1857
+ closeMenuOnScroll,
1858
+ escapeClearsValue,
1859
+ backspaceRemovesValue,
1860
+ noOptionsMessage,
1861
+ loadingMessage,
1862
+ controlShouldRenderValue,
1863
+ // Core props
1864
+ options,
1865
+ value,
1866
+ defaultValue,
1803
1867
  customComponents,
1804
- disabled,
1805
- interactable,
1806
- handleMenuOpen,
1807
- handleMenuClose,
1868
+ label,
1808
1869
  dataTestId,
1809
1870
  portalTarget,
1810
- // Critical props that must trigger recalculation when they change
1811
- options,
1812
- value,
1871
+ interactable,
1872
+ isSearchable,
1873
+ openMenuOnClick,
1874
+ openMenuOnFocus,
1875
+ isMulti,
1876
+ disabled,
1877
+ isClearable,
1878
+ menuPlacement,
1879
+ placeholder,
1880
+ hideSelectedOptions,
1813
1881
  onChange,
1814
- defaultValue,
1815
- // restPropsRef is intentionally not included - we access restPropsRef.current inside useMemo
1816
- // to always get the latest restProps without causing recalculation when the object reference changes
1882
+ onInputChange,
1883
+ onFocus,
1884
+ onBlur,
1885
+ getOptionLabel,
1886
+ getOptionValue,
1887
+ filterOption,
1888
+ formatOptionLabel,
1889
+ formatGroupLabel,
1890
+ isOptionDisabled,
1891
+ isOptionSelected,
1892
+ onKeyDown,
1893
+ onMenuScrollToTop,
1894
+ onMenuScrollToBottom,
1895
+ handleMenuOpen,
1896
+ handleMenuClose,
1817
1897
  ]);
1818
1898
  };
1819
1899
 
@@ -1833,26 +1913,41 @@ const BaseSelect = (props) => {
1833
1913
  /**
1834
1914
  * A hook used by creatable selects that extends useSelect with creatable-specific functionality
1835
1915
  *
1916
+ * @template TOption - The type of option objects in the select
1917
+ * @template TIsAsync - Boolean flag indicating if the select is async
1918
+ * @template TIsMulti - Boolean flag indicating if the select allows multiple selections
1919
+ * @template TGroup - The group type for grouped options
1836
1920
  * @param props - The props for the CreatableSelect component
1837
- * @returns {ReactCreatableProps} Props for react-select creatable component
1921
+ * @returns {ReactCreatableProps<TOption, TIsMulti, TGroup>} Props for react-select creatable component
1838
1922
  */
1839
1923
  const useCreatableSelect = (props) => {
1840
- const { onCreateOption, allowCreateWhileLoading, isMulti } = props;
1841
- const baseSelectProps = useSelect(props);
1924
+ // Extract only creatable-specific props before passing to useSelect
1925
+ // This prevents onCreateOption from triggering recalculation in useSelect
1926
+ // Note: isMulti is NOT extracted - it needs to be passed to useSelect
1927
+ const { onCreateOption, allowCreateWhileLoading, ...selectProps } = props;
1928
+ const baseSelectProps = useSelect(selectProps);
1929
+ // Get isMulti from props for use in this hook
1930
+ const { isMulti } = props;
1842
1931
  // Store onCreateOption in a ref to keep wrapper stable
1843
1932
  // This prevents unnecessary re-renders when parent components don't memoize this callback
1844
1933
  const onCreateOptionRef = react.useRef(onCreateOption);
1845
1934
  react.useEffect(() => {
1846
1935
  onCreateOptionRef.current = onCreateOption;
1847
1936
  }, [onCreateOption]);
1937
+ // Create stable wrapper for onCreateOption callback
1938
+ const handleCreateOption = react.useCallback(inputValue => {
1939
+ onCreateOptionRef.current?.(inputValue);
1940
+ }, []);
1941
+ // Track callback existence with stable boolean
1942
+ const hasOnCreateOption = Boolean(onCreateOption);
1848
1943
  return react.useMemo(() => ({
1849
1944
  ...baseSelectProps,
1850
1945
  allowCreateWhileLoading,
1851
- onCreateOption: onCreateOptionRef.current,
1946
+ onCreateOption: hasOnCreateOption ? handleCreateOption : undefined,
1852
1947
  // Override some defaults specific to creatable selects
1853
1948
  closeMenuOnSelect: false, // Keep menu open for multi-creation
1854
1949
  blurInputOnSelect: !Boolean(isMulti), // Only blur if not multi
1855
- }), [baseSelectProps, allowCreateWhileLoading, isMulti]);
1950
+ }), [baseSelectProps, allowCreateWhileLoading, hasOnCreateOption, handleCreateOption, isMulti]);
1856
1951
  };
1857
1952
 
1858
1953
  /**
package/index.esm.js CHANGED
@@ -1576,10 +1576,10 @@ autoComplete, // see https://github.com/JedWatson/react-select/issues/758
1576
1576
  return (jsx(components.ClearIndicator, { ...selectClearIndicator, className: cvaSelectClearIndicator({ className: selectClearIndicator.className }), getStyles: getNoStyles, children: jsx(Icon, { ariaLabel: t("clearIndicator.icon.tooltip.clearAll"), "data-testid": `${dataTestId}-XMarkIcon`, name: "XCircle", size: "medium" }) }));
1577
1577
  },
1578
1578
  LoadingMessage: selectLoadingMessage => {
1579
- return (jsx(components.LoadingMessage, { ...selectLoadingMessage, className: cvaSelectLoadingMessage({ className: selectLoadingMessage.className }), getStyles: getNoStyles, children: t("select.loadingMessage") }));
1579
+ return (jsx(components.LoadingMessage, { ...selectLoadingMessage, className: cvaSelectLoadingMessage({ className: selectLoadingMessage.className }), getStyles: getNoStyles, children: selectLoadingMessage.children ?? t("select.loadingMessage") }));
1580
1580
  },
1581
1581
  NoOptionsMessage: selectNoOptionsMessage => {
1582
- return (jsx(components.NoOptionsMessage, { ...selectNoOptionsMessage, className: cvaSelectNoOptionsMessage({ className: selectNoOptionsMessage.className }), getStyles: getNoStyles, children: t("select.noOptionsMessage") }));
1582
+ return (jsx(components.NoOptionsMessage, { ...selectNoOptionsMessage, className: cvaSelectNoOptionsMessage({ className: selectNoOptionsMessage.className }), getStyles: getNoStyles, children: selectNoOptionsMessage.children ?? t("select.noOptionsMessage") }));
1583
1583
  },
1584
1584
  SingleValue: selectSingleValue => {
1585
1585
  const optionPrefix = getOptionPrefix ? getOptionPrefix(selectSingleValue.data) : null;
@@ -1695,34 +1695,55 @@ const getPlaceholderElement = (children) => {
1695
1695
  * @param {SelectProps} props - The props for the Select component
1696
1696
  * @returns {ReactSelectProps} Props for react-select component
1697
1697
  */
1698
- const useSelect = ({ disabled = false, //renaming to isDisabled, so not directly passed to react-select
1699
- "data-testid": dataTestId,
1700
- // Extract critical props that must trigger recalculation when they change
1701
- onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1702
- // restProps are pur in a ref to keep dependencies stable while always having the latest values
1703
- // See explanation on restPropsRef below for more details 👇
1704
- ...restProps }) => {
1698
+ const useSelect = (props) => {
1699
+ const { disabled = false, //renaming to isDisabled, so not directly passed to react-select
1700
+ "data-testid": dataTestId,
1701
+ // Extract critical props that must trigger recalculation when they change
1702
+ options, value, defaultValue,
1703
+ // Extract callbacks - most are passed through directly to allow recalculation when changed
1704
+ // Only onMenuOpen and onMenuClose get wrapped with ref pattern (see below)
1705
+ onMenuOpen, onMenuClose, onChange, onInputChange, onFocus, onBlur, getOptionLabel, getOptionValue, filterOption, formatOptionLabel, formatGroupLabel, isOptionDisabled, isOptionSelected, onKeyDown, onMenuScrollToTop, onMenuScrollToBottom,
1706
+ // Extract other props used in final useMemo
1707
+ label, isSearchable, openMenuOnClick, openMenuOnFocus, isMulti, isClearable, menuPlacement, placeholder, hideSelectedOptions, readOnly,
1708
+ // Extract commonly-used props to prevent them from ending up in restProps
1709
+ // This makes dependency tracking more explicit and improves stability
1710
+ id, name, inputId, isLoading, async: asyncProp, required, tabIndex, closeMenuOnScroll, escapeClearsValue, backspaceRemovesValue, noOptionsMessage, loadingMessage, controlShouldRenderValue, ...restProps } = props;
1711
+ // Deep equality memoization for restProps to achieve hook stability
1712
+ // Problem: restProps is a new object on every render, causing unnecessary recalculations
1713
+ // Solution: Use deep equality check with isEqual to only update when actual values change
1714
+ // Trade-off: Small performance cost of deep comparison vs stability benefits
1715
+ const [stableRestProps, setStableRestProps] = useState(restProps);
1716
+ useEffect(() => {
1717
+ if (!isEqual(stableRestProps, restProps)) {
1718
+ // setState in useEffect is intentional here for deep equality memoization
1719
+ // We only update stableRestProps when the actual values change, not just the object reference
1720
+ queueMicrotask(() => {
1721
+ setStableRestProps(restProps);
1722
+ });
1723
+ }
1724
+ }, [restProps, stableRestProps]);
1705
1725
  const customComponents = useCustomComponents({
1706
1726
  disabled, // intentionally not evaluated as boolean, since it can be object too!
1707
- readOnly: restProps.readOnly ?? false, // intentionally not evaluated as boolean, since it can be object too!
1727
+ readOnly: readOnly ?? false, // intentionally not evaluated as boolean, since it can be object too!
1708
1728
  "data-testid": dataTestId ?? "select",
1709
- prefix: restProps.prefix,
1710
- hasError: restProps.hasError,
1711
- fieldSize: restProps.fieldSize,
1712
- getOptionLabelDescription: restProps.getOptionLabelDescription,
1713
- getOptionPrefix: restProps.getOptionPrefix,
1714
- isMulti: restProps.isMulti ?? false,
1715
- className: restProps.className,
1716
- autoComplete: restProps.autoComplete,
1729
+ prefix: stableRestProps.prefix,
1730
+ hasError: stableRestProps.hasError,
1731
+ fieldSize: stableRestProps.fieldSize,
1732
+ getOptionLabelDescription: stableRestProps.getOptionLabelDescription,
1733
+ getOptionPrefix: stableRestProps.getOptionPrefix,
1734
+ isMulti: isMulti ?? false,
1735
+ className: stableRestProps.className,
1736
+ autoComplete: stableRestProps.autoComplete,
1717
1737
  });
1718
- const interactable = useMemo(() => !Boolean(disabled) && !Boolean(restProps.readOnly), [disabled, restProps.readOnly]);
1738
+ const interactable = useMemo(() => !Boolean(disabled) && !Boolean(readOnly), [disabled, readOnly]);
1719
1739
  // Determine the portal target for the menu
1720
- const portalTarget = useMemo(() => (restProps.menuPortalTarget !== undefined ? restProps.menuPortalTarget : document.body), [restProps.menuPortalTarget]);
1740
+ const portalTarget = useMemo(() => (stableRestProps.menuPortalTarget !== undefined ? stableRestProps.menuPortalTarget : document.body), [stableRestProps.menuPortalTarget]);
1721
1741
  // Use custom scroll blocking hook to prevent layout shifts
1722
1742
  // Pass the portal target so we only block scroll when menu is portaled to document.body
1723
1743
  const { blockScroll, restoreScroll } = useScrollBlock(portalTarget);
1724
- // Store callbacks in refs to keep wrapper callbacks stable
1725
- // This prevents unnecessary re-renders when parent components don't memoize these callbacks
1744
+ // Store only the wrapped callbacks in refs to keep them stable
1745
+ // We only do this for onMenuOpen and onMenuClose because we wrap them with additional logic
1746
+ // Other callbacks are passed through directly and should trigger recalculation when they change
1726
1747
  const onMenuOpenRef = useRef(onMenuOpen);
1727
1748
  const onMenuCloseRef = useRef(onMenuClose);
1728
1749
  useEffect(() => {
@@ -1734,7 +1755,7 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1734
1755
  // Wrap user's onMenuOpen callback to block scrolling
1735
1756
  // We apply scroll blocking directly instead of using react-select's menuShouldBlockScroll
1736
1757
  // because it doesn't properly account for existing body padding, causing layout shifts
1737
- // See commeont next to menuShouldBlockScroll below for more
1758
+ // See comment next to menuShouldBlockScroll below for more
1738
1759
  const handleMenuOpen = useCallback(() => {
1739
1760
  blockScroll();
1740
1761
  onMenuOpenRef.current?.();
@@ -1744,28 +1765,36 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1744
1765
  restoreScroll();
1745
1766
  onMenuCloseRef.current?.();
1746
1767
  }, [restoreScroll]);
1747
- // Store restProps in a ref to keep dependencies stable while always having the latest values
1748
- // This allows us to access restProps in useMemo without including them in the dependency array
1749
- // Criteria for props in restProps:
1750
- // - Props used in computations are already tracked via derived values (interactable, portalTarget, etc.)
1751
- // - Other props are accessed via ref - they'll always be latest but won't trigger recalculation
1752
- // This is a trade-off: we prioritize stability over perfect reactivity for less critical props
1753
- const restPropsRef = useRef(restProps);
1754
- useEffect(() => {
1755
- restPropsRef.current = restProps;
1756
- }, [restProps]);
1757
- // eslint-disable-next-line react-hooks/refs
1768
+ // Final memoized props object for react-select
1769
+ // Stability strategy:
1770
+ // 1. stableRestProps - deep-equality memoized to prevent reference changes
1771
+ // 2. Extracted props - explicitly listed in dependency array for clear tracking
1772
+ // 3. Callbacks - most passed through directly to allow recalculation when changed
1773
+ // 4. handleMenuOpen/handleMenuClose - wrapped with ref pattern because they contain custom logic
1758
1774
  return useMemo(() => {
1759
- const currentRestProps = restPropsRef.current;
1760
1775
  return {
1761
- ...currentRestProps,
1776
+ ...stableRestProps,
1777
+ // Explicitly set extracted props
1778
+ id,
1779
+ name,
1780
+ inputId,
1781
+ isLoading,
1782
+ async: asyncProp,
1783
+ required,
1784
+ tabIndex,
1785
+ closeMenuOnScroll,
1786
+ escapeClearsValue,
1787
+ backspaceRemovesValue,
1788
+ noOptionsMessage,
1789
+ loadingMessage,
1790
+ controlShouldRenderValue,
1791
+ // Core props
1762
1792
  options,
1763
1793
  value,
1764
- onChange,
1765
1794
  defaultValue,
1766
1795
  components: customComponents,
1767
1796
  unstyled: true,
1768
- "aria-label": currentRestProps.label,
1797
+ "aria-label": label,
1769
1798
  "data-testid": dataTestId ?? "select",
1770
1799
  tabSelectsValue: false,
1771
1800
  blurInputOnSelect: false,
@@ -1773,22 +1802,38 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1773
1802
  // Setting menuPortalTarget to 'null' specifies that the dropdown should be rendered within
1774
1803
  // the parent element instead of 'document.body'.
1775
1804
  menuPortalTarget: portalTarget,
1776
- isSearchable: interactable ? (currentRestProps.isSearchable ?? true) : false,
1805
+ isSearchable: interactable ? (isSearchable ?? true) : false,
1777
1806
  // Disable react-select's built-in scroll blocking as we handle it ourselves in onMenuOpen/onMenuClose
1778
1807
  // to prevent layout shifts caused by not accounting for existing body padding in the react-select implementation.
1779
1808
  // See: https://github.com/JedWatson/react-select/issues/5342 AND https://github.com/JedWatson/react-select/issues/5020
1780
1809
  menuShouldBlockScroll: false,
1781
1810
  menuShouldScrollIntoView: true,
1782
- openMenuOnClick: interactable ? (currentRestProps.openMenuOnClick ?? true) : false,
1783
- openMenuOnFocus: Boolean(currentRestProps.openMenuOnFocus),
1784
- closeMenuOnSelect: !Boolean(currentRestProps.isMulti),
1811
+ openMenuOnClick: interactable ? (openMenuOnClick ?? true) : false,
1812
+ openMenuOnFocus: Boolean(openMenuOnFocus),
1813
+ isMulti,
1814
+ closeMenuOnSelect: !Boolean(isMulti),
1785
1815
  isDisabled: Boolean(disabled),
1786
- isClearable: Boolean(currentRestProps.isClearable),
1787
- menuPlacement: currentRestProps.menuPlacement ?? "auto",
1788
- placeholder: interactable ? currentRestProps.placeholder : undefined,
1789
- hideSelectedOptions: Boolean(currentRestProps.hideSelectedOptions),
1816
+ isClearable: Boolean(isClearable),
1817
+ menuPlacement: menuPlacement ?? "auto",
1818
+ placeholder: interactable ? placeholder : undefined,
1819
+ hideSelectedOptions: Boolean(hideSelectedOptions),
1790
1820
  menuIsOpen: interactable ? undefined : false, // close it if not interactable, otherwise leave state to react-select
1791
- // Wire up our custom menu open/close handlers
1821
+ // Pass through callbacks directly (they should trigger recalculation when changed)
1822
+ onChange,
1823
+ onInputChange,
1824
+ onFocus,
1825
+ onBlur,
1826
+ getOptionLabel,
1827
+ getOptionValue,
1828
+ filterOption,
1829
+ formatOptionLabel,
1830
+ formatGroupLabel,
1831
+ isOptionDisabled,
1832
+ isOptionSelected,
1833
+ onKeyDown,
1834
+ onMenuScrollToTop,
1835
+ onMenuScrollToBottom,
1836
+ // Wire up our wrapped menu open/close handlers (with scroll blocking)
1792
1837
  onMenuOpen: handleMenuOpen,
1793
1838
  onMenuClose: handleMenuClose,
1794
1839
  // 👇 putting these here to avoid them _accidentally_ being overwritten in the future👇
@@ -1799,20 +1844,55 @@ onMenuOpen, onMenuClose, options, value, onChange, defaultValue,
1799
1844
  styles: undefined,
1800
1845
  };
1801
1846
  }, [
1847
+ stableRestProps,
1848
+ // Explicitly extracted props
1849
+ id,
1850
+ name,
1851
+ inputId,
1852
+ isLoading,
1853
+ asyncProp,
1854
+ required,
1855
+ tabIndex,
1856
+ closeMenuOnScroll,
1857
+ escapeClearsValue,
1858
+ backspaceRemovesValue,
1859
+ noOptionsMessage,
1860
+ loadingMessage,
1861
+ controlShouldRenderValue,
1862
+ // Core props
1863
+ options,
1864
+ value,
1865
+ defaultValue,
1802
1866
  customComponents,
1803
- disabled,
1804
- interactable,
1805
- handleMenuOpen,
1806
- handleMenuClose,
1867
+ label,
1807
1868
  dataTestId,
1808
1869
  portalTarget,
1809
- // Critical props that must trigger recalculation when they change
1810
- options,
1811
- value,
1870
+ interactable,
1871
+ isSearchable,
1872
+ openMenuOnClick,
1873
+ openMenuOnFocus,
1874
+ isMulti,
1875
+ disabled,
1876
+ isClearable,
1877
+ menuPlacement,
1878
+ placeholder,
1879
+ hideSelectedOptions,
1812
1880
  onChange,
1813
- defaultValue,
1814
- // restPropsRef is intentionally not included - we access restPropsRef.current inside useMemo
1815
- // to always get the latest restProps without causing recalculation when the object reference changes
1881
+ onInputChange,
1882
+ onFocus,
1883
+ onBlur,
1884
+ getOptionLabel,
1885
+ getOptionValue,
1886
+ filterOption,
1887
+ formatOptionLabel,
1888
+ formatGroupLabel,
1889
+ isOptionDisabled,
1890
+ isOptionSelected,
1891
+ onKeyDown,
1892
+ onMenuScrollToTop,
1893
+ onMenuScrollToBottom,
1894
+ handleMenuOpen,
1895
+ handleMenuClose,
1816
1896
  ]);
1817
1897
  };
1818
1898
 
@@ -1832,26 +1912,41 @@ const BaseSelect = (props) => {
1832
1912
  /**
1833
1913
  * A hook used by creatable selects that extends useSelect with creatable-specific functionality
1834
1914
  *
1915
+ * @template TOption - The type of option objects in the select
1916
+ * @template TIsAsync - Boolean flag indicating if the select is async
1917
+ * @template TIsMulti - Boolean flag indicating if the select allows multiple selections
1918
+ * @template TGroup - The group type for grouped options
1835
1919
  * @param props - The props for the CreatableSelect component
1836
- * @returns {ReactCreatableProps} Props for react-select creatable component
1920
+ * @returns {ReactCreatableProps<TOption, TIsMulti, TGroup>} Props for react-select creatable component
1837
1921
  */
1838
1922
  const useCreatableSelect = (props) => {
1839
- const { onCreateOption, allowCreateWhileLoading, isMulti } = props;
1840
- const baseSelectProps = useSelect(props);
1923
+ // Extract only creatable-specific props before passing to useSelect
1924
+ // This prevents onCreateOption from triggering recalculation in useSelect
1925
+ // Note: isMulti is NOT extracted - it needs to be passed to useSelect
1926
+ const { onCreateOption, allowCreateWhileLoading, ...selectProps } = props;
1927
+ const baseSelectProps = useSelect(selectProps);
1928
+ // Get isMulti from props for use in this hook
1929
+ const { isMulti } = props;
1841
1930
  // Store onCreateOption in a ref to keep wrapper stable
1842
1931
  // This prevents unnecessary re-renders when parent components don't memoize this callback
1843
1932
  const onCreateOptionRef = useRef(onCreateOption);
1844
1933
  useEffect(() => {
1845
1934
  onCreateOptionRef.current = onCreateOption;
1846
1935
  }, [onCreateOption]);
1936
+ // Create stable wrapper for onCreateOption callback
1937
+ const handleCreateOption = useCallback(inputValue => {
1938
+ onCreateOptionRef.current?.(inputValue);
1939
+ }, []);
1940
+ // Track callback existence with stable boolean
1941
+ const hasOnCreateOption = Boolean(onCreateOption);
1847
1942
  return useMemo(() => ({
1848
1943
  ...baseSelectProps,
1849
1944
  allowCreateWhileLoading,
1850
- onCreateOption: onCreateOptionRef.current,
1945
+ onCreateOption: hasOnCreateOption ? handleCreateOption : undefined,
1851
1946
  // Override some defaults specific to creatable selects
1852
1947
  closeMenuOnSelect: false, // Keep menu open for multi-creation
1853
1948
  blurInputOnSelect: !Boolean(isMulti), // Only blur if not multi
1854
- }), [baseSelectProps, allowCreateWhileLoading, isMulti]);
1949
+ }), [baseSelectProps, allowCreateWhileLoading, hasOnCreateOption, handleCreateOption, isMulti]);
1855
1950
  };
1856
1951
 
1857
1952
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-form-components",
3
- "version": "1.8.128",
3
+ "version": "1.8.132",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -14,12 +14,12 @@
14
14
  "zod": "^3.23.8",
15
15
  "react-hook-form": "7.62.0",
16
16
  "tailwind-merge": "^2.0.0",
17
- "@trackunit/css-class-variance-utilities": "1.7.84",
18
- "@trackunit/react-components": "1.10.59",
19
- "@trackunit/ui-icons": "1.7.85",
20
- "@trackunit/shared-utils": "1.9.84",
21
- "@trackunit/ui-design-tokens": "1.7.84",
22
- "@trackunit/i18n-library-translation": "1.7.102",
17
+ "@trackunit/css-class-variance-utilities": "1.7.86",
18
+ "@trackunit/react-components": "1.10.61",
19
+ "@trackunit/ui-icons": "1.7.87",
20
+ "@trackunit/shared-utils": "1.9.86",
21
+ "@trackunit/ui-design-tokens": "1.7.86",
22
+ "@trackunit/i18n-library-translation": "1.7.104",
23
23
  "string-ts": "^2.0.0",
24
24
  "@js-temporal/polyfill": "^0.5.1",
25
25
  "es-toolkit": "^1.39.10",
@@ -4,7 +4,11 @@ import { CreatableSelectProps } from "./CreatableSelect";
4
4
  /**
5
5
  * A hook used by creatable selects that extends useSelect with creatable-specific functionality
6
6
  *
7
+ * @template TOption - The type of option objects in the select
8
+ * @template TIsAsync - Boolean flag indicating if the select is async
9
+ * @template TIsMulti - Boolean flag indicating if the select allows multiple selections
10
+ * @template TGroup - The group type for grouped options
7
11
  * @param props - The props for the CreatableSelect component
8
- * @returns {ReactCreatableProps} Props for react-select creatable component
12
+ * @returns {ReactCreatableProps<TOption, TIsMulti, TGroup>} Props for react-select creatable component
9
13
  */
10
14
  export declare const useCreatableSelect: <TOption, TIsAsync extends boolean = false, TIsMulti extends boolean = false, TGroup extends GroupBase<TOption> = GroupBase<TOption>>(props: CreatableSelectProps<TOption, TIsAsync, TIsMulti, TGroup>) => ReactCreatableProps<TOption, TIsMulti, TGroup>;
@@ -134,4 +134,4 @@ export type SelectProps<TOption, TIsAsync extends boolean = false, TIsMulti exte
134
134
  * @param {SelectProps} props - The props for the Select component
135
135
  * @returns {ReactSelectProps} Props for react-select component
136
136
  */
137
- export declare const useSelect: <TOption, TIsAsync extends boolean = false, TIsMulti extends boolean = false, TGroup extends GroupBase<TOption> = GroupBase<TOption>>({ disabled, "data-testid": dataTestId, onMenuOpen, onMenuClose, options, value, onChange, defaultValue, ...restProps }: SelectProps<TOption, TIsAsync, TIsMulti, TGroup>) => ReactSelectProps<TOption, TIsMulti, TGroup>;
137
+ export declare const useSelect: <TOption, TIsAsync extends boolean = false, TIsMulti extends boolean = false, TGroup extends GroupBase<TOption> = GroupBase<TOption>>(props: SelectProps<TOption, TIsAsync, TIsMulti, TGroup>) => ReactSelectProps<TOption, TIsMulti, TGroup>;