perf: batch mobile keyboard viewport handlers with rAF and debounce (#660)

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)
This commit is contained in:
Gyumin Lee
2026-03-15 23:18:59 +02:00
committed by GitHub
parent bcb0ccb6ca
commit edf0a197a8
4 changed files with 60 additions and 21 deletions
@@ -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]);
+15 -7
View File
@@ -779,18 +779,26 @@ export const Header: React.FC<HeaderProps> = ({
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]);
@@ -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();
+9 -2
View File
@@ -161,12 +161,19 @@ export function useDeviceInfo(): DeviceInfo {
React.useEffect(() => {
if (typeof window === 'undefined') return;
let debounceTimer: ReturnType<typeof setTimeout>;
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(() => {