perf: event-driven scroll follow, kill 60hz RAF during streaming

Persist mode was rescheduling RAF every frame forever, forcing layout
reads (scrollHeight) on each tick. Swap to ResizeObserver+MutationObserver
that trigger short RAF bursts only when content actually changes.

-10pp renderer CPU under active streaming.
This commit is contained in:
Bohdan Triapitsyn
2026-04-20 19:15:43 +03:00
parent ec38468e93
commit ad7790b3ec
+68 -19
View File
@@ -57,6 +57,7 @@ export const useScrollEngine = ({
const followRafRef = React.useRef<number | null>(null); const followRafRef = React.useRef<number | null>(null);
const followActiveRef = React.useRef(false); const followActiveRef = React.useRef(false);
const followPersistRef = React.useRef(false); const followPersistRef = React.useRef(false);
const followObserversRef = React.useRef<{ resize: ResizeObserver; mutation: MutationObserver } | null>(null);
const cancelSpring = React.useCallback(() => { const cancelSpring = React.useCallback(() => {
if (scrollAnimRef.current) { if (scrollAnimRef.current) {
@@ -65,7 +66,16 @@ export const useScrollEngine = ({
} }
}, []); }, []);
const teardownFollowObservers = React.useCallback(() => {
const observers = followObserversRef.current;
if (!observers) return;
observers.resize.disconnect();
observers.mutation.disconnect();
followObserversRef.current = null;
}, []);
const cancelFollow = React.useCallback(() => { const cancelFollow = React.useCallback(() => {
teardownFollowObservers();
if (followRafRef.current !== null && typeof window !== 'undefined') { if (followRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(followRafRef.current); window.cancelAnimationFrame(followRafRef.current);
followRafRef.current = null; followRafRef.current = null;
@@ -73,46 +83,49 @@ export const useScrollEngine = ({
followActiveRef.current = false; followActiveRef.current = false;
followPersistRef.current = false; followPersistRef.current = false;
setIsFollowingBottom(false); setIsFollowingBottom(false);
}, []); }, [teardownFollowObservers]);
const cancelAll = React.useCallback(() => { const cancelAll = React.useCallback(() => {
cancelSpring(); cancelSpring();
cancelFollow(); cancelFollow();
}, [cancelSpring, cancelFollow]); }, [cancelSpring, cancelFollow]);
// Continuous lerp loop that chases scrollHeight - clientHeight. // One burst of the lerp loop — runs until scrollTop catches up to bottom, then stops.
const startFollowLoop = React.useCallback((persist = false) => { // Re-invoked by observers when persist mode content grows.
followPersistRef.current = persist || followPersistRef.current; const runFollowBurst = React.useCallback(() => {
if (followActiveRef.current) return; // already running if (followActiveRef.current) return;
const container = containerRef.current;
if (!container) return;
followActiveRef.current = true; followActiveRef.current = true;
setIsFollowingBottom(true); if (!followPersistRef.current) {
setIsFollowingBottom(true);
}
let stableFrames = 0; let stableFrames = 0;
const tick = () => { const tick = () => {
const container = containerRef.current; const c = containerRef.current;
if (!container || !followActiveRef.current) { if (!c || !followActiveRef.current) {
followActiveRef.current = false; followActiveRef.current = false;
followRafRef.current = null; followRafRef.current = null;
setIsFollowingBottom(false); if (!followPersistRef.current) {
setIsFollowingBottom(false);
}
return; return;
} }
const target = container.scrollHeight - container.clientHeight; const target = c.scrollHeight - c.clientHeight;
const current = container.scrollTop; const current = c.scrollTop;
const delta = target - current; const delta = target - current;
if (Math.abs(delta) <= SNAP_EPSILON) { if (Math.abs(delta) <= SNAP_EPSILON) {
container.scrollTop = target; c.scrollTop = target;
if (followPersistRef.current) {
stableFrames = 0;
followRafRef.current = window.requestAnimationFrame(tick);
return;
}
stableFrames += 1; stableFrames += 1;
if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) { if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) {
followActiveRef.current = false; followActiveRef.current = false;
followRafRef.current = null; followRafRef.current = null;
setIsFollowingBottom(false); if (!followPersistRef.current) {
setIsFollowingBottom(false);
}
return; return;
} }
followRafRef.current = window.requestAnimationFrame(tick); followRafRef.current = window.requestAnimationFrame(tick);
@@ -120,13 +133,49 @@ export const useScrollEngine = ({
} }
stableFrames = 0; stableFrames = 0;
container.scrollTop = current + delta * LERP_FACTOR; c.scrollTop = current + delta * LERP_FACTOR;
followRafRef.current = window.requestAnimationFrame(tick); followRafRef.current = window.requestAnimationFrame(tick);
}; };
followRafRef.current = window.requestAnimationFrame(tick); followRafRef.current = window.requestAnimationFrame(tick);
}, [containerRef]); }, [containerRef]);
// Observer-driven persist mode — RAF bursts only fire when content actually changes.
// No idle CPU cost while waiting for the next token.
const setupFollowObservers = React.useCallback(() => {
const container = containerRef.current;
if (!container || followObserversRef.current) return;
const onChange = () => {
if (!followPersistRef.current) return;
runFollowBurst();
};
const resize = new ResizeObserver(onChange);
const inner = container.firstElementChild;
if (inner instanceof Element) {
resize.observe(inner);
}
resize.observe(container);
const mutation = new MutationObserver(onChange);
mutation.observe(container, { childList: true, subtree: true, characterData: true });
followObserversRef.current = { resize, mutation };
}, [containerRef, runFollowBurst]);
const startFollowLoop = React.useCallback((persist = false) => {
const wasPersist = followPersistRef.current;
followPersistRef.current = persist || wasPersist;
if (followPersistRef.current) {
if (!wasPersist) {
setIsFollowingBottom(true);
}
setupFollowObservers();
}
runFollowBurst();
}, [runFollowBurst, setupFollowObservers]);
const scrollToPosition = React.useCallback( const scrollToPosition = React.useCallback(
(position: number, options?: ScrollOptions) => { (position: number, options?: ScrollOptions) => {
const container = containerRef.current; const container = containerRef.current;