Skip to content

Commit 42ec17f

Browse files
wesbillmanBrain
andauthored
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>
1 parent 67c47de commit 42ec17f

2 files changed

Lines changed: 170 additions & 1 deletion

File tree

desktop/src/features/messages/ui/MessageThreadPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ export function MessageThreadPanel({
665665
return (
666666
<div
667667
className={cn(
668-
"content-visibility-auto-interactive flex flex-col gap-0",
668+
"flex flex-col gap-0",
669669
entry.summary &&
670670
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
671671
)}

desktop/tests/e2e/timeline-no-shift.spec.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,172 @@ test("thread panel late row reflow keeps the reading reply stable", async ({
427427
expect(drift.missingSamples).toBe(0);
428428
expect(drift.maxDrift).toBeLessThanOrEqual(2);
429429
});
430+
431+
test("thread panel stays put while replies stream in mid-scroll", async ({
432+
page,
433+
}, testInfo) => {
434+
testInfo.setTimeout(45_000);
435+
436+
await installMockBridge(page);
437+
await page.goto("/");
438+
await waitForMockTimelineBridge(page);
439+
440+
page.on("console", (msg) => {
441+
if (msg.text().includes("ANCHOR_DEBUG")) console.info(msg.text());
442+
});
443+
444+
const rootId = await page.evaluate(() => {
445+
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
446+
channelName: "general",
447+
content: "thread stream root",
448+
createdAt: 1_700_400_000,
449+
});
450+
if (!root) throw new Error("Failed to seed thread stream root");
451+
452+
for (let index = 0; index < 48; index += 1) {
453+
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
454+
channelName: "general",
455+
content: `thread stream reply ${index}\nsecond line ${index}\nthird line ${index}`,
456+
parentEventId: root.id,
457+
createdAt: 1_700_400_001 + index,
458+
});
459+
}
460+
461+
return root.id;
462+
});
463+
464+
await page.getByTestId("channel-general").click();
465+
await expect(page.getByTestId("chat-title")).toHaveText("general");
466+
467+
const timeline = page.getByTestId("message-timeline");
468+
const summary = timeline.locator(
469+
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
470+
);
471+
await expect(summary).toBeVisible({ timeout: 5_000 });
472+
await summary.click();
473+
474+
const threadPanel = page.getByTestId("message-thread-panel");
475+
await expect(threadPanel).toBeVisible();
476+
const threadBody = threadPanel.getByTestId("message-thread-body");
477+
await expect(threadBody.locator("[data-message-id]").first()).toBeVisible();
478+
await page.waitForFunction(() => {
479+
const element = document.querySelector(
480+
'[data-testid="message-thread-body"]',
481+
) as HTMLDivElement | null;
482+
return element && element.scrollHeight > element.clientHeight + 800;
483+
});
484+
485+
// Park the reader mid-history (a real "reading older replies" position), not
486+
// at the bottom — the at-bottom stick path corrects differently.
487+
await threadBody.evaluate((element) => {
488+
const scroller = element as HTMLDivElement;
489+
scroller.scrollTop = Math.floor(scroller.scrollHeight * 0.4);
490+
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
491+
});
492+
await page.waitForTimeout(100);
493+
494+
const before = await snapshotAnchor(threadBody);
495+
expect(before.anchorId).not.toBe("");
496+
497+
// Scroll-compensated drift probe: a row's position in *content* coordinates
498+
// (viewport-top offset + scrollTop) is invariant unless content ABOVE it
499+
// changes height. This isolates involuntary jumps (the bug) from the
500+
// reader's own deliberate scroll, which the shared viewport-top sampler
501+
// cannot distinguish.
502+
await threadBody.evaluate((element, anchorId) => {
503+
const scroller = element as HTMLDivElement;
504+
const win = window as typeof window & {
505+
__THREAD_STREAM_PROBE__?: {
506+
stop: boolean;
507+
maxDrift: number;
508+
samples: number;
509+
baseline: number | null;
510+
};
511+
};
512+
const probe = { stop: false, maxDrift: 0, samples: 0, baseline: null };
513+
win.__THREAD_STREAM_PROBE__ = probe;
514+
const contentTop = () => {
515+
const row = scroller.querySelector<HTMLElement>(
516+
`[data-message-id="${CSS.escape(anchorId)}"]`,
517+
);
518+
if (!row) return null;
519+
return (
520+
row.getBoundingClientRect().top -
521+
scroller.getBoundingClientRect().top +
522+
scroller.scrollTop
523+
);
524+
};
525+
const sample = () => {
526+
if (probe.stop) return;
527+
const top = contentTop();
528+
if (top !== null) {
529+
if (probe.baseline === null) probe.baseline = top;
530+
probe.maxDrift = Math.max(
531+
probe.maxDrift,
532+
Math.abs(top - probe.baseline),
533+
);
534+
}
535+
probe.samples += 1;
536+
requestAnimationFrame(sample);
537+
};
538+
requestAnimationFrame(sample);
539+
}, before.anchorId);
540+
541+
// Drive a brisk upward wheel scroll while new replies stream into the open
542+
// thread on the live-event path — Wes's exact symptom: scrolling a live
543+
// thread while replies arrive. The reading row's CONTENT position must hold;
544+
// the reader's scroll is compensated out, so any drift here is the bug.
545+
for (let batch = 0; batch < 6; batch += 1) {
546+
await threadBody.evaluate((element) => {
547+
const scroller = element as HTMLDivElement;
548+
const PX = 40;
549+
scroller.dispatchEvent(
550+
new WheelEvent("wheel", {
551+
deltaY: -PX,
552+
bubbles: true,
553+
cancelable: true,
554+
}),
555+
);
556+
scroller.scrollTop = Math.max(0, scroller.scrollTop - PX);
557+
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
558+
});
559+
await page.evaluate(
560+
({ rootEventId, index }) => {
561+
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
562+
channelName: "general",
563+
content: `thread stream live reply ${index}\nsecond line ${index}`,
564+
parentEventId: rootEventId,
565+
createdAt: 1_700_400_100 + index,
566+
});
567+
},
568+
{ rootEventId: rootId, index: batch },
569+
);
570+
await page.waitForTimeout(80);
571+
}
572+
573+
await page.waitForTimeout(300);
574+
575+
const drift = await threadBody.evaluate((element) => {
576+
const win = window as typeof window & {
577+
__THREAD_STREAM_PROBE__?: {
578+
stop: boolean;
579+
maxDrift: number;
580+
samples: number;
581+
};
582+
};
583+
const probe = win.__THREAD_STREAM_PROBE__;
584+
if (!probe) throw new Error("no thread stream probe installed");
585+
probe.stop = true;
586+
return {
587+
maxDrift: probe.maxDrift,
588+
samples: probe.samples,
589+
scrollTop: (element as HTMLDivElement).scrollTop,
590+
};
591+
});
592+
console.info("thread-panel-stream-scroll result", JSON.stringify(drift));
593+
expect(drift.samples).toBeGreaterThan(0);
594+
// The reader is driving the scroll; streamed replies append below the reading
595+
// row. In content coordinates the row must not move. Any material drift here
596+
// is the "content jumps around like crazy" bug.
597+
expect(drift.maxDrift).toBeLessThanOrEqual(2);
598+
});

0 commit comments

Comments
 (0)