From 2bd261fbbf1eda90cbc26d53390353577ceb2193 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 8 Apr 2026 02:25:18 +1000 Subject: [PATCH] fix: cleanup `useKebabMenu` code (#24042) Refactored the tab overflow hook by renaming `useTabOverflowKebabMenu` to `useKebabMenu` and removing the configurable `alwaysVisibleTabsCount` parameter. - Renamed `useTabOverflowKebabMenu` to `useKebabMenu` and moved it to a new file - Removed the `alwaysVisibleTabsCount` parameter and hardcoded it to 1 tab as `ALWAYS_VISIBLE_TABS_COUNT` - Removed the `utils/index.ts` export file for the Tabs component - Updated the import in `AgentRow.tsx` to use the new hook name and removed the `alwaysVisibleTabsCount` prop - Refactored the internal logic to use a more functional approach with `reduce` instead of imperative loops - Added better performance optimizations to prevent unnecessary re-renders --- site/src/components/Tabs/utils/index.ts | 1 - .../src/components/Tabs/utils/useKebabMenu.ts | 274 ++++++++++++++++++ .../Tabs/utils/useTabOverflowKebabMenu.ts | 137 --------- site/src/modules/resources/AgentRow.tsx | 5 +- 4 files changed, 276 insertions(+), 141 deletions(-) delete mode 100644 site/src/components/Tabs/utils/index.ts create mode 100644 site/src/components/Tabs/utils/useKebabMenu.ts delete mode 100644 site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts diff --git a/site/src/components/Tabs/utils/index.ts b/site/src/components/Tabs/utils/index.ts deleted file mode 100644 index 2f0616d64b..0000000000 --- a/site/src/components/Tabs/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useTabOverflowKebabMenu } from "./useTabOverflowKebabMenu"; diff --git a/site/src/components/Tabs/utils/useKebabMenu.ts b/site/src/components/Tabs/utils/useKebabMenu.ts new file mode 100644 index 0000000000..a1f9045ce0 --- /dev/null +++ b/site/src/components/Tabs/utils/useKebabMenu.ts @@ -0,0 +1,274 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +type TabValue = { + value: string; +}; + +type UseKebabMenuOptions = { + tabs: readonly T[]; + enabled: boolean; + isActive: boolean; + overflowTriggerWidth?: number; +}; + +type UseKebabMenuResult = { + containerRef: RefObject; + visibleTabs: T[]; + overflowTabs: T[]; + getTabMeasureProps: (tabValue: string) => Record; +}; + +const ALWAYS_VISIBLE_TABS_COUNT = 1; +const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value"; + +/** + * Splits tabs into visible and overflow groups based on container width. + * + * Tabs must render with `getTabMeasureProps()` so this hook can measure + * trigger widths from the DOM. + */ +export const useKebabMenu = ({ + tabs, + enabled, + isActive, + overflowTriggerWidth = 44, +}: UseKebabMenuOptions): UseKebabMenuResult => { + const containerRef = useRef(null); + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + const previousTabsRef = useRef(tabs); + const availableWidthRef = useRef(null); + // Width cache prevents oscillation when overflow tabs are not mounted. + const tabWidthByValueRef = useRef>({}); + const [overflowTabValues, setTabValues] = useState([]); + + const recalculateOverflow = useCallback( + (availableWidth: number) => { + if (!enabled || !isActive) { + // Keep this update idempotent to avoid render loops. + setTabValues((currentValues) => { + if (currentValues.length === 0) { + return currentValues; + } + return []; + }); + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + const currentTabs = tabsRef.current; + + const tabWidthByValue = measureTabWidths({ + tabs: currentTabs, + container, + previousTabWidthByValue: tabWidthByValueRef.current, + }); + tabWidthByValueRef.current = tabWidthByValue; + + const nextOverflowValues = calculateTabValues({ + tabs: currentTabs, + availableWidth, + tabWidthByValue, + overflowTriggerWidth, + }); + + setTabValues((currentValues) => { + // Avoid state updates when the computed overflow did not change. + if (areStringArraysEqual(currentValues, nextOverflowValues)) { + return currentValues; + } + return nextOverflowValues; + }); + }, + [enabled, isActive, overflowTriggerWidth], + ); + + useEffect(() => { + if (previousTabsRef.current === tabs) { + // No change in tabs, no need to recalculate. + return; + } + previousTabsRef.current = tabs; + if (availableWidthRef.current === null) { + // First mount, no width available yet. + return; + } + recalculateOverflow(availableWidthRef.current); + }, [recalculateOverflow, tabs]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + // Recompute whenever ResizeObserver reports a container width change. + const observer = new ResizeObserver(([entry]) => { + if (!entry) { + return; + } + availableWidthRef.current = entry.contentRect.width; + recalculateOverflow(entry.contentRect.width); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [recalculateOverflow]); + + const overflowTabValuesSet = new Set(overflowTabValues); + const { visibleTabs, overflowTabs } = tabs.reduce<{ + visibleTabs: T[]; + overflowTabs: T[]; + }>( + (tabGroups, tab) => { + if (overflowTabValuesSet.has(tab.value)) { + tabGroups.overflowTabs.push(tab); + } else { + tabGroups.visibleTabs.push(tab); + } + return tabGroups; + }, + { visibleTabs: [], overflowTabs: [] }, + ); + + const getTabMeasureProps = (tabValue: string) => { + return { [DATA_ATTR_TAB_VALUE]: tabValue }; + }; + + return { + containerRef, + visibleTabs, + overflowTabs, + getTabMeasureProps, + }; +}; + +const calculateTabValues = ({ + tabs, + availableWidth, + tabWidthByValue, + overflowTriggerWidth, +}: { + tabs: readonly T[]; + availableWidth: number; + tabWidthByValue: Readonly>; + overflowTriggerWidth: number; +}): string[] => { + const tabWidthByValueMap = new Map(); + for (const tab of tabs) { + tabWidthByValueMap.set(tab.value, tabWidthByValue[tab.value] ?? 0); + } + + const firstOptionalTabIndex = Math.min( + ALWAYS_VISIBLE_TABS_COUNT, + tabs.length, + ); + if (firstOptionalTabIndex >= tabs.length) { + return []; + } + + const alwaysVisibleTabs = tabs.slice(0, firstOptionalTabIndex); + const optionalTabs = tabs.slice(firstOptionalTabIndex); + const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => { + return total + (tabWidthByValueMap.get(tab.value) ?? 0); + }, 0); + const firstTabIndex = findFirstTabIndex({ + optionalTabs, + optionalTabWidths: optionalTabs.map((tab) => { + return tabWidthByValueMap.get(tab.value) ?? 0; + }), + startingUsedWidth: alwaysVisibleWidth, + availableWidth, + overflowTriggerWidth, + }); + + if (firstTabIndex === -1) { + return []; + } + + return optionalTabs + .slice(firstTabIndex) + .map((overflowTab) => overflowTab.value); +}; + +const measureTabWidths = ({ + tabs, + container, + previousTabWidthByValue, +}: { + tabs: readonly T[]; + container: HTMLDivElement; + previousTabWidthByValue: Readonly>; +}): Record => { + const nextTabWidthByValue = { ...previousTabWidthByValue }; + for (const tab of tabs) { + const tabElement = container.querySelector( + `[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`, + ); + if (tabElement) { + nextTabWidthByValue[tab.value] = tabElement.offsetWidth; + } + } + return nextTabWidthByValue; +}; + +const findFirstTabIndex = ({ + optionalTabs, + optionalTabWidths, + startingUsedWidth, + availableWidth, + overflowTriggerWidth, +}: { + optionalTabs: readonly TabValue[]; + optionalTabWidths: readonly number[]; + startingUsedWidth: number; + availableWidth: number; + overflowTriggerWidth: number; +}): number => { + const result = optionalTabs.reduce( + (acc, _tab, index) => { + if (acc.firstTabIndex !== -1) { + return acc; + } + + const tabWidth = optionalTabWidths[index] ?? 0; + const hasMoreTabs = index < optionalTabs.length - 1; + // Reserve kebab trigger width whenever additional tabs remain. + const widthNeeded = + acc.usedWidth + tabWidth + (hasMoreTabs ? overflowTriggerWidth : 0); + + if (widthNeeded <= availableWidth) { + return { + usedWidth: acc.usedWidth + tabWidth, + firstTabIndex: -1, + }; + } + + return { + usedWidth: acc.usedWidth, + firstTabIndex: index, + }; + }, + { usedWidth: startingUsedWidth, firstTabIndex: -1 }, + ); + + return result.firstTabIndex; +}; + +const areStringArraysEqual = ( + left: readonly string[], + right: readonly string[], +): boolean => { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +}; diff --git a/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts b/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts deleted file mode 100644 index 518606aa64..0000000000 --- a/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { type RefObject, useEffect, useMemo, useRef, useState } from "react"; - -type TabLike = { - value: string; -}; - -type UseTabOverflowKebabMenuOptions = { - tabs: readonly TTab[]; - enabled: boolean; - isActive: boolean; - alwaysVisibleTabsCount?: number; - overflowTriggerWidthPx?: number; -}; - -type UseTabOverflowKebabMenuResult = { - containerRef: RefObject; - visibleTabs: TTab[]; - overflowTabs: TTab[]; - getTabMeasureProps: (tabValue: string) => Record; -}; - -const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value"; - -export const useTabOverflowKebabMenu = ({ - tabs, - enabled, - isActive, - alwaysVisibleTabsCount = 1, - overflowTriggerWidthPx = 44, -}: UseTabOverflowKebabMenuOptions): UseTabOverflowKebabMenuResult => { - const containerRef = useRef(null); - const tabWidthByValueRef = useRef>({}); - const tabsRef = useRef(tabs); - const [overflowTabValues, setOverflowTabValues] = useState([]); - tabsRef.current = tabs; - - useEffect(() => { - if (!enabled || !isActive) { - setOverflowTabValues([]); - return; - } - - const container = containerRef.current; - if (!container) { - return; - } - - const recalculateOverflow = () => { - const currentTabs = tabsRef.current; - for (const tab of currentTabs) { - const tabElement = container.querySelector( - `[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`, - ); - if (tabElement) { - tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth; - } - } - - const alwaysVisibleTabs = currentTabs.slice(0, alwaysVisibleTabsCount); - const optionalTabs = currentTabs.slice(alwaysVisibleTabsCount); - if (optionalTabs.length === 0) { - setOverflowTabValues([]); - return; - } - - const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => { - return total + (tabWidthByValueRef.current[tab.value] ?? 0); - }, 0); - - const availableWidth = container.clientWidth; - let usedWidth = alwaysVisibleWidth; - const nextOverflowValues: string[] = []; - - for (let i = 0; i < optionalTabs.length; i++) { - const tab = optionalTabs[i]; - const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0; - const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1; - const widthNeeded = - usedWidth + - tabWidth + - (hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0); - - if (widthNeeded <= availableWidth) { - usedWidth += tabWidth; - continue; - } - - nextOverflowValues.push( - ...optionalTabs.slice(i).map((overflowTab) => overflowTab.value), - ); - break; - } - - setOverflowTabValues((currentValues) => { - if ( - currentValues.length === nextOverflowValues.length && - currentValues.every( - (value, index) => value === nextOverflowValues[index], - ) - ) { - return currentValues; - } - return nextOverflowValues; - }); - }; - - recalculateOverflow(); - const observer = new ResizeObserver(recalculateOverflow); - observer.observe(container); - return () => observer.disconnect(); - }, [alwaysVisibleTabsCount, enabled, isActive, overflowTriggerWidthPx]); - - const overflowTabValuesSet = useMemo( - () => new Set(overflowTabValues), - [overflowTabValues], - ); - - const visibleTabs = useMemo( - () => tabs.filter((tab) => !overflowTabValuesSet.has(tab.value)), - [tabs, overflowTabValuesSet], - ); - const overflowTabs = useMemo( - () => tabs.filter((tab) => overflowTabValuesSet.has(tab.value)), - [tabs, overflowTabValuesSet], - ); - - const getTabMeasureProps = (tabValue: string) => { - return { [DATA_ATTR_TAB_VALUE]: tabValue }; - }; - - return { - containerRef, - visibleTabs, - overflowTabs, - getTabMeasureProps, - }; -}; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 5a8fda4434..19fabffe3f 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -41,7 +41,7 @@ import { TabsList, TabsTrigger, } from "#/components/Tabs/Tabs"; -import { useTabOverflowKebabMenu } from "#/components/Tabs/utils"; +import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu"; import { useProxy } from "#/contexts/ProxyContext"; import { useClipboard } from "#/hooks/useClipboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; @@ -256,11 +256,10 @@ export const AgentRow: FC = ({ visibleTabs: visibleLogTabs, overflowTabs: overflowLogTabs, getTabMeasureProps, - } = useTabOverflowKebabMenu({ + } = useKebabMenu({ tabs: logTabs, enabled: true, isActive: showLogs, - alwaysVisibleTabsCount: 1, }); const overflowLogTabValuesSet = new Set( overflowLogTabs.map((tab) => tab.value),