From d8072e8c0571629a2367e8677a48253402bca337 Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Thu, 1 Oct 2026 08:27:22 -0600 Subject: [PATCH] fix(threads): keep thread history painted after scroll corrections Mac WebKit can leave a scroller unpainted when native momentum overlaps an instant programmatic scroll correction (WebKit 262287 class). The thread view hit this when older replies were prepended mid-flick: the anchor correction landed, DOM coverage was complete, and the viewport stayed blank for most of a second. Apply the thread's two automatic corrections through a small helper that interrupts momentum the same way the Virtua patch does for ChannelTimeline: on Mac WebKit only, hide vertical overflow for one task around the correction, then restore the prior declaration. The thread scroller keeps a stable scrollbar gutter so the toggle cannot re-wrap rows. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- src/features/messages/Messages.module.css | 2 + src/features/messages/ThreadPanel.tsx | 8 +- .../messages/scroll-correction.test.ts | 91 +++++++++++++++++++ src/features/messages/scroll-correction.ts | 37 ++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/features/messages/scroll-correction.test.ts create mode 100644 src/features/messages/scroll-correction.ts diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 47fd63118..6b343e8f4 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -579,6 +579,8 @@ html[data-keyboard-navigation] .attachmentImage:focus-visible { overflow: auto; padding: 0 var(--space-panel-inset); padding-bottom: var(--space-panel-inset); + /* Older-history corrections hide overflow for a task (scroll-correction.ts). */ + scrollbar-gutter: stable; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--text) 20%, transparent) transparent; } diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index da47b80a9..83e0d28f4 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -36,6 +36,7 @@ import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; import { useKnownAgentPubkeys } from "../agents/use-known"; import { JumpToLatestButton } from "./JumpToLatestButton"; +import { correctScrollTop } from "./scroll-correction"; export type ThreadPanelProps = { extensions?: ConversationExtensions | undefined; @@ -584,14 +585,17 @@ function ThreadMessages({ ...element.querySelectorAll("[data-message-id]"), ].find((row) => row.dataset.messageId === anchor.id); if (row) - element.scrollTop += row.getBoundingClientRect().top - anchor.top; + correctScrollTop( + element, + row.getBoundingClientRect().top - anchor.top, + ); if (snapshot.status !== "loading") olderAnchor.current = undefined; } } if (targetAnchor.current !== undefined) { const offset = selectedOffset(); if (offset !== undefined) { - element.scrollTop += offset - targetAnchor.current; + correctScrollTop(element, offset - targetAnchor.current); targetAnchor.current = offset; follow.current = false; } diff --git a/src/features/messages/scroll-correction.test.ts b/src/features/messages/scroll-correction.test.ts new file mode 100644 index 000000000..701111858 --- /dev/null +++ b/src/features/messages/scroll-correction.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { correctScrollTop } from "./scroll-correction"; + +const stubNavigator = (platform: string, vendor: string, maxTouchPoints = 0) => + vi.stubGlobal("navigator", { platform, vendor, maxTouchPoints }); + +function scroller() { + const element = document.createElement("section"); + let top = 100; + Object.defineProperty(element, "scrollTop", { + get: () => top, + set: (value: number) => { + top = value; + }, + }); + return element; +} + +const overflow = (element: HTMLElement) => [ + element.style.getPropertyValue("overflow-y"), + element.style.getPropertyPriority("overflow-y"), +]; + +beforeEach(() => { + vi.useFakeTimers(); + stubNavigator("MacIntel", "Apple Computer, Inc."); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("interrupts Mac WebKit momentum for one task around the correction", () => { + const element = scroller(); + element.style.setProperty("overflow-y", "scroll", "important"); + + correctScrollTop(element, 40); + + expect(element.scrollTop).toBe(140); + expect(overflow(element)).toEqual(["hidden", "important"]); + vi.runAllTimers(); + expect(overflow(element)).toEqual(["scroll", "important"]); +}); + +it("restores the original declaration across overlapping corrections", () => { + const element = scroller(); + + correctScrollTop(element, 40); + correctScrollTop(element, -10); + + expect(element.scrollTop).toBe(130); + expect(overflow(element)).toEqual(["hidden", "important"]); + vi.runAllTimers(); + expect(element.getAttribute("style")).toBe(""); +}); + +it("keeps a declaration written after the correction", () => { + const element = scroller(); + + correctScrollTop(element, 40); + element.style.overflowY = "auto"; + vi.runAllTimers(); + + expect(element.style.getPropertyValue("overflow-y")).toBe("auto"); +}); + +it.each([ + ["Chromium", "MacIntel", "Google Inc.", 0], + ["desktop-mode iPad", "MacIntel", "Apple Computer, Inc.", 5], + ["non-Mac WebKit", "Linux x86_64", "Apple Computer, Inc.", 0], +])("leaves %s momentum alone", (_, platform, vendor, touchPoints) => { + stubNavigator(platform, vendor, touchPoints); + const element = scroller(); + + correctScrollTop(element, 40); + + expect(element.scrollTop).toBe(140); + expect(element.getAttribute("style")).toBeNull(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("ignores a zero correction", () => { + const element = scroller(); + + correctScrollTop(element, 0); + + expect(element.scrollTop).toBe(100); + expect(element.getAttribute("style")).toBeNull(); +}); diff --git a/src/features/messages/scroll-correction.ts b/src/features/messages/scroll-correction.ts new file mode 100644 index 000000000..ba0fe818b --- /dev/null +++ b/src/features/messages/scroll-correction.ts @@ -0,0 +1,37 @@ +const restores = new WeakMap void>(); +const macWebKit = () => + navigator.platform === "MacIntel" && + !navigator.maxTouchPoints && // desktop-mode iPad keeps its momentum policy + navigator.vendor === "Apple Computer, Inc."; + +/** + * Applies an automatic scroll correction. Mac WebKit can leave the corrected + * viewport unpainted while native momentum continues, so stop the momentum the + * same way the Virtua patch does (patches/README.md): hide vertical overflow for + * one task. The scroller must keep its inline size (`scrollbar-gutter: stable`). + */ +export function correctScrollTop(element: HTMLElement, delta: number) { + if (!delta) return; + if (macWebKit()) { + // Corrections in one task share the original declaration. + restores.get(element)?.(); + const { style } = element; + const value = style.getPropertyValue("overflow-y"); + const priority = style.getPropertyPriority("overflow-y"); + style.setProperty("overflow-y", "hidden", "important"); + const restore = () => { + clearTimeout(timer); + restores.delete(element); + if ( + style.getPropertyValue("overflow-y") === "hidden" && + style.getPropertyPriority("overflow-y") === "important" + ) + value + ? style.setProperty("overflow-y", value, priority) + : style.removeProperty("overflow-y"); + }; + const timer = setTimeout(restore); + restores.set(element, restore); + } + element.scrollTop += delta; +}