refactor: redesign chat scroll system

The previous system layered three hooks (useScrollEngine, useChatScrollManager,
useChatTimelineController) with overlapping responsibilities, four parallel
ResizeObservers/MutationObservers, and six entry points to "scroll to bottom"
(force-flag combinations, persistent follow loops, materialization recovery).
This produced bugs where users could not break free of auto-follow during
streaming: scrollbar drag, keyboard scrolling and find-in-page were not
detected as user intent, and observers kept restarting the follow loop on
every DOM mutation.

The new architecture replaces the two low-level hooks with a single
useChatAutoFollow that owns scroll behaviour end to end:

- One state: 'following' or 'released'. No follow modes, no pin flags,
  no marker pixels.
- One scroll writer: a lerp loop that runs only while the session is
  streaming and state is 'following'. Idle sessions never write scrollTop
  programmatically.
- One user-intent detector: wheel up, touch drag down, keyboard
  (PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb,
  and explicit releaseAutoFollow() calls all flip the state to 'released'.
- A 1.2s grace period after explicit release: re-pin will not auto-engage
  inside this window, so a small wheel up cannot snap the user back even
  while they remain near the bottom spacer.
- Re-pin and the scroll-to-bottom button share the same threshold: the
  height of the empty bottom spacer (10vh on desktop, 40px on mobile).
  Released users see the button only after they have scrolled past the
  spacer that already exists at the end of the chat.
- Save/restore of scroll position uses ratio mapping, debounced at 150ms
  on user-driven scroll events; programmatic writes are masked via a short
  window so they never persist as user positions.
- Container reattachment is detected via a useLayoutEffect probe over
  scrollRef.current. Listeners and observers re-bind when ChatViewport
  mounts after hydration or after the first message promotes a draft
  session into a real chat.
- A pending-restore queue replays restoreSnapshot once the scroll
  container appears, fixing the case where a hydrating session landed at
  the top instead of the bottom.

Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent
follow loop with its own ResizeObserver+MutationObserver pair, the
materialization-recovery .finally resume that yanked idle users to the
bottom on transient sync gaps, and the openchamber:session-reselected
event (re-select still works through the existing onSessionSelected
callback). The openchamber:chat-force-scroll-bottom event remains for
synthetic-message paths like git-message generation.

Net change: ~1300 lines removed, two hooks replaced with one, one
observer pair instead of four.
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 14:20:16 +03:00
parent e1ff21bc0a
commit 0ea573f766
17 changed files with 799 additions and 1416 deletions
+706
View File
@@ -0,0 +1,706 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
export type AutoFollowState = 'following' | 'released';
export type ContentChangeReason = 'text' | 'structural' | 'permission';
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
interface UseChatAutoFollowOptions {
currentSessionId: string | null;
sessionMessageCount: number;
sessionIsWorking: boolean;
isMobile: boolean;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatAutoFollowResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
state: AutoFollowState;
isPinned: boolean;
isOverflowing: boolean;
isFollowingProgrammatically: boolean;
showScrollButton: boolean;
notifyContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
goToBottom: (mode?: 'instant' | 'smooth') => void;
releaseAutoFollow: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
}
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
const BOTTOM_SPACER_MOBILE_PX = 40;
const PROGRAMMATIC_WRITE_WINDOW_MS = 200;
const SAVE_DEBOUNCE_MS = 150;
const LERP = 0.18;
const SETTLE_EPSILON = 0.5;
const SETTLE_FRAMES = 4;
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
const SETTLE_BURST_DURATION_MS = 280;
const REPIN_GRACE_AFTER_RELEASE_MS = 1200;
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
// — its height is exactly how far above scrollHeight the user can be while still
// looking at "empty" space. We use that same value as the threshold for both
// re-pinning auto-follow and showing the scroll-to-bottom button.
const computeBottomZoneThreshold = (isMobile: boolean): number => {
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
if (typeof window === 'undefined') return 96;
return Math.max(48, window.innerHeight * BOTTOM_SPACER_DESKTOP_VH);
};
const distanceFromBottom = (el: HTMLElement): number => {
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile);
};
const isReleaseKey = (event: KeyboardEvent): boolean => {
if (event.altKey || event.ctrlKey || event.metaKey) {
return false;
}
switch (event.key) {
case 'ArrowUp':
case 'PageUp':
case 'Home':
return true;
default:
return false;
}
};
const targetIsNestedScrollable = (root: HTMLElement, target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
const nested = target.closest('[data-scrollable]');
return Boolean(nested) && nested !== root;
};
const isAtBottomSnapshot = (snapshot: NonNullable<SessionMemoryState['scrollPosition']>, isMobile: boolean): boolean => {
const max = Math.max(0, snapshot.scrollHeight - snapshot.clientHeight);
if (max <= 0) return true;
const threshold = computeBottomZoneThreshold(isMobile);
return max - snapshot.scrollTop <= threshold;
};
export const useChatAutoFollow = ({
currentSessionId,
sessionMessageCount,
sessionIsWorking,
isMobile,
onActiveTurnChange,
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
const [state, setState] = React.useState<AutoFollowState>('following');
const [isOverflowing, setIsOverflowing] = React.useState(false);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
const stateRef = React.useRef<AutoFollowState>('following');
const sessionWorkingRef = React.useRef(sessionIsWorking);
sessionWorkingRef.current = sessionIsWorking;
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const lastSessionIdRef = React.useRef<string | null>(null);
const programmaticWriteUntilRef = React.useRef(0);
const followRafRef = React.useRef<number | null>(null);
const settledFramesRef = React.useRef(0);
const lastScrollTopRef = React.useRef(0);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const settleBurstRafRef = React.useRef<number | null>(null);
const lastUserReleaseAtRef = React.useRef(0);
// When restoreSnapshot is invoked while ChatViewport is still hydrating
// (skeleton rendered, no scroll container yet), we record the session here
// so a follow-up effect can replay the restore once the container mounts.
const pendingInitialRestoreRef = React.useRef<string | null>(null);
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
// Detect when the scroll container DOM element changes (mount, unmount, remount).
// Without this, listener-attach effects would only ever bind to the element that
// existed at the hook's first render, missing later mounts (e.g. after first send
// promotes a draft session to a real chat with messages).
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (scrollRef.current !== lastSeenContainerRef.current) {
lastSeenContainerRef.current = scrollRef.current;
setContainerEl(scrollRef.current);
}
});
const setStateValue = React.useCallback((next: AutoFollowState) => {
if (stateRef.current === next) return;
stateRef.current = next;
setState(next);
}, []);
const markProgrammaticWrite = React.useCallback(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
programmaticWriteUntilRef.current = now + PROGRAMMATIC_WRITE_WINDOW_MS;
}, []);
const isInProgrammaticWindow = React.useCallback(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
return now < programmaticWriteUntilRef.current;
}, []);
const stopFollowLoop = React.useCallback(() => {
if (followRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(followRafRef.current);
}
followRafRef.current = null;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(false);
}, []);
const tickFollow = React.useCallback(() => {
followRafRef.current = null;
const container = scrollRef.current;
if (!container) {
stopFollowLoop();
return;
}
if (stateRef.current !== 'following' || !sessionWorkingRef.current) {
stopFollowLoop();
return;
}
const target = Math.max(0, container.scrollHeight - container.clientHeight);
const current = container.scrollTop;
const delta = target - current;
if (Math.abs(delta) <= SETTLE_EPSILON) {
if (current !== target) {
markProgrammaticWrite();
container.scrollTop = target;
lastScrollTopRef.current = target;
}
settledFramesRef.current += 1;
if (settledFramesRef.current >= SETTLE_FRAMES) {
stopFollowLoop();
return;
}
followRafRef.current = window.requestAnimationFrame(tickFollow);
return;
}
settledFramesRef.current = 0;
const next = current + delta * LERP;
markProgrammaticWrite();
container.scrollTop = next;
lastScrollTopRef.current = container.scrollTop;
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [markProgrammaticWrite, stopFollowLoop]);
const startFollowLoop = React.useCallback(() => {
if (typeof window === 'undefined') return;
if (followRafRef.current !== null) return;
if (stateRef.current !== 'following' || !sessionWorkingRef.current) return;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(true);
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [tickFollow]);
const writeScrollTopInstant = React.useCallback((target: number) => {
const container = scrollRef.current;
if (!container) return;
const max = Math.max(0, container.scrollHeight - container.clientHeight);
const clamped = Math.max(0, Math.min(target, max));
markProgrammaticWrite();
container.scrollTop = clamped;
lastScrollTopRef.current = container.scrollTop;
}, [markProgrammaticWrite]);
const stopSettleBurst = React.useCallback(() => {
if (settleBurstRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(settleBurstRafRef.current);
}
settleBurstRafRef.current = null;
}, []);
const startSettleBurst = React.useCallback(() => {
if (typeof window === 'undefined') return;
stopSettleBurst();
const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS;
const tick = () => {
settleBurstRafRef.current = null;
if (stateRef.current !== 'following') return;
const c = scrollRef.current;
if (!c) return;
const target = Math.max(0, c.scrollHeight - c.clientHeight);
if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) {
markProgrammaticWrite();
c.scrollTop = target;
lastScrollTopRef.current = target;
}
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now < until) {
settleBurstRafRef.current = window.requestAnimationFrame(tick);
}
};
settleBurstRafRef.current = window.requestAnimationFrame(tick);
}, [markProgrammaticWrite, stopSettleBurst]);
const releaseAutoFollow = React.useCallback(() => {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
const releaseFromUserIntent = React.useCallback(() => {
if (stateRef.current === 'following') {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
} else {
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
}
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
const container = scrollRef.current;
setStateValue('following');
lastUserReleaseAtRef.current = 0;
if (!container) return;
if (mode === 'smooth' && sessionWorkingRef.current) {
startFollowLoop();
return;
}
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
if (sessionWorkingRef.current) {
startFollowLoop();
} else {
startSettleBurst();
}
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const pending = pendingSaveRef.current;
if (!pending) return;
const container = scrollRef.current;
if (!container) {
pendingSaveRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
pendingSaveRef.current = null;
}, [updateViewportAnchor]);
const queueSave = React.useCallback(() => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
const container = scrollRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const anchorRatio = scrollHeight > 0
? (scrollTop + clientHeight / 2) / scrollHeight
: 0;
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
pendingSaveRef.current = { sessionId, anchor };
if (saveTimerRef.current !== null) return;
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
flushSave();
}, SAVE_DEBOUNCE_MS);
}, [flushSave]);
const saveSnapshotNow = React.useCallback(() => {
flushSave();
}, [flushSave]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return false;
const container = scrollRef.current;
if (!container) {
// ChatViewport not mounted yet (e.g., session still hydrating).
// Record the request so the container-attach effect can replay it.
pendingInitialRestoreRef.current = sessionId;
setStateValue('following');
return false;
}
pendingInitialRestoreRef.current = null;
const saved = useViewportStore.getState().sessionMemoryState.get(sessionId)?.scrollPosition;
if (!saved || isAtBottomSnapshot(saved, isMobile)) {
setStateValue('following');
lastUserReleaseAtRef.current = 0;
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
if (sessionWorkingRef.current) {
startFollowLoop();
}
startSettleBurst();
return false;
}
const savedMaxScroll = Math.max(0, saved.scrollHeight - saved.clientHeight);
const ratio = savedMaxScroll > 0 ? saved.scrollTop / savedMaxScroll : 0;
const currentMaxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const targetTop = Math.round(ratio * currentMaxScroll);
setStateValue('released');
writeScrollTopInstant(targetTop);
const memState = useViewportStore.getState().sessionMemoryState.get(sessionId);
updateViewportAnchor(sessionId, memState?.viewportAnchor ?? 0, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
return true;
}, [isMobile, setStateValue, startFollowLoop, startSettleBurst, updateViewportAnchor, writeScrollTopInstant]);
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
}
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushSave();
stopFollowLoop();
stopSettleBurst();
markProgrammaticWrite();
// Drop any pending restore request inherited from a different session.
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) {
pendingInitialRestoreRef.current = null;
}
}, [currentSessionId, flushSave, markProgrammaticWrite, stopFollowLoop, stopSettleBurst]);
React.useEffect(() => {
if (!sessionIsWorking) {
stopFollowLoop();
} else if (stateRef.current === 'following') {
startFollowLoop();
}
}, [sessionIsWorking, startFollowLoop, stopFollowLoop]);
// Replay a deferred restoreSnapshot once ChatViewport mounts.
React.useEffect(() => {
if (!containerEl) return;
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionId) {
void restoreSnapshot();
}
}, [containerEl, currentSessionId, restoreSnapshot]);
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setIsOverflowing(false);
setShowScrollButton(false);
return;
}
const overflowing = container.scrollHeight > container.clientHeight + 1;
setIsOverflowing(overflowing);
if (!overflowing) {
setShowScrollButton(false);
return;
}
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobile);
setShowScrollButton(showButton);
}, [isMobile]);
const handleScrollEvent = React.useCallback(() => {
const container = scrollRef.current;
if (!container) return;
const programmatic = isInProgrammaticWindow();
const currentTop = container.scrollTop;
const previousTop = lastScrollTopRef.current;
lastScrollTopRef.current = currentTop;
updateOverflowAndButton();
if (programmatic) {
return;
}
if (currentTop < previousTop && stateRef.current === 'following') {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
}
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const inGrace = (now - lastUserReleaseAtRef.current) < REPIN_GRACE_AFTER_RELEASE_MS;
if (stateRef.current === 'released' && isNearBottom(container, isMobile) && !inGrace) {
setStateValue('following');
if (sessionWorkingRef.current) {
startFollowLoop();
}
}
queueSave();
}, [
isInProgrammaticWindow,
isMobile,
queueSave,
setStateValue,
startFollowLoop,
stopFollowLoop,
stopSettleBurst,
updateOverflowAndButton,
]);
React.useEffect(() => {
const container = containerEl;
if (!container) return;
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0) return;
if (targetIsNestedScrollable(container, event.target)) return;
releaseFromUserIntent();
};
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches.item(0);
touchLastY = touch ? touch.clientY : null;
};
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches.item(0);
if (!touch) {
touchLastY = null;
return;
}
const previousY = touchLastY;
touchLastY = touch.clientY;
if (previousY === null) return;
const fingerDelta = touch.clientY - previousY;
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
if (targetIsNestedScrollable(container, event.target)) return;
releaseFromUserIntent();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isReleaseKey(event)) return;
releaseFromUserIntent();
};
const handlePointerDownIntent = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
releaseFromUserIntent();
};
container.addEventListener('scroll', handleScrollEvent, { passive: true });
container.addEventListener('wheel', handleWheel, { passive: true });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
container.addEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', handlePointerDownIntent, true);
}
return () => {
container.removeEventListener('scroll', handleScrollEvent);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
container.removeEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
}
};
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
React.useEffect(() => {
const container = containerEl;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
updateOverflowAndButton();
if (stateRef.current === 'following' && sessionWorkingRef.current) {
startFollowLoop();
}
});
observer.observe(container);
const inner = container.firstElementChild;
if (inner instanceof Element) {
observer.observe(inner);
}
return () => observer.disconnect();
}, [containerEl, startFollowLoop, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
}, [sessionMessageCount, updateOverflowAndButton]);
const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => {
void _reason;
updateOverflowAndButton();
if (stateRef.current === 'following' && sessionWorkingRef.current) {
startFollowLoop();
}
}, [startFollowLoop, updateOverflowAndButton]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const cached = animationHandlersRef.current.get(messageId);
if (cached) return cached;
const kick = () => {
if (stateRef.current === 'following' && sessionWorkingRef.current) {
startFollowLoop();
}
};
const handlers: AnimationHandlers = {
onChunk: kick,
onComplete: () => {
updateOverflowAndButton();
},
onStreamingCandidate: () => {},
onAnimationStart: () => {},
onAnimatedHeightChange: kick,
onReservationCancelled: () => {},
onReasoningBlock: () => {},
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [startFollowLoop, updateOverflowAndButton]);
React.useEffect(() => {
return () => {
stopFollowLoop();
stopSettleBurst();
flushSave();
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [flushSave, stopFollowLoop, stopSettleBurst]);
React.useEffect(() => {
if (!onActiveTurnChange) return;
const container = containerEl;
if (!container) return;
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) return;
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
if (elementByTurnId.get(turnId) !== node) return false;
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) collected.push(node);
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) changed = true;
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) changed = true;
});
});
});
if (changed) spy.markDirty();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const onScroll = () => spy.onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [containerEl, onActiveTurnChange]);
return {
scrollRef,
state,
isPinned: state === 'following',
isOverflowing,
isFollowingProgrammatically,
showScrollButton,
notifyContentChange,
getAnimationHandlers,
goToBottom,
releaseAutoFollow,
saveSnapshotNow,
restoreSnapshot,
};
};