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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user