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
This commit is contained in:
Jake Howell
2026-04-08 02:25:18 +10:00
committed by GitHub
parent cffc68df58
commit 2bd261fbbf
4 changed files with 276 additions and 141 deletions
-1
View File
@@ -1 +0,0 @@
export { useTabOverflowKebabMenu } from "./useTabOverflowKebabMenu";
@@ -0,0 +1,274 @@
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
type TabValue = {
value: string;
};
type UseKebabMenuOptions<T extends TabValue> = {
tabs: readonly T[];
enabled: boolean;
isActive: boolean;
overflowTriggerWidth?: number;
};
type UseKebabMenuResult<T extends TabValue> = {
containerRef: RefObject<HTMLDivElement | null>;
visibleTabs: T[];
overflowTabs: T[];
getTabMeasureProps: (tabValue: string) => Record<string, string>;
};
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 = <T extends TabValue>({
tabs,
enabled,
isActive,
overflowTriggerWidth = 44,
}: UseKebabMenuOptions<T>): UseKebabMenuResult<T> => {
const containerRef = useRef<HTMLDivElement>(null);
const tabsRef = useRef<readonly T[]>(tabs);
tabsRef.current = tabs;
const previousTabsRef = useRef<readonly T[]>(tabs);
const availableWidthRef = useRef<number | null>(null);
// Width cache prevents oscillation when overflow tabs are not mounted.
const tabWidthByValueRef = useRef<Record<string, number>>({});
const [overflowTabValues, setTabValues] = useState<string[]>([]);
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 = <T extends TabValue>({
tabs,
availableWidth,
tabWidthByValue,
overflowTriggerWidth,
}: {
tabs: readonly T[];
availableWidth: number;
tabWidthByValue: Readonly<Record<string, number>>;
overflowTriggerWidth: number;
}): string[] => {
const tabWidthByValueMap = new Map<string, number>();
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 = <T extends TabValue>({
tabs,
container,
previousTabWidthByValue,
}: {
tabs: readonly T[];
container: HTMLDivElement;
previousTabWidthByValue: Readonly<Record<string, number>>;
}): Record<string, number> => {
const nextTabWidthByValue = { ...previousTabWidthByValue };
for (const tab of tabs) {
const tabElement = container.querySelector<HTMLElement>(
`[${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])
);
};
@@ -1,137 +0,0 @@
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
type TabLike = {
value: string;
};
type UseTabOverflowKebabMenuOptions<TTab extends TabLike> = {
tabs: readonly TTab[];
enabled: boolean;
isActive: boolean;
alwaysVisibleTabsCount?: number;
overflowTriggerWidthPx?: number;
};
type UseTabOverflowKebabMenuResult<TTab extends TabLike> = {
containerRef: RefObject<HTMLDivElement | null>;
visibleTabs: TTab[];
overflowTabs: TTab[];
getTabMeasureProps: (tabValue: string) => Record<string, string>;
};
const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value";
export const useTabOverflowKebabMenu = <TTab extends TabLike>({
tabs,
enabled,
isActive,
alwaysVisibleTabsCount = 1,
overflowTriggerWidthPx = 44,
}: UseTabOverflowKebabMenuOptions<TTab>): UseTabOverflowKebabMenuResult<TTab> => {
const containerRef = useRef<HTMLDivElement>(null);
const tabWidthByValueRef = useRef<Record<string, number>>({});
const tabsRef = useRef(tabs);
const [overflowTabValues, setOverflowTabValues] = useState<string[]>([]);
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<HTMLElement>(
`[${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,
};
};
+2 -3
View File
@@ -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<AgentRowProps> = ({
visibleTabs: visibleLogTabs,
overflowTabs: overflowLogTabs,
getTabMeasureProps,
} = useTabOverflowKebabMenu({
} = useKebabMenu({
tabs: logTabs,
enabled: true,
isActive: showLogs,
alwaysVisibleTabsCount: 1,
});
const overflowLogTabValuesSet = new Set(
overflowLogTabs.map((tab) => tab.value),