From edf0a197a8abd1626aada933f966e31cd816fc44 Mon Sep 17 00:00:00 2001 From: Gyumin Lee <64009299+Chi-square-test@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:18:59 +0900 Subject: [PATCH] perf: batch mobile keyboard viewport handlers with rAF and debounce (#660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce layout thrashing during virtual keyboard open/close on mobile by batching resize and visualViewport event handlers to once per animation frame. No visual or behavioral changes — desktop is unaffected. - MainLayout: rAF-batch visualViewport resize/scroll and window resize listeners (was firing 10-20x per frame during keyboard animation) - Header: rAF-batch ResizeObserver and resize/orientationchange callbacks - ChatContainer: rAF-batch ResizeObserver and resize fallback callbacks - useDeviceInfo: 150ms debounce on resize listener (device type does not change during keyboard transitions) --- .../ui/src/components/chat/ChatContainer.tsx | 17 ++++++++-- packages/ui/src/components/layout/Header.tsx | 22 ++++++++----- .../ui/src/components/layout/MainLayout.tsx | 31 +++++++++++++------ packages/ui/src/lib/device.ts | 11 +++++-- 4 files changed, 60 insertions(+), 21 deletions(-) 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(() => {