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:
@@ -281,17 +281,28 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
updateChatScrollHeight();
|
updateChatScrollHeight();
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
const scheduleUpdate = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
updateChatScrollHeight();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
window.addEventListener('resize', updateChatScrollHeight);
|
window.addEventListener('resize', scheduleUpdate);
|
||||||
return () => {
|
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);
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||||
|
|||||||
@@ -779,18 +779,26 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
return () => { };
|
return () => { };
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
let rafId = 0;
|
||||||
updateHeaderHeight();
|
const scheduleUpdate = () => {
|
||||||
});
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
updateHeaderHeight();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(scheduleUpdate);
|
||||||
|
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
window.addEventListener('resize', updateHeaderHeight);
|
window.addEventListener('resize', scheduleUpdate);
|
||||||
window.addEventListener('orientationchange', updateHeaderHeight);
|
window.addEventListener('orientationchange', scheduleUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
window.removeEventListener('resize', updateHeaderHeight);
|
window.removeEventListener('resize', scheduleUpdate);
|
||||||
window.removeEventListener('orientationchange', updateHeaderHeight);
|
window.removeEventListener('orientationchange', scheduleUpdate);
|
||||||
};
|
};
|
||||||
}, [updateHeaderHeight]);
|
}, [updateHeaderHeight]);
|
||||||
|
|
||||||
|
|||||||
@@ -378,6 +378,10 @@ export const MainLayout: React.FC = () => {
|
|||||||
setKeyboardOpen(false);
|
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 updateVisualViewport = () => {
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
|
|
||||||
@@ -480,13 +484,21 @@ export const MainLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleVisualViewportUpdate = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
updateVisualViewport();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
updateVisualViewport();
|
updateVisualViewport();
|
||||||
|
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
viewport?.addEventListener('resize', updateVisualViewport);
|
viewport?.addEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
viewport?.addEventListener('scroll', updateVisualViewport);
|
viewport?.addEventListener('scroll', scheduleVisualViewportUpdate);
|
||||||
window.addEventListener('resize', updateVisualViewport);
|
window.addEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
window.addEventListener('orientationchange', updateVisualViewport);
|
window.addEventListener('orientationchange', scheduleVisualViewportUpdate);
|
||||||
const isTextInputTarget = (element: HTMLElement | null) => {
|
const isTextInputTarget = (element: HTMLElement | null) => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -504,7 +516,7 @@ export const MainLayout: React.FC = () => {
|
|||||||
if (isTextInputTarget(target)) {
|
if (isTextInputTarget(target)) {
|
||||||
ignoreOpenUntilZero = false;
|
ignoreOpenUntilZero = false;
|
||||||
}
|
}
|
||||||
updateVisualViewport();
|
scheduleVisualViewportUpdate();
|
||||||
};
|
};
|
||||||
document.addEventListener('focusin', handleFocusIn, true);
|
document.addEventListener('focusin', handleFocusIn, true);
|
||||||
|
|
||||||
@@ -549,10 +561,11 @@ export const MainLayout: React.FC = () => {
|
|||||||
document.addEventListener('focusout', handleFocusOut, true);
|
document.addEventListener('focusout', handleFocusOut, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
viewport?.removeEventListener('resize', updateVisualViewport);
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
viewport?.removeEventListener('scroll', updateVisualViewport);
|
viewport?.removeEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
window.removeEventListener('resize', updateVisualViewport);
|
viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate);
|
||||||
window.removeEventListener('orientationchange', updateVisualViewport);
|
window.removeEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
|
window.removeEventListener('orientationchange', scheduleVisualViewportUpdate);
|
||||||
document.removeEventListener('focusin', handleFocusIn, true);
|
document.removeEventListener('focusin', handleFocusIn, true);
|
||||||
document.removeEventListener('focusout', handleFocusOut, true);
|
document.removeEventListener('focusout', handleFocusOut, true);
|
||||||
clearKeyboardAvoidTarget();
|
clearKeyboardAvoidTarget();
|
||||||
|
|||||||
@@ -161,12 +161,19 @@ export function useDeviceInfo(): DeviceInfo {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
setDeviceInfo(getDeviceInfo());
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
setDeviceInfo(getDeviceInfo());
|
||||||
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user