From 2e5c65120a76b6093c64ae7f8a0654a4b362752a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 15:00:02 +0100 Subject: [PATCH] Fix PluginHeader ResizeObserver loop Defer header height updates to avoid ResizeObserver loop warnings in FullPage layouts and normalize header actions before rendering. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../fix-plugin-header-resizeobserver-loop.md | 7 +++ .../components/PluginHeader/PluginHeader.tsx | 51 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-plugin-header-resizeobserver-loop.md diff --git a/.changeset/fix-plugin-header-resizeobserver-loop.md b/.changeset/fix-plugin-header-resizeobserver-loop.md new file mode 100644 index 0000000000..b91620d00c --- /dev/null +++ b/.changeset/fix-plugin-header-resizeobserver-loop.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed `PluginHeader` to avoid triggering `ResizeObserver loop completed with undelivered notifications` warnings when used in layouts that react to the header height, such as pages that use `FullPage`. + +**Affected components:** PluginHeader diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.tsx index ac5f04a011..d153a3af18 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx @@ -19,7 +19,7 @@ import { Tabs, TabList, Tab } from '../Tabs'; import { useDefinition } from '../../hooks/useDefinition'; import { PluginHeaderDefinition } from './definition'; import { type NavigateOptions } from 'react-router-dom'; -import { useRef } from 'react'; +import { Children, useMemo, useRef } from 'react'; import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; import { Box } from '../Box'; import { Link } from 'react-aria-components'; @@ -55,35 +55,70 @@ export const PluginHeader = (props: PluginHeaderProps) => { const toolbarWrapperRef = useRef(null); const toolbarContentRef = useRef(null); const toolbarControlsRef = useRef(null); + const animationFrameRef = useRef(undefined); + const lastAppliedHeightRef = useRef(undefined); + + const actionChildren = useMemo(() => { + return Children.toArray(customActions); + }, [customActions]); useIsomorphicLayoutEffect(() => { const el = headerRef.current; - if (!el) return undefined; + if (!el) { + return undefined; + } - const updateHeight = () => { - const height = el.offsetHeight; + const cancelScheduledUpdate = () => { + if (animationFrameRef.current === undefined) { + return; + } + + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; + }; + + const applyHeight = (height: number) => { + if (lastAppliedHeightRef.current === height) { + return; + } + + lastAppliedHeightRef.current = height; document.documentElement.style.setProperty( '--bui-header-height', `${height}px`, ); }; - // Set height once immediately - updateHeight(); + const scheduleHeightUpdate = () => { + cancelScheduledUpdate(); + animationFrameRef.current = requestAnimationFrame(() => { + animationFrameRef.current = undefined; + applyHeight(el.offsetHeight); + }); + }; + + // Set height once immediately so the initial layout is correct. + applyHeight(el.offsetHeight); // Observe for resize changes if ResizeObserver is available // (not present in Jest/jsdom by default) if (typeof ResizeObserver === 'undefined') { return () => { + cancelScheduledUpdate(); + lastAppliedHeightRef.current = undefined; document.documentElement.style.removeProperty('--bui-header-height'); }; } - const observer = new ResizeObserver(updateHeight); + const observer = new ResizeObserver(() => { + scheduleHeightUpdate(); + }); observer.observe(el); return () => { observer.disconnect(); + cancelScheduledUpdate(); + lastAppliedHeightRef.current = undefined; document.documentElement.style.removeProperty('--bui-header-height'); }; }, []); @@ -111,7 +146,7 @@ export const PluginHeader = (props: PluginHeaderProps) => {
- {customActions} + {actionChildren}