fix(thread): stop mid-scroll content jump in live threads (#1397)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-30 11:04:34 -06:00
committed by GitHub
parent 67c47de69c
commit 42ec17f137
2 changed files with 170 additions and 1 deletions

View File

@@ -665,7 +665,7 @@ export function MessageThreadPanel({
return (
<div
className={cn(
"content-visibility-auto-interactive flex flex-col gap-0",
"flex flex-col gap-0",
entry.summary &&
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}

View File

@@ -427,3 +427,172 @@ test("thread panel late row reflow keeps the reading reply stable", async ({
expect(drift.missingSamples).toBe(0);
expect(drift.maxDrift).toBeLessThanOrEqual(2);
});
test("thread panel stays put while replies stream in mid-scroll", async ({
page,
}, testInfo) => {
testInfo.setTimeout(45_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
page.on("console", (msg) => {
if (msg.text().includes("ANCHOR_DEBUG")) console.info(msg.text());
});
const rootId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "thread stream root",
createdAt: 1_700_400_000,
});
if (!root) throw new Error("Failed to seed thread stream root");
for (let index = 0; index < 48; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `thread stream reply ${index}\nsecond line ${index}\nthird line ${index}`,
parentEventId: root.id,
createdAt: 1_700_400_001 + index,
});
}
return root.id;
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
const summary = timeline.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(summary).toBeVisible({ timeout: 5_000 });
await summary.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadBody = threadPanel.getByTestId("message-thread-body");
await expect(threadBody.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-thread-body"]',
) as HTMLDivElement | null;
return element && element.scrollHeight > element.clientHeight + 800;
});
// Park the reader mid-history (a real "reading older replies" position), not
// at the bottom — the at-bottom stick path corrects differently.
await threadBody.evaluate((element) => {
const scroller = element as HTMLDivElement;
scroller.scrollTop = Math.floor(scroller.scrollHeight * 0.4);
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(100);
const before = await snapshotAnchor(threadBody);
expect(before.anchorId).not.toBe("");
// Scroll-compensated drift probe: a row's position in *content* coordinates
// (viewport-top offset + scrollTop) is invariant unless content ABOVE it
// changes height. This isolates involuntary jumps (the bug) from the
// reader's own deliberate scroll, which the shared viewport-top sampler
// cannot distinguish.
await threadBody.evaluate((element, anchorId) => {
const scroller = element as HTMLDivElement;
const win = window as typeof window & {
__THREAD_STREAM_PROBE__?: {
stop: boolean;
maxDrift: number;
samples: number;
baseline: number | null;
};
};
const probe = { stop: false, maxDrift: 0, samples: 0, baseline: null };
win.__THREAD_STREAM_PROBE__ = probe;
const contentTop = () => {
const row = scroller.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(anchorId)}"]`,
);
if (!row) return null;
return (
row.getBoundingClientRect().top -
scroller.getBoundingClientRect().top +
scroller.scrollTop
);
};
const sample = () => {
if (probe.stop) return;
const top = contentTop();
if (top !== null) {
if (probe.baseline === null) probe.baseline = top;
probe.maxDrift = Math.max(
probe.maxDrift,
Math.abs(top - probe.baseline),
);
}
probe.samples += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, before.anchorId);
// Drive a brisk upward wheel scroll while new replies stream into the open
// thread on the live-event path — Wes's exact symptom: scrolling a live
// thread while replies arrive. The reading row's CONTENT position must hold;
// the reader's scroll is compensated out, so any drift here is the bug.
for (let batch = 0; batch < 6; batch += 1) {
await threadBody.evaluate((element) => {
const scroller = element as HTMLDivElement;
const PX = 40;
scroller.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -PX,
bubbles: true,
cancelable: true,
}),
);
scroller.scrollTop = Math.max(0, scroller.scrollTop - PX);
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.evaluate(
({ rootEventId, index }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `thread stream live reply ${index}\nsecond line ${index}`,
parentEventId: rootEventId,
createdAt: 1_700_400_100 + index,
});
},
{ rootEventId: rootId, index: batch },
);
await page.waitForTimeout(80);
}
await page.waitForTimeout(300);
const drift = await threadBody.evaluate((element) => {
const win = window as typeof window & {
__THREAD_STREAM_PROBE__?: {
stop: boolean;
maxDrift: number;
samples: number;
};
};
const probe = win.__THREAD_STREAM_PROBE__;
if (!probe) throw new Error("no thread stream probe installed");
probe.stop = true;
return {
maxDrift: probe.maxDrift,
samples: probe.samples,
scrollTop: (element as HTMLDivElement).scrollTop,
};
});
console.info("thread-panel-stream-scroll result", JSON.stringify(drift));
expect(drift.samples).toBeGreaterThan(0);
// The reader is driving the scroll; streamed replies append below the reading
// row. In content coordinates the row must not move. Any material drift here
// is the "content jumps around like crazy" bug.
expect(drift.maxDrift).toBeLessThanOrEqual(2);
});