Skip to content

Commit 4f7f57a

Browse files
samejrclaude
andcommitted
Stop sessions detail observer thrash + fix auto-scroll race
- Observer + scroll listener now attach once on mount instead of on every streaming chunk (deps `[]`, not `[merged.length]`). Earlier this caused sustained CPU during fast streams. - Auto-scroll write deferred to the next animation frame so it runs after the virtualizer's layout-effect measures `getTotalSize()`. Without rAF the imperative scrollTop write races the virtualizer and the user gets "stuck half a row up" during streaming. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e8497d commit 4f7f57a

1 file changed

Lines changed: 22 additions & 14 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ function RawConversationView({
326326
[getCompactText]
327327
);
328328

329+
// Observer + scroll listener attach once on mount. Earlier this effect
330+
// depended on `merged.length` and tore down / re-attached on every
331+
// streaming chunk, thrashing CPU on hot sessions.
329332
useEffect(() => {
330333
const bottomElement = bottomRef.current;
331334
const scrollElement = scrollRef.current;
@@ -338,38 +341,43 @@ function RawConversationView({
338341
},
339342
{ root: scrollElement, threshold: 0.1, rootMargin: "0px" }
340343
);
341-
342344
observer.observe(bottomElement);
343345

344346
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
345347
const handleScroll = () => {
346-
if (!scrollElement || !bottomElement) return;
347348
if (scrollTimeout) clearTimeout(scrollTimeout);
348349
scrollTimeout = setTimeout(() => {
349350
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
350-
const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
351-
setIsAtBottom(isNearBottom);
351+
setIsAtBottom(scrollElement.scrollHeight - scrollBottom < 50);
352352
}, 100);
353353
};
354-
355354
scrollElement.addEventListener("scroll", handleScroll);
356-
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
357-
const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
358-
setIsAtBottom(isNearBottom);
355+
356+
// Initial state — match the post-mount scroll position.
357+
const initialScrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
358+
setIsAtBottom(scrollElement.scrollHeight - initialScrollBottom < 50);
359359

360360
return () => {
361361
observer.disconnect();
362362
scrollElement.removeEventListener("scroll", handleScroll);
363363
if (scrollTimeout) clearTimeout(scrollTimeout);
364364
};
365-
}, [merged.length]);
365+
}, []);
366366

367+
// Stick-to-bottom on new content. Defer the scroll write to the next
368+
// animation frame so the virtualizer's layout effect has run and
369+
// `scrollHeight` reflects the updated content. Without rAF, the write
370+
// races the virtualizer's `getTotalSize()` update and the user gets
371+
// "stuck half a row up" while streaming.
367372
useEffect(() => {
368-
if (isAtBottom && scrollRef.current) {
369-
const currentScrollLeft = scrollRef.current.scrollLeft;
370-
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
371-
scrollRef.current.scrollLeft = currentScrollLeft;
372-
}
373+
if (!isAtBottom || !scrollRef.current) return;
374+
const el = scrollRef.current;
375+
const raf = requestAnimationFrame(() => {
376+
const currentScrollLeft = el.scrollLeft;
377+
el.scrollTop = el.scrollHeight;
378+
el.scrollLeft = currentScrollLeft;
379+
});
380+
return () => cancelAnimationFrame(raf);
373381
}, [merged, isAtBottom]);
374382

375383
const rowVirtualizer = useVirtualizer({

0 commit comments

Comments
 (0)