Skip to content

Commit bb0c0b3

Browse files
authored
🤖 fix: coalesce ResizeObserver scrolls to prevent 1px visual flakiness (#1264)
The useAutoScroll hook's ResizeObserver callback was using an `if (rafIdRef.current !== null) return` guard for coalescing, but the stories were still seeing 1px scroll offsets during visual regression tests. ## Changes - Use `??=` operator for cleaner RAF coalescing in ResizeObserver callback - Simplify story helpers to use single RAF wait after message load - Remove complex multi-frame scroll stabilization from stories The production code now properly coalesces all resize events within a frame into a single scroll operation, making scroll positions deterministic for visual regression testing. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent dad982c commit bb0c0b3

File tree

4 files changed

+38
-37
lines changed

4 files changed

+38
-37
lines changed

‎src/browser/hooks/useAutoScroll.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ export function useAutoScroll() {
2626
const autoScrollRef = useRef<boolean>(true);
2727
// Track the ResizeObserver so we can disconnect it when the element unmounts
2828
const observerRef = useRef<ResizeObserver | null>(null);
29+
// Track pending RAF to coalesce rapid resize events
30+
const rafIdRef = useRef<number | null>(null);
2931

3032
// Sync ref with state to ensure callbacks always have latest value
3133
autoScrollRef.current = autoScroll;
32-
// Track pending RAF to coalesce rapid resize events
33-
const rafIdRef = useRef<number | null>(null);
3434

3535
// Callback ref for the inner content wrapper - sets up ResizeObserver when element mounts.
3636
// ResizeObserver fires when the content size changes (Shiki highlighting, Mermaid, images, etc.),
@@ -52,10 +52,10 @@ export function useAutoScroll() {
5252
// Skip if auto-scroll is disabled (user scrolled up)
5353
if (!autoScrollRef.current || !contentRef.current) return;
5454

55-
// Defer layout read to next frame to avoid forcing synchronous layout
56-
// during React's commit phase (which can cause 50-85ms layout thrashing)
57-
if (rafIdRef.current !== null) return; // Coalesce rapid calls
58-
rafIdRef.current = requestAnimationFrame(() => {
55+
// Coalesce all resize events in a frame into one scroll operation.
56+
// Without this, rapid resize events (Shiki highlighting, etc.) cause
57+
// multiple scrolls per frame with slightly different scrollHeight values.
58+
rafIdRef.current ??= requestAnimationFrame(() => {
5959
rafIdRef.current = null;
6060
if (autoScrollRef.current && contentRef.current) {
6161
contentRef.current.scrollTop = contentRef.current.scrollHeight;

‎src/browser/stories/App.bash.stories.tsx‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,20 @@ import {
1717
createBashBackgroundTerminateTool,
1818
} from "./mockFactory";
1919
import { setupSimpleChatStory } from "./storyHelpers";
20-
import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js";
20+
import {
21+
blurActiveElement,
22+
waitForChatInputAutofocusDone,
23+
waitForChatMessagesLoaded,
24+
} from "./storyPlayHelpers.js";
2125
import { userEvent, waitFor } from "@storybook/test";
2226

2327
/**
2428
* Helper to expand all bash tool calls in a story.
2529
* Waits for messages to load, then clicks on the â–¶ expand icons to expand tool details.
2630
*/
2731
async function expandAllBashTools(canvasElement: HTMLElement) {
28-
// Wait for messages to finish loading (non-racy: uses actual loading state)
29-
await waitFor(
30-
() => {
31-
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
32-
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
33-
throw new Error("Messages not loaded yet");
34-
}
35-
},
36-
{ timeout: 5000 }
37-
);
32+
// Wait for messages to finish loading
33+
await waitForChatMessagesLoaded(canvasElement);
3834

3935
// Now find and expand all tool icons (scoped to the message window so we don't click unrelated â–¶)
4036
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
@@ -64,6 +60,9 @@ async function expandAllBashTools(canvasElement: HTMLElement) {
6460
}
6561
}
6662

63+
// One RAF to let any pending coalesced scroll complete after tool expansion
64+
await new Promise((r) => requestAnimationFrame(r));
65+
6766
// Avoid leaving focus on a tool header.
6867
await waitForChatInputAutofocusDone(canvasElement);
6968
blurActiveElement();

‎src/browser/stories/App.markdown.stories.tsx‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,7 @@
55
import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
66
import { STABLE_TIMESTAMP, createUserMessage, createAssistantMessage } from "./mockFactory";
77
import { expect, waitFor } from "@storybook/test";
8-
9-
async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Promise<void> {
10-
await waitFor(
11-
() => {
12-
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
13-
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
14-
throw new Error("Messages not loaded yet");
15-
}
16-
},
17-
{ timeout: 5000 }
18-
);
19-
}
8+
import { waitForChatMessagesLoaded } from "./storyPlayHelpers";
209

2110
import { setupSimpleChatStory } from "./storyHelpers";
2211

@@ -273,14 +262,6 @@ export const CodeBlocks: AppStory = {
273262
{ timeout: 5000 }
274263
);
275264

276-
// Scroll to bottom and wait a frame for ResizeObserver to settle.
277-
// Shiki highlighting can trigger useAutoScroll's ResizeObserver, causing scroll jitter.
278-
const scrollContainer = canvasElement.querySelector('[data-testid="message-window"]');
279-
if (scrollContainer) {
280-
scrollContainer.scrollTop = scrollContainer.scrollHeight;
281-
await new Promise((r) => requestAnimationFrame(r));
282-
}
283-
284265
const url = "https://github.com/coder/mux/pull/new/chat-autocomplete-b24r";
285266
const container = await waitFor(
286267
() => {

‎src/browser/stories/storyPlayHelpers.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
import { waitFor } from "@storybook/test";
22

3+
/**
4+
* Wait for chat messages to finish loading.
5+
*
6+
* Waits for data-loaded="true" on the message window, then one RAF
7+
* to let any pending coalesced scroll from useAutoScroll complete.
8+
*/
9+
export async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Promise<void> {
10+
await waitFor(
11+
() => {
12+
const messageWindow = canvasElement.querySelector('[data-testid="message-window"]');
13+
if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") {
14+
throw new Error("Messages not loaded yet");
15+
}
16+
},
17+
{ timeout: 5000 }
18+
);
19+
20+
// One RAF to let any pending coalesced scroll complete
21+
await new Promise((r) => requestAnimationFrame(r));
22+
}
23+
324
export async function waitForChatInputAutofocusDone(canvasElement: HTMLElement): Promise<void> {
425
await waitFor(
526
() => {

0 commit comments

Comments
 (0)