From 87a17488b7149110b22bee3517106e984e8ff0bd Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 16:05:38 +0100 Subject: [PATCH 1/4] fix(nav): stop project navbar items clipping off-screen The project navbar overflow logic mis-measured available width, letting items (e.g. Project Settings) clip past the right edge instead of collapsing into the overflow menu. - Read the real flex gap and subtract container padding instead of guessing; measure item widths with getBoundingClientRect so fractional widths are not lost. - Re-measure when a visible item changes size after the first pass (async permission-gated links, count badges). - Wrap rendered children so DOM nodes map 1:1 with items, and split the admin-only links into separate items so they overflow independently. Also shorten two labels: "Feature Lifecycle" -> "Lifecycle" and "Release Pipelines" -> "Pipelines". Co-Authored-By: Claude Fable 5 --- .../common/hooks/useOverflowVisibleCount.ts | 56 +++++++++++++++++-- .../web/components/navigation/OverflowNav.tsx | 9 ++- .../navigation/navbars/ProjectNavbar.tsx | 36 ++++++------ 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/frontend/common/hooks/useOverflowVisibleCount.ts b/frontend/common/hooks/useOverflowVisibleCount.ts index 171d8a4d14d1..5779417e40df 100644 --- a/frontend/common/hooks/useOverflowVisibleCount.ts +++ b/frontend/common/hooks/useOverflowVisibleCount.ts @@ -3,6 +3,10 @@ import { useState, useLayoutEffect, RefObject } from 'react' const GAP_MULTIPLIER = 5 +// offsetWidth rounds to integers; use fractional widths so sums don't +// underestimate and let items clip instead of overflowing +const measureWidth = (el: HTMLElement) => el.getBoundingClientRect().width + type UseOverflowVisibleCountOptions = { outerContainerRef: RefObject itemsContainerRef: RefObject @@ -43,10 +47,33 @@ export const useOverflowVisibleCount = ({ if (!itemsCont) return const childEls = Array.from(itemsCont.children) as HTMLElement[] - const newWidths = childEls.map((el) => el.offsetWidth) + const newWidths = childEls.map(measureWidth) setWidths(newWidths) }, [itemCount, force, widths.length, itemsContainerRef]) + // Re-measure when a visible item changes size (e.g. async content such as + // permission-gated links or count badges rendering after the first measure) + useLayoutEffect(() => { + if (force || widths.length === 0) { + return + } + + const itemsCont = itemsContainerRef.current + if (!itemsCont) return + + const ro = new ResizeObserver(() => { + const childEls = Array.from(itemsCont.children) as HTMLElement[] + const changed = childEls.some( + (el, i) => Math.abs(measureWidth(el) - widths[i]) > 0.5, + ) + if (changed) { + setWidths([]) + } + }) + Array.from(itemsCont.children).forEach((el) => ro.observe(el)) + return () => ro.disconnect() + }, [widths, force, itemsContainerRef]) + // Calculate visible count based useLayoutEffect(() => { if (force || widths.length === 0) { @@ -57,7 +84,15 @@ export const useOverflowVisibleCount = ({ const outerCont = outerContainerRef.current if (!outerCont) return - const gapPx = gap * GAP_MULTIPLIER + // Read the real flex gap; the gap * GAP_MULTIPLIER guess undershoots + // (e.g. gap-3 is 16px, not 15px) which lets items clip off-screen + const itemsCont = itemsContainerRef.current + const computedGap = itemsCont + ? parseFloat(getComputedStyle(itemsCont).columnGap) + : NaN + const gapPx = Number.isNaN(computedGap) + ? gap * GAP_MULTIPLIER + : computedGap const sumWidths = (count: number) => { if (count === 0) return 0 @@ -68,7 +103,12 @@ export const useOverflowVisibleCount = ({ return totalWidths + totalGaps } - const containerWidth = outerCont.clientWidth + // clientWidth includes padding, which is not available to items + const outerStyle = getComputedStyle(outerCont) + const containerWidth = + outerCont.clientWidth - + (parseFloat(outerStyle.paddingLeft) || 0) - + (parseFloat(outerStyle.paddingRight) || 0) // All items fit if (sumWidths(widths.length) <= containerWidth) { setVisibleCount(widths.length) @@ -93,7 +133,15 @@ export const useOverflowVisibleCount = ({ ro.observe(outerContainerRef.current) } return () => ro.disconnect() - }, [widths, force, gap, itemCount, extraWidth, outerContainerRef]) + }, [ + widths, + force, + gap, + itemCount, + extraWidth, + outerContainerRef, + itemsContainerRef, + ]) const isMeasuring = !force && widths.length === 0 && itemCount > 0 diff --git a/frontend/web/components/navigation/OverflowNav.tsx b/frontend/web/components/navigation/OverflowNav.tsx index 944fb7a6831a..cd3112c0bf38 100644 --- a/frontend/web/components/navigation/OverflowNav.tsx +++ b/frontend/web/components/navigation/OverflowNav.tsx @@ -71,8 +71,15 @@ const OverflowNav: FC = ({ 'd-flex align-items-center', )} > + {/* Wrappers keep DOM children 1:1 with items so measured widths align, + even when an item renders nothing or multiple elements */} {(isMeasuring ? items : visible).map((child, idx) => ( - {child} +
+ {child} +
))} diff --git a/frontend/web/components/navigation/navbars/ProjectNavbar.tsx b/frontend/web/components/navigation/navbars/ProjectNavbar.tsx index 3c66eb25e0cd..d0e7f4d7ba7e 100644 --- a/frontend/web/components/navigation/navbars/ProjectNavbar.tsx +++ b/frontend/web/components/navigation/navbars/ProjectNavbar.tsx @@ -60,7 +60,7 @@ const ProjectNavbar: FC = ({ environmentId, projectId }) => { location.pathname.startsWith(`/project/${projectId}/lifecycle`) } > - Feature Lifecycle + Lifecycle )} = ({ environmentId, projectId }) => { > Compare + {projectAdmin && Utils.getFlagsmithHasFeature('release_pipelines') && ( + } + id='release-pipelines-link' + to={`/project/${projectId}/release-pipelines`} + > + Pipelines + + )} {projectAdmin && ( - <> - {Utils.getFlagsmithHasFeature('release_pipelines') && ( - } - id='release-pipelines-link' - to={`/project/${projectId}/release-pipelines`} - > - Release Pipelines - - )} - } - id='project-settings-link' - to={`/project/${projectId}/settings`} - > - Project Settings - - + } + id='project-settings-link' + to={`/project/${projectId}/settings`} + > + Project Settings + )} ) From f3ac95ca18d845e45a5e42b2c7f534de9f97e18e Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 16:24:37 +0100 Subject: [PATCH 2/4] fix(nav): let organisation navbar admin links overflow independently The admin-only Organisation Integrations and Organisation Settings links were wrapped in a single fragment, so the overflow logic treated them as one double-width item and dropped both at once instead of hiding just the one that no longer fits. Co-Authored-By: Claude Fable 5 --- .../navigation/navbars/OrganisationNavbar.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/frontend/web/components/navigation/navbars/OrganisationNavbar.tsx b/frontend/web/components/navigation/navbars/OrganisationNavbar.tsx index 2ba0d7a92f4f..09d630b89570 100644 --- a/frontend/web/components/navigation/navbars/OrganisationNavbar.tsx +++ b/frontend/web/components/navigation/navbars/OrganisationNavbar.tsx @@ -42,28 +42,27 @@ const OrganisationNavbar: FC = ({}) => { Usage )} - {AccountStore.isAdmin() && ( - <> - {Utils.getFlagsmithHasFeature('organisation_integrations') && ( - } - id='integrations-link' - to={`/organisation/${ - AccountStore.getOrganisation().id - }/integrations`} - > - Organisation Integrations - - )} + {AccountStore.isAdmin() && + Utils.getFlagsmithHasFeature('organisation_integrations') && ( } - id='org-settings-link' - data-test='org-settings-link' - to={`/organisation/${AccountStore.getOrganisation().id}/settings`} + icon={} + id='integrations-link' + to={`/organisation/${ + AccountStore.getOrganisation().id + }/integrations`} > - Organisation Settings + Organisation Integrations - + )} + {AccountStore.isAdmin() && ( + } + id='org-settings-link' + data-test='org-settings-link' + to={`/organisation/${AccountStore.getOrganisation().id}/settings`} + > + Organisation Settings + )} ) From 34169397ff80582664dee96fcd51418766b87a4b Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 16:32:45 +0100 Subject: [PATCH 3/4] perf(nav): coalesce overflow re-measures into one per frame Debounce the ResizeObserver re-measure through requestAnimationFrame so a burst of resize entries triggers a single setWidths reset instead of one per entry, and cancel any pending frame on cleanup. Co-Authored-By: Claude Fable 5 --- .../common/hooks/useOverflowVisibleCount.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/common/hooks/useOverflowVisibleCount.ts b/frontend/common/hooks/useOverflowVisibleCount.ts index 5779417e40df..ca9301543947 100644 --- a/frontend/common/hooks/useOverflowVisibleCount.ts +++ b/frontend/common/hooks/useOverflowVisibleCount.ts @@ -61,17 +61,27 @@ export const useOverflowVisibleCount = ({ const itemsCont = itemsContainerRef.current if (!itemsCont) return + // Coalesce a burst of resize entries into a single re-measure per frame, + // and ignore sub-pixel drift so fractional rounding doesn't churn + let frame = 0 const ro = new ResizeObserver(() => { - const childEls = Array.from(itemsCont.children) as HTMLElement[] - const changed = childEls.some( - (el, i) => Math.abs(measureWidth(el) - widths[i]) > 0.5, - ) - if (changed) { - setWidths([]) - } + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + const childEls = Array.from(itemsCont.children) as HTMLElement[] + const changed = childEls.some( + (el, i) => Math.abs(measureWidth(el) - widths[i]) > 0.5, + ) + if (changed) { + setWidths([]) + } + }) }) Array.from(itemsCont.children).forEach((el) => ro.observe(el)) - return () => ro.disconnect() + return () => { + if (frame) cancelAnimationFrame(frame) + ro.disconnect() + } }, [widths, force, itemsContainerRef]) // Calculate visible count based From 67c88145ed51962ba9e54e7f8832e476bcd40b9e Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 17:55:30 +0100 Subject: [PATCH 4/4] fix(nav): narrow overflow hook changes to the padding fix only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extra behaviours added to useOverflowVisibleCount — a per-child ResizeObserver re-measure and reading the real column-gap — changed measurement for every consumer of the hook, including the modal Tabs. That regressed the roles e2e (the Permissions tab briefly became non-interactive), so revert those and keep only the container-padding subtraction, which is what actually stopped the navbar items clipping. Co-Authored-By: Claude Fable 5 --- .../common/hooks/useOverflowVisibleCount.ts | 63 ++----------------- 1 file changed, 6 insertions(+), 57 deletions(-) diff --git a/frontend/common/hooks/useOverflowVisibleCount.ts b/frontend/common/hooks/useOverflowVisibleCount.ts index ca9301543947..faadeb0fb24c 100644 --- a/frontend/common/hooks/useOverflowVisibleCount.ts +++ b/frontend/common/hooks/useOverflowVisibleCount.ts @@ -3,10 +3,6 @@ import { useState, useLayoutEffect, RefObject } from 'react' const GAP_MULTIPLIER = 5 -// offsetWidth rounds to integers; use fractional widths so sums don't -// underestimate and let items clip instead of overflowing -const measureWidth = (el: HTMLElement) => el.getBoundingClientRect().width - type UseOverflowVisibleCountOptions = { outerContainerRef: RefObject itemsContainerRef: RefObject @@ -47,43 +43,10 @@ export const useOverflowVisibleCount = ({ if (!itemsCont) return const childEls = Array.from(itemsCont.children) as HTMLElement[] - const newWidths = childEls.map(measureWidth) + const newWidths = childEls.map((el) => el.offsetWidth) setWidths(newWidths) }, [itemCount, force, widths.length, itemsContainerRef]) - // Re-measure when a visible item changes size (e.g. async content such as - // permission-gated links or count badges rendering after the first measure) - useLayoutEffect(() => { - if (force || widths.length === 0) { - return - } - - const itemsCont = itemsContainerRef.current - if (!itemsCont) return - - // Coalesce a burst of resize entries into a single re-measure per frame, - // and ignore sub-pixel drift so fractional rounding doesn't churn - let frame = 0 - const ro = new ResizeObserver(() => { - if (frame) return - frame = requestAnimationFrame(() => { - frame = 0 - const childEls = Array.from(itemsCont.children) as HTMLElement[] - const changed = childEls.some( - (el, i) => Math.abs(measureWidth(el) - widths[i]) > 0.5, - ) - if (changed) { - setWidths([]) - } - }) - }) - Array.from(itemsCont.children).forEach((el) => ro.observe(el)) - return () => { - if (frame) cancelAnimationFrame(frame) - ro.disconnect() - } - }, [widths, force, itemsContainerRef]) - // Calculate visible count based useLayoutEffect(() => { if (force || widths.length === 0) { @@ -94,15 +57,7 @@ export const useOverflowVisibleCount = ({ const outerCont = outerContainerRef.current if (!outerCont) return - // Read the real flex gap; the gap * GAP_MULTIPLIER guess undershoots - // (e.g. gap-3 is 16px, not 15px) which lets items clip off-screen - const itemsCont = itemsContainerRef.current - const computedGap = itemsCont - ? parseFloat(getComputedStyle(itemsCont).columnGap) - : NaN - const gapPx = Number.isNaN(computedGap) - ? gap * GAP_MULTIPLIER - : computedGap + const gapPx = gap * GAP_MULTIPLIER const sumWidths = (count: number) => { if (count === 0) return 0 @@ -113,7 +68,9 @@ export const useOverflowVisibleCount = ({ return totalWidths + totalGaps } - // clientWidth includes padding, which is not available to items + // clientWidth includes the container's horizontal padding, which the items + // can't actually occupy. Subtract it so padded navbars don't over-estimate + // the available width and let items clip off-screen. const outerStyle = getComputedStyle(outerCont) const containerWidth = outerCont.clientWidth - @@ -143,15 +100,7 @@ export const useOverflowVisibleCount = ({ ro.observe(outerContainerRef.current) } return () => ro.disconnect() - }, [ - widths, - force, - gap, - itemCount, - extraWidth, - outerContainerRef, - itemsContainerRef, - ]) + }, [widths, force, gap, itemCount, extraWidth, outerContainerRef]) const isMeasuring = !force && widths.length === 0 && itemCount > 0