diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d64d86d4..e713ae7f 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -281,17 +281,28 @@ export const ChatContainer: React.FC = () => { updateChatScrollHeight(); + let rafId = 0; + const scheduleUpdate = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + updateChatScrollHeight(); + }); + }; + if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', updateChatScrollHeight); + window.addEventListener('resize', scheduleUpdate); return () => { - window.removeEventListener('resize', updateChatScrollHeight); + if (rafId) cancelAnimationFrame(rafId); + window.removeEventListener('resize', scheduleUpdate); }; } - const resizeObserver = new ResizeObserver(updateChatScrollHeight); + const resizeObserver = new ResizeObserver(scheduleUpdate); resizeObserver.observe(container); return () => { + if (rafId) cancelAnimationFrame(rafId); resizeObserver.disconnect(); }; }, [currentSessionId, isDesktopExpandedInput, scrollRef]); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index cffb7f32..3267316c 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -779,18 +779,26 @@ export const Header: React.FC = ({ return () => { }; } - const observer = new ResizeObserver(() => { - updateHeaderHeight(); - }); + let rafId = 0; + const scheduleUpdate = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + updateHeaderHeight(); + }); + }; + + const observer = new ResizeObserver(scheduleUpdate); observer.observe(node); - window.addEventListener('resize', updateHeaderHeight); - window.addEventListener('orientationchange', updateHeaderHeight); + window.addEventListener('resize', scheduleUpdate); + window.addEventListener('orientationchange', scheduleUpdate); return () => { + if (rafId) cancelAnimationFrame(rafId); observer.disconnect(); - window.removeEventListener('resize', updateHeaderHeight); - window.removeEventListener('orientationchange', updateHeaderHeight); + window.removeEventListener('resize', scheduleUpdate); + window.removeEventListener('orientationchange', scheduleUpdate); }; }, [updateHeaderHeight]); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index ad79085d..be0480b8 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -378,6 +378,10 @@ export const MainLayout: React.FC = () => { setKeyboardOpen(false); }; + // Batch visualViewport updates to once per animation frame to avoid + // layout thrashing during keyboard open/close animations. + let rafId = 0; + const updateVisualViewport = () => { const viewport = window.visualViewport; @@ -480,13 +484,21 @@ export const MainLayout: React.FC = () => { } }; + const scheduleVisualViewportUpdate = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + updateVisualViewport(); + }); + }; + updateVisualViewport(); const viewport = window.visualViewport; - viewport?.addEventListener('resize', updateVisualViewport); - viewport?.addEventListener('scroll', updateVisualViewport); - window.addEventListener('resize', updateVisualViewport); - window.addEventListener('orientationchange', updateVisualViewport); + viewport?.addEventListener('resize', scheduleVisualViewportUpdate); + viewport?.addEventListener('scroll', scheduleVisualViewportUpdate); + window.addEventListener('resize', scheduleVisualViewportUpdate); + window.addEventListener('orientationchange', scheduleVisualViewportUpdate); const isTextInputTarget = (element: HTMLElement | null) => { if (!element) { return false; @@ -504,7 +516,7 @@ export const MainLayout: React.FC = () => { if (isTextInputTarget(target)) { ignoreOpenUntilZero = false; } - updateVisualViewport(); + scheduleVisualViewportUpdate(); }; document.addEventListener('focusin', handleFocusIn, true); @@ -549,10 +561,11 @@ export const MainLayout: React.FC = () => { document.addEventListener('focusout', handleFocusOut, true); return () => { - viewport?.removeEventListener('resize', updateVisualViewport); - viewport?.removeEventListener('scroll', updateVisualViewport); - window.removeEventListener('resize', updateVisualViewport); - window.removeEventListener('orientationchange', updateVisualViewport); + if (rafId) cancelAnimationFrame(rafId); + viewport?.removeEventListener('resize', scheduleVisualViewportUpdate); + viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate); + window.removeEventListener('resize', scheduleVisualViewportUpdate); + window.removeEventListener('orientationchange', scheduleVisualViewportUpdate); document.removeEventListener('focusin', handleFocusIn, true); document.removeEventListener('focusout', handleFocusOut, true); clearKeyboardAvoidTarget(); diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index a4337377..3aada2a8 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -161,12 +161,19 @@ export function useDeviceInfo(): DeviceInfo { React.useEffect(() => { if (typeof window === 'undefined') return; + let debounceTimer: ReturnType; const handleResize = () => { - setDeviceInfo(getDeviceInfo()); + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + setDeviceInfo(getDeviceInfo()); + }, 150); }; window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); + return () => { + clearTimeout(debounceTimer); + window.removeEventListener('resize', handleResize); + }; }, []); React.useEffect(() => {