From b718e7a67c7f4e8d39e6c6c860f817699179393a Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sat, 13 Jun 2026 21:09:06 -0400 Subject: [PATCH 1/6] Fix scroll position when loading older messages Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- desktop/playwright.config.ts | 1 + .../features/messages/ui/MessageTimeline.tsx | 2 - .../messages/ui/useLoadOlderOnScroll.ts | 282 ++++++++++++++++-- desktop/src/shared/ui/markdown.tsx | 31 +- desktop/src/testing/e2eBridge.ts | 100 ++++++- desktop/tests/e2e/scroll-history.spec.ts | 220 ++++++++++++++ desktop/tests/helpers/bridge.ts | 1 + 7 files changed, 594 insertions(+), 43 deletions(-) create mode 100644 desktop/tests/e2e/scroll-history.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3dd4e6c2f..c79265a38 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/scroll-history.spec.ts", "**/channels.spec.ts", "**/badge.spec.ts", "**/channel-browser.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1fa347a20..b02c921d8 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -137,7 +137,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({ highlightedMessageId, isAtBottom, newMessageCount, - restoreScrollPosition, scrollToBottom, syncScrollState, } = useTimelineScrollManager({ @@ -177,7 +176,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({ fetchOlder, hasOlderMessages, isLoading, - restoreScrollPosition, scrollContainerRef, sentinelRef: topSentinelRef, }); diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 73efbd3fc..00f3e7ef9 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -4,39 +4,102 @@ type UseLoadOlderOnScrollOptions = { fetchOlder?: () => Promise; hasOlderMessages: boolean; isLoading: boolean; - restoreScrollPosition: (scrollTop: number) => void; scrollContainerRef: React.RefObject; sentinelRef: React.RefObject; }; +type ScrollAnchor = { + id: string; + top: number; +}; + +type ActiveScrollAnchorLock = { + cleanup: () => void; + restore: () => void; + scheduleReleaseAfterQuietLayout: () => void; + trackPendingImages: () => void; +}; + +function captureScrollAnchor(container: HTMLDivElement): ScrollAnchor | null { + const containerRect = container.getBoundingClientRect(); + const messages = Array.from( + container.querySelectorAll("[data-message-id]"), + ); + + for (const message of messages) { + const rect = message.getBoundingClientRect(); + if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) { + continue; + } + + return { + id: message.dataset.messageId ?? "", + top: rect.top - containerRect.top, + }; + } + + return null; +} + +function restoreScrollAnchor( + container: HTMLDivElement, + anchor: ScrollAnchor | null, +): number | null { + if (!anchor?.id) { + return null; + } + + const message = container.querySelector( + `[data-message-id="${CSS.escape(anchor.id)}"]`, + ); + if (!message) { + return null; + } + + const currentTop = + message.getBoundingClientRect().top - container.getBoundingClientRect().top; + container.scrollTop += currentTop - anchor.top; + return container.scrollTop; +} + /** * Triggers `fetchOlder` when a sentinel element near the top of the scroll - * container enters the viewport, then restores the scroll position so the - * visible content doesn't jump. + * container enters the viewport, then keeps the viewport locked to the first + * visible message until the prepended content and its media have settled. */ export function useLoadOlderOnScroll({ fetchOlder, hasOlderMessages, isLoading, - restoreScrollPosition, scrollContainerRef, sentinelRef, }: UseLoadOlderOnScrollOptions) { - const restoreScrollPositionRef = React.useRef(restoreScrollPosition); + const activeLockRef = React.useRef(null); + const loadStateRef = React.useRef({ + fetchOlder, + hasOlderMessages, + isLoading, + }); + React.useEffect(() => { - restoreScrollPositionRef.current = restoreScrollPosition; + loadStateRef.current = { fetchOlder, hasOlderMessages, isLoading }; + }, [fetchOlder, hasOlderMessages, isLoading]); + + React.useLayoutEffect(() => { + const lock = activeLockRef.current; + if (!lock) { + return; + } + + lock.trackPendingImages(); + lock.restore(); + lock.scheduleReleaseAfterQuietLayout(); }); React.useEffect(() => { const sentinel = sentinelRef.current; const container = scrollContainerRef.current; - if ( - !sentinel || - !container || - !fetchOlder || - isLoading || - !hasOlderMessages - ) { + if (!sentinel || !container) { return; } @@ -50,24 +113,186 @@ export function useLoadOlderOnScroll({ currentObserver = new IntersectionObserver( ([entry]) => { - if (!entry.isIntersecting || disposed) { + const { fetchOlder, hasOlderMessages, isLoading } = + loadStateRef.current; + if ( + !entry.isIntersecting || + disposed || + activeLockRef.current || + !fetchOlder || + isLoading || + !hasOlderMessages + ) { return; } currentObserver?.disconnect(); - const previousHeight = container.scrollHeight; - const previousScrollTop = container.scrollTop; - void fetchOlder().then(() => { - requestAnimationFrame(() => { + let anchor = captureScrollAnchor(container); + let fetchSettled = false; + let pendingImages = 0; + let restoreFrame: number | null = null; + let releaseTimer: number | null = null; + let maxReleaseTimer: number | null = null; + let resizeObserver: ResizeObserver | null = null; + let mutationObserver: MutationObserver | null = null; + let isRestoringAnchor = false; + let lastRestoredScrollTop: number | null = null; + const trackedImages = new WeakSet(); + const imageCleanups: Array<() => void> = []; + + const restoreAcrossFrames = (remainingFrames: number) => { + isRestoringAnchor = true; + lastRestoredScrollTop = restoreScrollAnchor(container, anchor); + + if (remainingFrames <= 0) { requestAnimationFrame(() => { - const newHeight = container.scrollHeight; - const delta = newHeight - previousHeight; - if (delta > 0) { - restoreScrollPositionRef.current(previousScrollTop + delta); - } - observe(); + isRestoringAnchor = false; }); + return; + } + + restoreFrame = requestAnimationFrame(() => { + restoreFrame = null; + restoreAcrossFrames(remainingFrames - 1); + }); + }; + + const scheduleRestore = () => { + if (restoreFrame !== null) { + return; + } + + restoreFrame = requestAnimationFrame(() => { + restoreFrame = null; + restoreAcrossFrames(2); + }); + }; + + const cleanupLock = () => { + container.removeEventListener("scroll", updateAnchor); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + for (const cleanupImage of imageCleanups) { + cleanupImage(); + } + imageCleanups.length = 0; + if (restoreFrame !== null) { + cancelAnimationFrame(restoreFrame); + restoreFrame = null; + } + if (releaseTimer !== null) { + window.clearTimeout(releaseTimer); + releaseTimer = null; + } + if (maxReleaseTimer !== null) { + window.clearTimeout(maxReleaseTimer); + maxReleaseTimer = null; + } + if (activeLockRef.current === lock) { + activeLockRef.current = null; + } + }; + + const releaseLock = () => { + cleanupLock(); + observe(); + }; + + const scheduleReleaseAfterQuietLayout = () => { + if (!fetchSettled || pendingImages > 0) { + return; + } + if (releaseTimer !== null) { + window.clearTimeout(releaseTimer); + } + releaseTimer = window.setTimeout(releaseLock, 250); + }; + + const settleImage = () => { + pendingImages = Math.max(0, pendingImages - 1); + scheduleRestore(); + scheduleReleaseAfterQuietLayout(); + }; + + const trackPendingImages = () => { + const images = Array.from(container.querySelectorAll("img")); + for (const image of images) { + if (trackedImages.has(image)) { + continue; + } + trackedImages.add(image); + if (image.complete) { + continue; + } + + pendingImages += 1; + image.addEventListener("load", settleImage, { once: true }); + image.addEventListener("error", settleImage, { once: true }); + imageCleanups.push(() => { + image.removeEventListener("load", settleImage); + image.removeEventListener("error", settleImage); + }); + } + }; + + const updateAnchor = () => { + if ( + isRestoringAnchor && + container.scrollTop === lastRestoredScrollTop + ) { + return; + } + anchor = captureScrollAnchor(container) ?? anchor; + }; + + const lock: ActiveScrollAnchorLock = { + cleanup: cleanupLock, + restore: () => { + restoreAcrossFrames(2); + }, + scheduleReleaseAfterQuietLayout, + trackPendingImages, + }; + + container.addEventListener("scroll", updateAnchor, { passive: true }); + activeLockRef.current = lock; + + const content = container.firstElementChild; + if (content instanceof HTMLElement) { + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => { + scheduleRestore(); + scheduleReleaseAfterQuietLayout(); + }); + resizeObserver.observe(content); + } + + if (typeof MutationObserver !== "undefined") { + mutationObserver = new MutationObserver(() => { + trackPendingImages(); + scheduleRestore(); + scheduleReleaseAfterQuietLayout(); + }); + mutationObserver.observe(content, { + childList: true, + subtree: true, + }); + } + } + + void fetchOlder().finally(() => { + fetchSettled = true; + requestAnimationFrame(() => { + if (disposed) { + cleanupLock(); + return; + } + + trackPendingImages(); + scheduleRestore(); + scheduleReleaseAfterQuietLayout(); + maxReleaseTimer = window.setTimeout(releaseLock, 10_000); }); }); }, @@ -80,13 +305,8 @@ export function useLoadOlderOnScroll({ observe(); return () => { disposed = true; + activeLockRef.current?.cleanup(); currentObserver?.disconnect(); }; - }, [ - fetchOlder, - hasOlderMessages, - isLoading, - scrollContainerRef, - sentinelRef, - ]); + }, [scrollContainerRef, sentinelRef]); } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b21b45e73..1396cf0ae 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -121,16 +121,28 @@ function useStableArray(arr: T[]): T[] { return ref.current; } -function aspectRatioFromDim(dim?: string): number | undefined { +function dimensionsFromDim( + dim?: string, +): { width: number; height: number } | undefined { if (!dim) return undefined; const match = dim.match(/^(\d+)x(\d+)$/i); if (!match) return undefined; const width = Number(match[1]); const height = Number(match[2]); - if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) { + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { return undefined; } - return width / height; + return { width, height }; +} + +function aspectRatioFromDim(dim?: string): number | undefined { + const dimensions = dimensionsFromDim(dim); + return dimensions ? dimensions.width / dimensions.height : undefined; } /** @@ -231,10 +243,12 @@ type MarkdownVariant = "default" | "compact" | "tight"; */ function ImageBlock({ alt, + dimensions, resolvedSrc, src, }: { alt: string | undefined; + dimensions: { width: number; height: number } | undefined; resolvedSrc: string | undefined; src: string | undefined; }) { @@ -290,7 +304,9 @@ function ImageBlock({ {alt} setLightboxOpen(true)} onContextMenuCapture={handleContextMenu} /> @@ -899,7 +915,14 @@ function createMarkdownComponents( } return ( - + ); }, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b8afdef20..4cfb69649 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -74,6 +74,7 @@ type E2eConfig = { profileReadError?: string; profileUpdateError?: string; searchProfiles?: MockSearchProfileSeed[]; + historyDelayMs?: number; updateChannelDelayMs?: number; stallWebsocketSends?: boolean; userSearchDelayMs?: number; @@ -458,6 +459,8 @@ type MockFilter = { "#h"?: string[]; authors?: string[]; kinds?: number[]; + limit?: number; + until?: number; }; type MockSocket = { @@ -585,6 +588,14 @@ declare global { channelName: string; pubkey?: string; }) => RelayEvent; + __BUZZ_E2E_PREPEND_MOCK_HISTORY__?: (input: { + channelName: string; + count: number; + startIndex?: number; + lineCount?: number; + createdAtStart?: number; + emit?: boolean; + }) => RelayEvent[]; __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload?: Record, @@ -2192,12 +2203,87 @@ function getMockMessageStore(channelId: string): RelayEvent[] { return seeded; } -function emitMockHistory(socket: MockSocket, subId: string, channelId: string) { - const events = getMockMessageStore(channelId); - for (const event of events) { - sendWsText(socket.handler, ["EVENT", subId, event]); +function prependMockHistory(input: { + channelName: string; + count: number; + startIndex?: number; + lineCount?: number; + createdAtStart?: number; + emit?: boolean; +}) { + const channel = mockChannels.find( + (candidate) => candidate.name === input.channelName, + ); + if (!channel) { + throw new Error(`Unknown mock channel: ${input.channelName}`); + } + + const store = getMockMessageStore(channel.id); + const earliestCreatedAt = store.reduce( + (earliest, event) => Math.min(earliest, event.created_at), + Math.floor(Date.now() / 1000), + ); + const createdAtStart = + input.createdAtStart ?? earliestCreatedAt - input.count - 1; + const startIndex = input.startIndex ?? 0; + const lineCount = input.lineCount ?? 1; + + const events = Array.from({ length: input.count }, (_, offset) => { + const index = startIndex + offset; + const body = Array.from( + { length: lineCount }, + (_unused, lineIndex) => `mock older ${index} line ${lineIndex + 1}`, + ).join("\n"); + + return createMockEvent( + 9, + body, + [["h", channel.id]], + ALICE_PUBKEY, + createdAtStart + offset, + `mock-older-${channel.name}-${index}`.replace(/[^a-zA-Z0-9]/g, ""), + ); + }); + + store.unshift(...events); + store.sort((left, right) => left.created_at - right.created_at); + + if (input.emit) { + for (const event of events) { + emitMockLiveEvent(channel.id, event); + } + } + + return events; +} + +function emitMockHistory( + socket: MockSocket, + subId: string, + channelId: string, + filter: MockFilter = {}, +) { + const events = getMockMessageStore(channelId) + .filter((event) => + filter.until !== undefined ? event.created_at <= filter.until : true, + ) + .sort((left, right) => right.created_at - left.created_at) + .slice(0, filter.limit ?? 50); + + const emit = () => { + for (const event of events) { + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + }; + + const delayMs = getConfig()?.mock?.historyDelayMs ?? 0; + if (delayMs > 0 && subId.startsWith("history-")) { + window.setTimeout(emit, delayMs); + return; } - sendWsText(socket.handler, ["EOSE", subId]); + + emit(); } function emitMockLiveEvent(channelId: string, event: RelayEvent) { @@ -5683,7 +5769,7 @@ function sendToMockSocket(args: { return; } - emitMockHistory(socket, subId, channelId); + emitMockHistory(socket, subId, channelId, filter); return; } @@ -5801,6 +5887,7 @@ export function maybeInstallE2eTauriMocks() { return; } + mockMessages.clear(); resetMockRelayMembers(config); resetMockManagedAgents(config); resetMockPersonas(config); @@ -5842,6 +5929,7 @@ export function maybeInstallE2eTauriMocks() { extraTags, ); }; + window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory; window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => { const channel = mockChannels.find( (candidate) => candidate.name === channelName, diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts new file mode 100644 index 000000000..e808272fa --- /dev/null +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +async function getTimelineMetrics(page: import("@playwright/test").Page) { + return page.getByTestId("message-timeline").evaluate((element) => { + const timeline = element as HTMLDivElement; + + return { + clientHeight: timeline.clientHeight, + scrollHeight: timeline.scrollHeight, + scrollTop: timeline.scrollTop, + }; + }); +} + +async function getFirstVisibleMessage(page: import("@playwright/test").Page) { + return page.getByTestId("message-timeline").evaluate((element) => { + const timeline = element as HTMLDivElement; + const timelineRect = timeline.getBoundingClientRect(); + const messages = Array.from( + timeline.querySelectorAll("[data-message-id]"), + ); + + for (const message of messages) { + const rect = message.getBoundingClientRect(); + if (rect.bottom <= timelineRect.top || rect.top >= timelineRect.bottom) { + continue; + } + + return { + id: message.dataset.messageId ?? "", + text: message.textContent?.replace(/\s+/g, " ").slice(0, 80) ?? "", + top: rect.top - timelineRect.top, + }; + } + + return null; + }); +} + +async function getMessagePosition( + page: import("@playwright/test").Page, + messageId: string, +) { + return page.getByTestId("message-timeline").evaluate((element, id) => { + const timeline = element as HTMLDivElement; + const message = timeline.querySelector( + `[data-message-id="${CSS.escape(id)}"]`, + ); + if (!message) { + return null; + } + + return { + id, + top: + message.getBoundingClientRect().top - + timeline.getBoundingClientRect().top, + }; + }, messageId); +} + +test("preserves user scroll while older channel history loads", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", + ); + + await page.evaluate(() => { + for (let index = 0; index < 40; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `visible current ${index}\nsecond line ${index}`, + }); + } + window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ + channelName: "general", + count: 250, + lineCount: 3, + }); + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toContainText("visible current 39"); + + // Initial load should receive enough history to make the page scrollable. + // Delay only the next history request, so the test isolates pagination while + // the user is actively scrolling. + await page.evaluate(() => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { + ...window.__BUZZ_E2E__?.mock, + historyDelayMs: 1_000, + }, + }; + }); + + await page.waitForFunction(() => { + const element = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return element && element.scrollHeight > element.clientHeight + 1000; + }); + + // Move away from the bottom before jumping near the top; otherwise the + // timeline's sticky-bottom guard can intentionally pin the first upward jump. + const beforeFetch = await getTimelineMetrics(page); + await timeline.evaluate((element) => { + const timelineElement = element as HTMLDivElement; + timelineElement.scrollTop = timelineElement.scrollHeight; + timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(50); + + const nearTop = await timeline.evaluate((element) => { + const timelineElement = element as HTMLDivElement; + timelineElement.scrollTop = 180; + timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); + return timelineElement.scrollTop; + }); + expect(nearTop).toBeLessThan(260); + + await page.waitForTimeout(100); + const duringFetch = await timeline.evaluate((element) => { + const timelineElement = element as HTMLDivElement; + timelineElement.scrollTop = timelineElement.scrollTop + 160; + timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); + return timelineElement.scrollTop; + }); + expect(duringFetch).toBeGreaterThan(nearTop); + const anchorDuringFetch = await getFirstVisibleMessage(page); + expect(anchorDuringFetch).not.toBeNull(); + + await expect + .poll( + async () => { + const [anchor, metrics] = await Promise.all([ + getMessagePosition(page, anchorDuringFetch?.id ?? ""), + getTimelineMetrics(page), + ]); + if (metrics.scrollHeight <= beforeFetch.scrollHeight + 1000) { + return Number.POSITIVE_INFINITY; + } + return anchor + ? Math.abs(anchor.top - (anchorDuringFetch?.top ?? 0)) + : Number.POSITIVE_INFINITY; + }, + { + timeout: 3_000, + }, + ) + .toBeLessThanOrEqual(2); +}); + +const REAL_BUZZ_BUGS_IMAGE_SHA = + "ff2862080bac3d009f97cad4bb94e6efec328eaaee058a405e854acd49fc1483"; +const REAL_BUZZ_BUGS_IMAGE_URL = `https://sprout-oss.stage.blox.sqprod.co/media/${REAL_BUZZ_BUGS_IMAGE_SHA}.png`; +const REAL_BUZZ_BUGS_IMAGE_TAG = [ + "imeta", + `url ${REAL_BUZZ_BUGS_IMAGE_URL}`, + "m image/png", + `x ${REAL_BUZZ_BUGS_IMAGE_SHA}`, + "size 26257", + "dim 951x244", + "filename image.png", +] as string[]; + +test("reserves real buzz-bugs imeta image height before image loads", async ({ + page, +}) => { + await page.route("**/media/**", () => new Promise(() => {})); + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + await page.evaluate( + ({ content, extraTags }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + extraTags, + }); + }, + { + content: `this setting gets reverted on every update\n![image](${REAL_BUZZ_BUGS_IMAGE_URL})`, + extraTags: [REAL_BUZZ_BUGS_IMAGE_TAG], + }, + ); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const image = page.getByAltText("image").last(); + const rect = await image.evaluate((element) => { + const img = element as HTMLImageElement; + const box = img.getBoundingClientRect(); + return { + attrHeight: img.getAttribute("height"), + attrWidth: img.getAttribute("width"), + height: box.height, + offsetHeight: img.offsetHeight, + offsetWidth: img.offsetWidth, + width: box.width, + }; + }); + expect(rect.attrWidth).toBe("951"); + expect(rect.attrHeight).toBe("244"); + expect(rect.offsetHeight).toBeGreaterThan(80); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index a4d4489dc..daaddc8ff 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -99,6 +99,7 @@ type MockBridgeOptions = { profileReadError?: string; profileUpdateError?: string; searchProfiles?: MockSearchProfileSeed[]; + historyDelayMs?: number; updateChannelDelayMs?: number; stallWebsocketSends?: boolean; userSearchDelayMs?: number; From 476f7f86abcf2baddc07376a2ca731a56925672f Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:45:42 -0400 Subject: [PATCH 2/6] fix(desktop): shrink older-history scroll restore to scrollHeight-delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1035 grew useLoadOlderOnScroll into a 312-line custom anchor lock — MutationObserver / ResizeObserver / image-load restores guarded by a 250ms quiet-layout timer — chasing intermittent jumps Tyler was seeing on the macOS dev build. The lock writes container.scrollTop repeatedly during the same window the user is actively wheeling, which on WKWebView/macOS (off-main-thread scrolling) is exactly the failure mode the Element/Matrix team documents in their element-web docs/scrolling.md: setting scrollTop while scrolling tends to not work well, and read-then- write of scrollTop is unreliable because the read is stale relative to what the user actually sees. Replace the whole lock with the classical infinite-scroll-up algorithm: 1. When the top sentinel intersects, call fetchOlder(). 2. The moment fetchOlder resolves (microtask before React commits), snapshot container.scrollHeight. This baseline includes any chrome that's mounted during the fetch — notably the inline spinner gated on `isFetchingOlder`. 3. In a useLayoutEffect after the prepend commits, compute the delta between current and snapshot scrollHeight, and call `container.scrollBy(0, delta)`. scrollBy is preferred over `scrollTop = scrollTop + delta` because the latter reads scrollTop first, and that read is the stale-read footgun WebKit/macOS exhibits during active wheel input. Net effect: 312 lines → 139 lines for the hook. No observers, no locks, no quiet-layout timer. Robust to the user continuing to scroll during the fetch (scrollTop already reflects whatever they did, we only add the prepended height on top) and to the loading spinner that mounts during fetch and unmounts in the same React commit as the prepend (baseline captures spinner, delta nets it out). The `imeta dim` width/height reservation on from the prior revision stays — it removes a *cause* of layout shift (image-load re-flow) rather than compensating for one. Verified locally: - tests/e2e/scroll-history.spec.ts both pass (preserves user scroll while older channel history loads + reserves real imeta image height before image loads) - full desktop smoke suite passes except two pre-existing flakes (video-attachment, custom-emoji-screenshots) that also fail on the parent commit - tsc --noEmit, biome check both clean References: - element-hq/element-web docs/scrolling.md (BACAT, macOS scroll-write failure mode) - matrix-org/matrix-react-sdk#4166 (scrollBy vs scrollTop on macOS) - thread: buzz-bugs #d14cd131 root 1f86d045 (Dawn + Max research pass) Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- .../messages/ui/useLoadOlderOnScroll.ts | 319 ++++-------------- 1 file changed, 73 insertions(+), 246 deletions(-) diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 00f3e7ef9..6a83b9e1f 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -8,64 +8,28 @@ type UseLoadOlderOnScrollOptions = { sentinelRef: React.RefObject; }; -type ScrollAnchor = { - id: string; - top: number; -}; - -type ActiveScrollAnchorLock = { - cleanup: () => void; - restore: () => void; - scheduleReleaseAfterQuietLayout: () => void; - trackPendingImages: () => void; -}; - -function captureScrollAnchor(container: HTMLDivElement): ScrollAnchor | null { - const containerRect = container.getBoundingClientRect(); - const messages = Array.from( - container.querySelectorAll("[data-message-id]"), - ); - - for (const message of messages) { - const rect = message.getBoundingClientRect(); - if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) { - continue; - } - - return { - id: message.dataset.messageId ?? "", - top: rect.top - containerRect.top, - }; - } - - return null; -} - -function restoreScrollAnchor( - container: HTMLDivElement, - anchor: ScrollAnchor | null, -): number | null { - if (!anchor?.id) { - return null; - } - - const message = container.querySelector( - `[data-message-id="${CSS.escape(anchor.id)}"]`, - ); - if (!message) { - return null; - } - - const currentTop = - message.getBoundingClientRect().top - container.getBoundingClientRect().top; - container.scrollTop += currentTop - anchor.top; - return container.scrollTop; -} - /** * Triggers `fetchOlder` when a sentinel element near the top of the scroll - * container enters the viewport, then keeps the viewport locked to the first - * visible message until the prepended content and its media have settled. + * container enters the viewport, then restores the scroll position so the + * visible content doesn't jump. + * + * Uses the classical infinite-scroll-up algorithm: snapshot `scrollHeight` + * the moment `fetchOlder` resolves (before React commits the new messages), + * then in a `useLayoutEffect` after the prepend commits, advance the scroll + * position by the resulting `scrollHeight` delta. This is robust to: + * + * - The user continuing to scroll during the fetch — `scrollTop` already + * reflects whatever they did, we only add the prepended height on top. + * - Inline loading chrome that appears during the fetch (e.g. a top + * spinner gated on `isFetchingOlder`). The baseline `scrollHeight` is + * captured *after* such chrome is mounted, so when it unmounts in the + * same commit as the prepend, the delta still reflects the net change + * in content above the viewport. + * + * A prior implementation snapshotted an anchor element's bounding-rect top + * *before* the fetch and tried to restore by anchor delta. That captured + * the user's in-flight scroll into the delta and snapped them back by + * hundreds-to-thousands of pixels per fetch. */ export function useLoadOlderOnScroll({ fetchOlder, @@ -74,32 +38,47 @@ export function useLoadOlderOnScroll({ scrollContainerRef, sentinelRef, }: UseLoadOlderOnScrollOptions) { - const activeLockRef = React.useRef(null); - const loadStateRef = React.useRef({ - fetchOlder, - hasOlderMessages, - isLoading, - }); - - React.useEffect(() => { - loadStateRef.current = { fetchOlder, hasOlderMessages, isLoading }; - }, [fetchOlder, hasOlderMessages, isLoading]); + const [, scheduleRestore] = React.useReducer((count: number) => count + 1, 0); + const pendingPreviousScrollHeightRef = React.useRef(null); React.useLayoutEffect(() => { - const lock = activeLockRef.current; - if (!lock) { + const previousScrollHeight = pendingPreviousScrollHeightRef.current; + const container = scrollContainerRef.current; + if (previousScrollHeight === null || !container) { return; } - lock.trackPendingImages(); - lock.restore(); - lock.scheduleReleaseAfterQuietLayout(); + pendingPreviousScrollHeightRef.current = null; + const delta = container.scrollHeight - previousScrollHeight; + if (delta > 0) { + // Single synchronous pre-paint write. We deliberately do NOT route + // through useTimelineScrollManager.restoreScrollPosition: that helper + // schedules a 2-rAF locked-write loop (correct for + // ResizeObserver-driven resizes that may settle across frames, wrong + // for prepend), which fights live wheel input for 2–3 frames after + // every fetchOlder. + // + // Use `scrollBy` rather than `scrollTop = scrollTop + delta`: on + // WebKit/macOS (which Tauri uses for the desktop app) scrolling + // happens off the main thread, so a `scrollTop` *read* during active + // wheel input can be stale relative to what the user actually sees. + // `scrollBy` is delta-based — it doesn't read first — and avoids that + // class of stale-read bug. (Element/Matrix documents the same + // failure mode in element-web's docs/scrolling.md.) + container.scrollBy(0, delta); + } }); React.useEffect(() => { const sentinel = sentinelRef.current; const container = scrollContainerRef.current; - if (!sentinel || !container) { + if ( + !sentinel || + !container || + !fetchOlder || + isLoading || + !hasOlderMessages + ) { return; } @@ -113,188 +92,31 @@ export function useLoadOlderOnScroll({ currentObserver = new IntersectionObserver( ([entry]) => { - const { fetchOlder, hasOlderMessages, isLoading } = - loadStateRef.current; - if ( - !entry.isIntersecting || - disposed || - activeLockRef.current || - !fetchOlder || - isLoading || - !hasOlderMessages - ) { + if (!entry.isIntersecting || disposed) { return; } currentObserver?.disconnect(); - let anchor = captureScrollAnchor(container); - let fetchSettled = false; - let pendingImages = 0; - let restoreFrame: number | null = null; - let releaseTimer: number | null = null; - let maxReleaseTimer: number | null = null; - let resizeObserver: ResizeObserver | null = null; - let mutationObserver: MutationObserver | null = null; - let isRestoringAnchor = false; - let lastRestoredScrollTop: number | null = null; - const trackedImages = new WeakSet(); - const imageCleanups: Array<() => void> = []; - - const restoreAcrossFrames = (remainingFrames: number) => { - isRestoringAnchor = true; - lastRestoredScrollTop = restoreScrollAnchor(container, anchor); - - if (remainingFrames <= 0) { - requestAnimationFrame(() => { - isRestoringAnchor = false; - }); - return; - } - - restoreFrame = requestAnimationFrame(() => { - restoreFrame = null; - restoreAcrossFrames(remainingFrames - 1); - }); - }; - - const scheduleRestore = () => { - if (restoreFrame !== null) { - return; - } - - restoreFrame = requestAnimationFrame(() => { - restoreFrame = null; - restoreAcrossFrames(2); - }); - }; - - const cleanupLock = () => { - container.removeEventListener("scroll", updateAnchor); - resizeObserver?.disconnect(); - mutationObserver?.disconnect(); - for (const cleanupImage of imageCleanups) { - cleanupImage(); - } - imageCleanups.length = 0; - if (restoreFrame !== null) { - cancelAnimationFrame(restoreFrame); - restoreFrame = null; - } - if (releaseTimer !== null) { - window.clearTimeout(releaseTimer); - releaseTimer = null; - } - if (maxReleaseTimer !== null) { - window.clearTimeout(maxReleaseTimer); - maxReleaseTimer = null; - } - if (activeLockRef.current === lock) { - activeLockRef.current = null; - } - }; - - const releaseLock = () => { - cleanupLock(); - observe(); - }; - - const scheduleReleaseAfterQuietLayout = () => { - if (!fetchSettled || pendingImages > 0) { - return; - } - if (releaseTimer !== null) { - window.clearTimeout(releaseTimer); - } - releaseTimer = window.setTimeout(releaseLock, 250); - }; - - const settleImage = () => { - pendingImages = Math.max(0, pendingImages - 1); - scheduleRestore(); - scheduleReleaseAfterQuietLayout(); - }; - - const trackPendingImages = () => { - const images = Array.from(container.querySelectorAll("img")); - for (const image of images) { - if (trackedImages.has(image)) { - continue; - } - trackedImages.add(image); - if (image.complete) { - continue; - } - - pendingImages += 1; - image.addEventListener("load", settleImage, { once: true }); - image.addEventListener("error", settleImage, { once: true }); - imageCleanups.push(() => { - image.removeEventListener("load", settleImage); - image.removeEventListener("error", settleImage); - }); - } - }; - - const updateAnchor = () => { - if ( - isRestoringAnchor && - container.scrollTop === lastRestoredScrollTop - ) { - return; - } - anchor = captureScrollAnchor(container) ?? anchor; - }; - - const lock: ActiveScrollAnchorLock = { - cleanup: cleanupLock, - restore: () => { - restoreAcrossFrames(2); - }, - scheduleReleaseAfterQuietLayout, - trackPendingImages, - }; - - container.addEventListener("scroll", updateAnchor, { passive: true }); - activeLockRef.current = lock; - - const content = container.firstElementChild; - if (content instanceof HTMLElement) { - if (typeof ResizeObserver !== "undefined") { - resizeObserver = new ResizeObserver(() => { - scheduleRestore(); - scheduleReleaseAfterQuietLayout(); - }); - resizeObserver.observe(content); - } - - if (typeof MutationObserver !== "undefined") { - mutationObserver = new MutationObserver(() => { - trackPendingImages(); - scheduleRestore(); - scheduleReleaseAfterQuietLayout(); - }); - mutationObserver.observe(content, { - childList: true, - subtree: true, - }); - } - } - - void fetchOlder().finally(() => { - fetchSettled = true; - requestAnimationFrame(() => { + void fetchOlder() + .then(() => { if (disposed) { - cleanupLock(); return; } - - trackPendingImages(); + // Capture scrollHeight in the resolved callback rather than at + // IO-fire time so that any chrome that appears *during* the + // fetch (e.g. an inline loading spinner gated on + // `isFetchingOlder`) is included in the baseline. The + // useLayoutEffect that runs after the prepended messages + // commit measures against this baseline; if the spinner is + // unmounted in the same React commit as the prepend, the + // delta still reflects net prepended content correctly. + pendingPreviousScrollHeightRef.current = container.scrollHeight; scheduleRestore(); - scheduleReleaseAfterQuietLayout(); - maxReleaseTimer = window.setTimeout(releaseLock, 10_000); + }) + .finally(() => { + observe(); }); - }); }, { root: container, rootMargin: "200px 0px 0px 0px" }, ); @@ -305,8 +127,13 @@ export function useLoadOlderOnScroll({ observe(); return () => { disposed = true; - activeLockRef.current?.cleanup(); currentObserver?.disconnect(); }; - }, [scrollContainerRef, sentinelRef]); + }, [ + fetchOlder, + hasOlderMessages, + isLoading, + scrollContainerRef, + sentinelRef, + ]); } From ad7041c32f2135be78c3c717d5ba10c540ba35db Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 14 Jun 2026 13:14:40 -0400 Subject: [PATCH 3/6] fix(desktop): align history scroll restore with render commits Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../messages/ui/useLoadOlderOnScroll.ts | 91 ++++++++++--------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 6a83b9e1f..4971d9d0f 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -13,23 +13,24 @@ type UseLoadOlderOnScrollOptions = { * container enters the viewport, then restores the scroll position so the * visible content doesn't jump. * - * Uses the classical infinite-scroll-up algorithm: snapshot `scrollHeight` - * the moment `fetchOlder` resolves (before React commits the new messages), - * then in a `useLayoutEffect` after the prepend commits, advance the scroll - * position by the resulting `scrollHeight` delta. This is robust to: + * Uses the classical infinite-scroll-up algorithm: while an older-history + * request is in flight, each render compares the current `scrollHeight` to + * the previous render's `scrollHeight` and advances the scroll position by + * that delta. This keeps compensation aligned with the real React commit that + * changed the DOM instead of assuming `fetchOlder().then(...)` runs before the + * query cache update is rendered. This is robust to: * * - The user continuing to scroll during the fetch — `scrollTop` already - * reflects whatever they did, we only add the prepended height on top. + * reflects whatever they did, we only add height changes on top. * - Inline loading chrome that appears during the fetch (e.g. a top - * spinner gated on `isFetchingOlder`). The baseline `scrollHeight` is - * captured *after* such chrome is mounted, so when it unmounts in the - * same commit as the prepend, the delta still reflects the net change - * in content above the viewport. + * spinner gated on `isFetchingOlder`). Spinner mount is compensated one + * way; spinner removal in the prepend commit is compensated the other way, + * so the net viewport stays anchored to the same content. * - * A prior implementation snapshotted an anchor element's bounding-rect top - * *before* the fetch and tried to restore by anchor delta. That captured - * the user's in-flight scroll into the delta and snapped them back by - * hundreds-to-thousands of pixels per fetch. + * A prior implementation snapshotted after `fetchOlder` resolved, but the real + * fetch path mutates the React Query cache before resolving the promise. That + * can miss the actual prepend commit and leave the browser to apply its own + * scroll-range adjustment. */ export function useLoadOlderOnScroll({ fetchOlder, @@ -39,34 +40,45 @@ export function useLoadOlderOnScroll({ sentinelRef, }: UseLoadOlderOnScrollOptions) { const [, scheduleRestore] = React.useReducer((count: number) => count + 1, 0); - const pendingPreviousScrollHeightRef = React.useRef(null); + const pendingRestoreRef = React.useRef<"loading" | "settling" | null>(null); + const previousScrollHeightRef = React.useRef(null); React.useLayoutEffect(() => { - const previousScrollHeight = pendingPreviousScrollHeightRef.current; const container = scrollContainerRef.current; - if (previousScrollHeight === null || !container) { + if (!container) { + previousScrollHeightRef.current = null; return; } - pendingPreviousScrollHeightRef.current = null; - const delta = container.scrollHeight - previousScrollHeight; - if (delta > 0) { - // Single synchronous pre-paint write. We deliberately do NOT route - // through useTimelineScrollManager.restoreScrollPosition: that helper - // schedules a 2-rAF locked-write loop (correct for - // ResizeObserver-driven resizes that may settle across frames, wrong - // for prepend), which fights live wheel input for 2–3 frames after - // every fetchOlder. - // - // Use `scrollBy` rather than `scrollTop = scrollTop + delta`: on - // WebKit/macOS (which Tauri uses for the desktop app) scrolling - // happens off the main thread, so a `scrollTop` *read* during active - // wheel input can be stale relative to what the user actually sees. - // `scrollBy` is delta-based — it doesn't read first — and avoids that - // class of stale-read bug. (Element/Matrix documents the same - // failure mode in element-web's docs/scrolling.md.) - container.scrollBy(0, delta); + const previousScrollHeight = previousScrollHeightRef.current; + const currentScrollHeight = container.scrollHeight; + + if (pendingRestoreRef.current !== null && previousScrollHeight !== null) { + const delta = currentScrollHeight - previousScrollHeight; + if (delta !== 0) { + // Single synchronous pre-paint delta write. We deliberately do NOT + // route through useTimelineScrollManager.restoreScrollPosition: that + // helper schedules a 2-rAF locked-write loop (correct for + // ResizeObserver-driven resizes that may settle across frames, wrong + // for prepend), which fights live wheel input for 2–3 frames after + // every fetchOlder. + // + // Use `scrollBy` rather than `scrollTop = scrollTop + delta`: on + // WebKit/macOS (which Tauri uses for the desktop app) scrolling + // happens off the main thread, so a `scrollTop` *read* during active + // wheel input can be stale relative to what the user actually sees. + // `scrollBy` is delta-based — it doesn't read first — and avoids that + // class of stale-read bug. (Element/Matrix documents the same + // failure mode in element-web's docs/scrolling.md.) + container.scrollBy(0, delta); + } + + if (pendingRestoreRef.current === "settling") { + pendingRestoreRef.current = null; + } } + + previousScrollHeightRef.current = currentScrollHeight; }); React.useEffect(() => { @@ -98,20 +110,13 @@ export function useLoadOlderOnScroll({ currentObserver?.disconnect(); + pendingRestoreRef.current = "loading"; void fetchOlder() .then(() => { if (disposed) { return; } - // Capture scrollHeight in the resolved callback rather than at - // IO-fire time so that any chrome that appears *during* the - // fetch (e.g. an inline loading spinner gated on - // `isFetchingOlder`) is included in the baseline. The - // useLayoutEffect that runs after the prepended messages - // commit measures against this baseline; if the spinner is - // unmounted in the same React commit as the prepend, the - // delta still reflects net prepended content correctly. - pendingPreviousScrollHeightRef.current = container.scrollHeight; + pendingRestoreRef.current = "settling"; scheduleRestore(); }) .finally(() => { From 5b95fed0102b60de1f4ae91d55861e3d5bb4cf5f Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 14 Jun 2026 13:36:49 -0400 Subject: [PATCH 4/6] fix(desktop): anchor older-message prepends Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../messages/ui/useLoadOlderOnScroll.ts | 184 ++++++++++++------ 1 file changed, 120 insertions(+), 64 deletions(-) diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 4971d9d0f..b4d0bb2ca 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -8,29 +8,74 @@ type UseLoadOlderOnScrollOptions = { sentinelRef: React.RefObject; }; +type ScrollAnchor = { + id: string; + top: number; +}; + +function getMessageTop(container: HTMLDivElement, message: HTMLElement) { + return ( + message.getBoundingClientRect().top - container.getBoundingClientRect().top + ); +} + +function captureFirstVisibleMessage( + container: HTMLDivElement, +): ScrollAnchor | null { + const containerRect = container.getBoundingClientRect(); + const messages = Array.from( + container.querySelectorAll("[data-message-id]"), + ); + + for (const message of messages) { + const rect = message.getBoundingClientRect(); + if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) { + continue; + } + + const id = message.dataset.messageId; + if (!id) { + continue; + } + + return { + id, + top: rect.top - containerRect.top, + }; + } + + return null; +} + +function restoreAnchor(container: HTMLDivElement, anchor: ScrollAnchor) { + const message = container.querySelector( + `[data-message-id="${CSS.escape(anchor.id)}"]`, + ); + if (!message) { + return false; + } + + const delta = getMessageTop(container, message) - anchor.top; + if (Math.abs(delta) > 0.5) { + // Use a relative write. On WebKit/macOS, reading scrollTop during active + // wheel input can be stale; scrollBy applies the measured DOM delta without + // deriving a new absolute scrollTop from that potentially stale value. + container.scrollBy(0, delta); + } + + return true; +} + /** - * Triggers `fetchOlder` when a sentinel element near the top of the scroll - * container enters the viewport, then restores the scroll position so the - * visible content doesn't jump. - * - * Uses the classical infinite-scroll-up algorithm: while an older-history - * request is in flight, each render compares the current `scrollHeight` to - * the previous render's `scrollHeight` and advances the scroll position by - * that delta. This keeps compensation aligned with the real React commit that - * changed the DOM instead of assuming `fetchOlder().then(...)` runs before the - * query cache update is rendered. This is robust to: - * - * - The user continuing to scroll during the fetch — `scrollTop` already - * reflects whatever they did, we only add height changes on top. - * - Inline loading chrome that appears during the fetch (e.g. a top - * spinner gated on `isFetchingOlder`). Spinner mount is compensated one - * way; spinner removal in the prepend commit is compensated the other way, - * so the net viewport stays anchored to the same content. + * Triggers `fetchOlder` when a sentinel near the top of the scroll container + * enters the viewport, then preserves the user's visual position across the + * prepend by anchoring a stable message DOM node. * - * A prior implementation snapshotted after `fetchOlder` resolved, but the real - * fetch path mutates the React Query cache before resolving the promise. That - * can miss the actual prepend commit and leave the browser to apply its own - * scroll-range adjustment. + * The important invariant is: do not infer the user's viewport from global + * `scrollHeight` changes. Capture the first visible `[data-message-id]`, keep + * that anchor fresh while the user continues scrolling during the fetch, and + * after React commits the prepended rows, move the scroll container by the + * anchor node's visual delta exactly once per layout change. */ export function useLoadOlderOnScroll({ fetchOlder, @@ -39,48 +84,61 @@ export function useLoadOlderOnScroll({ scrollContainerRef, sentinelRef, }: UseLoadOlderOnScrollOptions) { - const [, scheduleRestore] = React.useReducer((count: number) => count + 1, 0); - const pendingRestoreRef = React.useRef<"loading" | "settling" | null>(null); - const previousScrollHeightRef = React.useRef(null); + const [, scheduleLayoutCheck] = React.useReducer( + (count: number) => count + 1, + 0, + ); + const activeAnchorRef = React.useRef(null); + const fetchSettledRef = React.useRef(false); + const isRestoringRef = React.useRef(false); React.useLayoutEffect(() => { const container = scrollContainerRef.current; - if (!container) { - previousScrollHeightRef.current = null; + const anchor = activeAnchorRef.current; + if (!container || !anchor) { return; } - const previousScrollHeight = previousScrollHeightRef.current; - const currentScrollHeight = container.scrollHeight; - - if (pendingRestoreRef.current !== null && previousScrollHeight !== null) { - const delta = currentScrollHeight - previousScrollHeight; - if (delta !== 0) { - // Single synchronous pre-paint delta write. We deliberately do NOT - // route through useTimelineScrollManager.restoreScrollPosition: that - // helper schedules a 2-rAF locked-write loop (correct for - // ResizeObserver-driven resizes that may settle across frames, wrong - // for prepend), which fights live wheel input for 2–3 frames after - // every fetchOlder. - // - // Use `scrollBy` rather than `scrollTop = scrollTop + delta`: on - // WebKit/macOS (which Tauri uses for the desktop app) scrolling - // happens off the main thread, so a `scrollTop` *read* during active - // wheel input can be stale relative to what the user actually sees. - // `scrollBy` is delta-based — it doesn't read first — and avoids that - // class of stale-read bug. (Element/Matrix documents the same - // failure mode in element-web's docs/scrolling.md.) - container.scrollBy(0, delta); - } + isRestoringRef.current = true; + const restored = restoreAnchor(container, anchor); + requestAnimationFrame(() => { + isRestoringRef.current = false; + }); - if (pendingRestoreRef.current === "settling") { - pendingRestoreRef.current = null; - } + if (!restored) { + activeAnchorRef.current = null; + fetchSettledRef.current = false; + return; } - previousScrollHeightRef.current = currentScrollHeight; + if (fetchSettledRef.current) { + activeAnchorRef.current = null; + fetchSettledRef.current = false; + } }); + React.useEffect(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + const updateAnchorFromUserScroll = () => { + if (!activeAnchorRef.current || isRestoringRef.current) { + return; + } + activeAnchorRef.current = captureFirstVisibleMessage(container); + }; + + container.addEventListener("scroll", updateAnchorFromUserScroll, { + passive: true, + }); + + return () => { + container.removeEventListener("scroll", updateAnchorFromUserScroll); + }; + }, [scrollContainerRef]); + React.useEffect(() => { const sentinel = sentinelRef.current; const container = scrollContainerRef.current; @@ -109,19 +167,17 @@ export function useLoadOlderOnScroll({ } currentObserver?.disconnect(); + fetchSettledRef.current = false; + activeAnchorRef.current = captureFirstVisibleMessage(container); - pendingRestoreRef.current = "loading"; - void fetchOlder() - .then(() => { - if (disposed) { - return; - } - pendingRestoreRef.current = "settling"; - scheduleRestore(); - }) - .finally(() => { - observe(); - }); + void fetchOlder().finally(() => { + if (disposed) { + return; + } + fetchSettledRef.current = true; + scheduleLayoutCheck(); + observe(); + }); }, { root: container, rootMargin: "200px 0px 0px 0px" }, ); From f0d947d5e50c2285653267057a22ffe3c1b2673c Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 14 Jun 2026 13:51:43 -0400 Subject: [PATCH 5/6] fix(desktop): stop restoring scrollTop during history prepends Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../messages/ui/useTimelineScrollManager.ts | 49 +++---------------- 1 file changed, 7 insertions(+), 42 deletions(-) diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index da2fd7ff6..445a973c8 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -33,7 +33,6 @@ export function useTimelineScrollManager({ const isProgrammaticBottomScrollRef = React.useRef(false); const previousTimelineHeightRef = React.useRef(null); const previousScrollTopRef = React.useRef(0); - const lockedScrollTopRef = React.useRef(null); const previousLastMessageKeyRef = React.useRef(undefined); const previousMessageCountRef = React.useRef(0); const handledTargetMessageIdRef = React.useRef(null); @@ -50,7 +49,6 @@ export function useTimelineScrollManager({ isProgrammaticBottomScrollRef.current = false; previousTimelineHeightRef.current = null; previousScrollTopRef.current = 0; - lockedScrollTopRef.current = null; previousLastMessageKeyRef.current = undefined; previousMessageCountRef.current = 0; handledTargetMessageIdRef.current = null; @@ -108,7 +106,7 @@ export function useTimelineScrollManager({ return; } - const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop; + const scrollTop = timeline.scrollTop; const atBottom = isNearBottom(timeline); const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current; @@ -137,38 +135,6 @@ export function useTimelineScrollManager({ setObservedBottomState(atBottom); }, [pinToBottom, setObservedBottomState]); - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes - const restoreScrollPosition = React.useCallback( - (scrollTop: number) => { - const timeline = timelineRef.current; - - if (!timeline) { - return; - } - - isProgrammaticBottomScrollRef.current = false; - lockedScrollTopRef.current = scrollTop; - - const restore = (remainingFrames: number) => { - timeline.scrollTop = scrollTop; - - if (remainingFrames > 0) { - requestAnimationFrame(() => { - restore(remainingFrames - 1); - }); - return; - } - - lockedScrollTopRef.current = null; - previousScrollTopRef.current = timeline.scrollTop; - syncScrollState(); - }; - - restore(2); - }, - [syncScrollState], - ); - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes const scrollToBottom = React.useCallback( (behavior: ScrollBehavior) => { @@ -192,7 +158,6 @@ export function useTimelineScrollManager({ }; alignToBottom(behavior); - lockedScrollTopRef.current = null; previousScrollTopRef.current = timeline.scrollTop; pinToBottom({ clearNewMessageCount: true }); @@ -223,6 +188,11 @@ export function useTimelineScrollManager({ [pinToBottom, syncScrollState], ); + // Keep the timeline pinned only when its viewport size changes while the user + // is already at the bottom. When reading history, do not restore an old + // absolute scrollTop: prepends are handled by `useLoadOlderOnScroll` with a + // message anchor, and an absolute multi-frame restore can overwrite that + // correction on WebKit. // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes React.useEffect(() => { const timeline = timelineRef.current; @@ -232,7 +202,6 @@ export function useTimelineScrollManager({ } previousTimelineHeightRef.current = timeline.clientHeight; - previousScrollTopRef.current = timeline.scrollTop; const observer = new ResizeObserver(([entry]) => { const previousTimelineHeight = previousTimelineHeightRef.current; @@ -248,10 +217,7 @@ export function useTimelineScrollManager({ if (shouldStickToBottomRef.current || isAtBottomRef.current) { scrollToBottom("auto"); - return; } - - restoreScrollPosition(previousScrollTopRef.current); }); observer.observe(timeline); @@ -259,7 +225,7 @@ export function useTimelineScrollManager({ return () => { observer.disconnect(); }; - }, [restoreScrollPosition, scrollToBottom]); + }, [scrollToBottom]); React.useEffect(() => { const content = contentRef.current; @@ -409,7 +375,6 @@ export function useTimelineScrollManager({ highlightedMessageId, isAtBottom, newMessageCount, - restoreScrollPosition, scrollToBottom, syncScrollState, }; From c85b06e240155b258bb3a554fd2999d12b36da0c Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 14 Jun 2026 15:40:53 -0400 Subject: [PATCH 6/6] feat(desktop): virtualize message timeline Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- desktop/package.json | 1 + .../features/messages/ui/MessageTimeline.tsx | 651 ++++++++++++------ .../messages/ui/TimelineMessageList.tsx | 409 +++++++---- .../ui/VirtualizedTimelineMessageList.tsx | 266 +++++++ .../messages/ui/useLoadOlderOnScroll.ts | 200 ------ desktop/tests/e2e/scroll-history.spec.ts | 50 +- pnpm-lock.yaml | 14 + 7 files changed, 985 insertions(+), 606 deletions(-) create mode 100644 desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx delete mode 100644 desktop/src/features/messages/ui/useLoadOlderOnScroll.ts diff --git a/desktop/package.json b/desktop/package.json index e307e592c..ff679e7b0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -64,6 +64,7 @@ "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.7", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b02c921d8..ab379d8ab 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import { ArrowDown, Hash } from "lucide-react"; +import type { VirtuosoHandle } from "react-virtuoso"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { isSameDay } from "@/features/messages/lib/dateFormatters"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; @@ -11,9 +13,7 @@ import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { TimelineSkeleton } from "./TimelineSkeleton"; -import { TimelineMessageList } from "./TimelineMessageList"; -import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; -import { useTimelineScrollManager } from "./useTimelineScrollManager"; +import { VirtualizedTimelineMessageList } from "./VirtualizedTimelineMessageList"; type MessageTimelineProps = { agentPubkeys?: ReadonlySet; @@ -88,6 +88,35 @@ type ChannelIntro = { icon?: React.ReactNode; }; +function mergeRefs(...refs: Array | undefined>) { + return (value: T | null) => { + for (const ref of refs) { + if (!ref) continue; + if (typeof ref === "function") { + ref(value); + } else { + ref.current = value; + } + } + }; +} + +function getTimelineItemIndex(messages: TimelineMessage[], messageId: string) { + let itemIndex = 0; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const previous = index > 0 ? messages[index - 1] : null; + if (!previous || !isSameDay(previous.createdAt, message.createdAt)) { + itemIndex += 1; + } + if (message.id === messageId) { + return itemIndex; + } + itemIndex += 1; + } + return -1; +} + export const MessageTimeline = React.memo(function MessageTimeline({ agentPubkeys, channelId, @@ -125,61 +154,45 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onTargetReached, }: MessageTimelineProps) { const internalScrollRef = React.useRef(null); + const virtuosoRef = React.useRef(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; - const topSentinelRef = React.useRef(null); + const fetchOlderInFlightRef = React.useRef(false); + const hasInitializedScrollRef = React.useRef(false); + const shouldStickToBottomRef = React.useRef(true); + const handledTargetMessageIdRef = React.useRef(null); + const previousLastMessageKeyRef = React.useRef(undefined); + const previousMessageCountRef = React.useRef(0); + const previousChannelIdRef = React.useRef(channelId); + const [highlightedMessageId, setHighlightedMessageId] = React.useState< + string | null + >(null); + const [isAtBottom, setIsAtBottom] = React.useState(true); + const [newMessageCount, setNewMessageCount] = React.useState(0); const scrollRestorationId = targetMessageId ? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}` : `message-timeline:${channelId ?? "none"}`; - const { - bottomAnchorRef, - contentRef, - highlightedMessageId, - isAtBottom, - newMessageCount, - scrollToBottom, - syncScrollState, - } = useTimelineScrollManager({ - channelId, - isLoading, - messages, - onTargetReached, - scrollContainerRef, - targetMessageId, - }); - - // Scroll to the active search match when it changes. - const prevSearchActiveRef = React.useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref - React.useEffect(() => { - if ( - !searchActiveMessageId || - searchActiveMessageId === prevSearchActiveRef.current - ) { - prevSearchActiveRef.current = searchActiveMessageId; + React.useLayoutEffect(() => { + if (previousChannelIdRef.current === channelId) { return; } - prevSearchActiveRef.current = searchActiveMessageId; - const container = scrollContainerRef.current; - if (!container) return; - - const el = container.querySelector( - `[data-message-id="${searchActiveMessageId}"]`, - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); - } - }, [searchActiveMessageId]); - - useLoadOlderOnScroll({ - fetchOlder, - hasOlderMessages, - isLoading, - scrollContainerRef, - sentinelRef: topSentinelRef, + previousChannelIdRef.current = channelId; + hasInitializedScrollRef.current = false; + shouldStickToBottomRef.current = true; + handledTargetMessageIdRef.current = null; + previousLastMessageKeyRef.current = undefined; + previousMessageCountRef.current = 0; + setHighlightedMessageId(null); + setIsAtBottom(true); + setNewMessageCount(0); }); + const setScrollerRef = React.useMemo( + () => mergeRefs(scrollContainerRef), + [scrollContainerRef], + ); + const showDirectMessageIntro = !isLoading && directMessageIntro !== null; const showChannelIntro = !isLoading && channelIntro !== null && directMessageIntro === null; @@ -190,204 +203,261 @@ export const MessageTimeline = React.memo(function MessageTimeline({ directMessageIntro === null && channelIntro === null; const showMessageList = !isLoading && messages.length > 0; + const latestMessage = + messages.length > 0 ? messages[messages.length - 1] : undefined; + const latestMessageKey = latestMessage + ? (latestMessage.renderKey ?? latestMessage.id) + : undefined; + + const scrollToBottom = React.useCallback( + (behavior: ScrollBehavior) => { + if (messages.length === 0) return; + shouldStickToBottomRef.current = true; + setIsAtBottom(true); + setNewMessageCount(0); + virtuosoRef.current?.scrollToIndex({ + align: "end", + behavior: behavior === "smooth" ? "smooth" : "auto", + index: "LAST", + }); + }, + [messages.length], + ); + + const handleAtBottomStateChange = React.useCallback((atBottom: boolean) => { + shouldStickToBottomRef.current = atBottom; + setIsAtBottom(atBottom); + if (atBottom) { + setNewMessageCount(0); + } + }, []); + + const handleStartReached = React.useCallback(() => { + if ( + !fetchOlder || + !hasOlderMessages || + isLoading || + isFetchingOlder || + fetchOlderInFlightRef.current + ) { + return; + } + + fetchOlderInFlightRef.current = true; + void fetchOlder().finally(() => { + fetchOlderInFlightRef.current = false; + }); + }, [fetchOlder, hasOlderMessages, isFetchingOlder, isLoading]); + + React.useLayoutEffect(() => { + if (isLoading || !showMessageList) { + return; + } + + if (!hasInitializedScrollRef.current) { + hasInitializedScrollRef.current = true; + previousLastMessageKeyRef.current = latestMessageKey; + previousMessageCountRef.current = messages.length; + + if (!targetMessageId) { + scrollToBottom("auto"); + } + return; + } + + const previousLastMessageKey = previousLastMessageKeyRef.current; + const previousMessageCount = previousMessageCountRef.current; + const hasNewLatestMessage = + latestMessage !== undefined && + latestMessageKey !== previousLastMessageKey; + + if (hasNewLatestMessage) { + if ( + !targetMessageId && + (shouldStickToBottomRef.current || latestMessage.accent) + ) { + scrollToBottom(latestMessage.accent ? "smooth" : "auto"); + } else { + setNewMessageCount((current) => { + const addedMessages = Math.max( + 1, + messages.length - previousMessageCount, + ); + return current + addedMessages; + }); + } + } + + previousLastMessageKeyRef.current = latestMessageKey; + previousMessageCountRef.current = messages.length; + }, [ + isLoading, + latestMessage, + latestMessageKey, + messages.length, + scrollToBottom, + showMessageList, + targetMessageId, + ]); + + React.useEffect(() => { + if (!searchActiveMessageId) return; + const index = getTimelineItemIndex(messages, searchActiveMessageId); + if (index < 0) return; + virtuosoRef.current?.scrollToIndex({ + align: "center", + behavior: "smooth", + index, + }); + }, [messages, searchActiveMessageId]); + + React.useEffect(() => { + if (!targetMessageId) { + handledTargetMessageIdRef.current = null; + setHighlightedMessageId(null); + return; + } + + if (handledTargetMessageIdRef.current === targetMessageId || isLoading) { + return; + } + + const index = getTimelineItemIndex(messages, targetMessageId); + if (index < 0) return; + + handledTargetMessageIdRef.current = targetMessageId; + shouldStickToBottomRef.current = false; + setHighlightedMessageId(targetMessageId); + setNewMessageCount(0); + virtuosoRef.current?.scrollToIndex({ + align: "center", + behavior: "auto", + index, + }); + onTargetReached?.(targetMessageId); + + const timeout = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === targetMessageId ? null : current, + ); + }, 2_000); + + return () => { + window.clearTimeout(timeout); + }; + }, [isLoading, messages, onTargetReached, targetMessageId]); return (
-
-
- - {isFetchingOlder ? ( -
- -
- ) : null} - - {isLoading ? : null} - - {showDirectMessageIntro ? ( + {showMessageList ? ( + + shouldStickToBottomRef.current ? "auto" : false + } + followThreadById={followThreadById} + hasOlderMessages={hasOlderMessages} + highlightedMessageId={highlightedMessageId} + isFetchingOlder={isFetchingOlder} + isFollowingThreadById={isFollowingThreadById} + messageFooters={messageFooters} + messages={messages} + onDelete={onDelete} + onEdit={onEdit} + onMarkUnread={onMarkUnread} + onReply={onReply} + onStartReached={handleStartReached} + isSendingVideoReviewComment={isSendingVideoReviewComment} + onSendVideoReviewComment={onSendVideoReviewComment} + onToggleReaction={onToggleReaction} + personaLookup={personaLookup} + profiles={profiles} + scrollerRef={(element) => { + setScrollerRef(element); + if (element) { + element.dataset.scrollRestorationId = scrollRestorationId; + } + }} + searchActiveMessageId={searchActiveMessageId} + searchMatchingMessageIds={searchMatchingMessageIds} + searchQuery={searchQuery} + topHeader={ + showDirectMessageIntro ? ( + + ) : showChannelIntro ? ( + + ) : null + } + unfollowThreadById={unfollowThreadById} + virtuosoRef={virtuosoRef} + /> + ) : ( +
- -

- {directMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {directMessageIntro.displayName} - - . -

-
- ) : null} + {isLoading ? : null} - {showChannelIntro ? ( -
-
- {channelIntro.icon ?? ( - - )} -
-

- #{channelIntro.channelName} -

-

- This is the beginning of the{" "} - - {channelIntro.channelKindLabel} - - . -

- {channelIntro.description ? ( -

- {channelIntro.description} -

+ {showDirectMessageIntro ? ( + ) : null} - {channelIntro.actions?.length ? ( -
- {channelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); - - return ( - - ); - })} -
+ + {showChannelIntro ? ( + ) : null} -
- ) : null} - {showGenericEmpty ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

+ {showGenericEmpty ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null}
- ) : null} +
+ )} - {showMessageList ? ( -
- + {isFetchingOlder && showMessageList ? ( +
+
+
- ) : null} - -
-
+
+ ) : null}
{!isAtBottom ? ( @@ -418,3 +488,126 @@ export const MessageTimeline = React.memo(function MessageTimeline({ ); }); + +function DirectMessageIntroCard({ + directMessageIntro, +}: { + directMessageIntro: { + avatarUrl: string | null; + displayName: string; + }; +}) { + return ( +
+ +

+ {directMessageIntro.displayName} +

+

+ This is the beginning of your direct message with{" "} + + {directMessageIntro.displayName} + + . +

+
+ ); +} + +function ChannelIntroCard({ channelIntro }: { channelIntro: ChannelIntro }) { + return ( +
+
+ {channelIntro.icon ?? } +
+

+ #{channelIntro.channelName} +

+

+ This is the beginning of the{" "} + + {channelIntro.channelKindLabel} + + . +

+ {channelIntro.description ? ( +

+ {channelIntro.description} +

+ ) : null} + {channelIntro.actions?.length ? ( +
+ {channelIntro.actions.map((action) => { + const hasDescription = Boolean(action.description); + + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index a649fa357..7938acca1 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -16,7 +16,7 @@ import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; -type TimelineMessageListProps = { +export type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; channelName?: string; @@ -56,7 +56,11 @@ type TimelineMessageListProps = { searchQuery?: string; }; -function hasVideoAttachment(message: TimelineMessage): boolean { +export type TimelineMessageEntry = ReturnType< + typeof buildMainTimelineEntries +>[number]; + +export function hasVideoAttachment(message: TimelineMessage): boolean { if (message.body.includes("![video](")) return true; return ( @@ -68,7 +72,7 @@ function hasVideoAttachment(message: TimelineMessage): boolean { ); } -function buildReviewCommentsByRootId( +export function buildReviewCommentsByRootId( messages: TimelineMessage[], ): Map { const messageById = new Map(messages.map((message) => [message.id, message])); @@ -100,6 +104,209 @@ function buildReviewCommentsByRootId( return commentsByRootId; } +type BuildVideoReviewContextOptions = Pick< + TimelineMessageListProps, + | "channelId" + | "channelName" + | "channelType" + | "isSendingVideoReviewComment" + | "onSendVideoReviewComment" + | "onToggleReaction" + | "profiles" +> & { + messages: TimelineMessage[]; + reviewCommentsByRootId: Map; +}; + +export function buildVideoReviewContextById({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment = false, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, +}: BuildVideoReviewContextOptions): Map { + const contexts = new Map(); + for (const message of messages) { + if (!hasVideoAttachment(message)) continue; + const comments = reviewCommentsByRootId.get(message.id) ?? []; + contexts.set(message.id, { + channelId, + channelName, + channelType, + comments, + disabled: !onSendVideoReviewComment || message.pending, + isSending: isSendingVideoReviewComment, + onSendComment: onSendVideoReviewComment + ? (content, mentionPubkeys, mediaTags, parentEventId) => + onSendVideoReviewComment( + message, + content, + mentionPubkeys, + mediaTags, + parentEventId, + ) + : undefined, + onToggleCommentReaction: onToggleReaction + ? (comment, emoji, remove) => { + const sourceComment = comments.find( + (candidate) => candidate.id === comment.id, + ); + if (!sourceComment) return Promise.resolve(); + return onToggleReaction(sourceComment, emoji, remove); + } + : undefined, + profiles, + rootEventId: message.id, + }); + } + return contexts; +} + +type RenderTimelineMessageEntryOptions = Omit< + TimelineMessageListProps, + "messages" | "messageFooters" +> & { + entry: TimelineMessageEntry; + footer?: React.ReactNode; + messageKey?: React.Key; + videoReviewContext?: VideoReviewContext; +}; + +export function renderTimelineMessageEntry({ + agentPubkeys, + channelId, + currentPubkey, + entry, + followThreadById, + footer, + highlightedMessageId = null, + isFollowingThreadById, + messageKey, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + personaLookup, + profiles, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, + unfollowThreadById, + videoReviewContext, +}: RenderTimelineMessageEntryOptions) { + const { message, summary } = entry; + const key = messageKey ?? message.renderKey ?? message.id; + + if (message.kind === KIND_SYSTEM_MESSAGE) { + return ( +
+ + {footer} +
+ ); + } + + if (summary && onReply) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ followThreadById(message.id) : undefined + } + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ ); + } + + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + + return ( +
+ + {footer} +
+ ); +} + export const TimelineMessageList = React.memo(function TimelineMessageList({ agentPubkeys, channelId, @@ -137,53 +344,31 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would // defeat the memo and re-render every video message on every pass. - const videoReviewContextById = React.useMemo(() => { - const contexts = new Map(); - for (const message of messages) { - if (!hasVideoAttachment(message)) continue; - const comments = reviewCommentsByRootId.get(message.id) ?? []; - contexts.set(message.id, { + const videoReviewContextById = React.useMemo( + () => + buildVideoReviewContextById({ channelId, channelName, channelType, - comments, - disabled: !onSendVideoReviewComment || message.pending, - isSending: isSendingVideoReviewComment, - onSendComment: onSendVideoReviewComment - ? (content, mentionPubkeys, mediaTags, parentEventId) => - onSendVideoReviewComment( - message, - content, - mentionPubkeys, - mediaTags, - parentEventId, - ) - : undefined, - onToggleCommentReaction: onToggleReaction - ? (comment, emoji, remove) => { - const sourceComment = comments.find( - (candidate) => candidate.id === comment.id, - ); - if (!sourceComment) return Promise.resolve(); - return onToggleReaction(sourceComment, emoji, remove); - } - : undefined, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, profiles, - rootEventId: message.id, - }); - } - return contexts; - }, [ - channelId, - channelName, - channelType, - isSendingVideoReviewComment, - messages, - onSendVideoReviewComment, - onToggleReaction, - profiles, - reviewCommentsByRootId, - ]); + reviewCommentsByRootId, + }), + [ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, + ], + ); const dayGroups: Array<{ key: string; label: string; @@ -192,7 +377,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ let currentDayGroup: (typeof dayGroups)[number] | null = null; for (let i = 0; i < entries.length; i++) { - const { message, summary } = entries[i]; + const entry = entries[i]; + const { message } = entry; const prev = i > 0 ? entries[i - 1]?.message : null; const messageRenderKey = message.renderKey ?? message.id; @@ -205,110 +391,31 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ dayGroups.push(currentDayGroup); } - if (message.kind === KIND_SYSTEM_MESSAGE) { - const footer = messageFooters?.[message.id] ?? null; - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } else if (summary && onReply) { - const footer = messageFooters?.[message.id] ?? null; - const isHighlighted = message.id === highlightedMessageId; - currentDayGroup?.elements.push( -
- followThreadById(message.id) : undefined - } - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - videoReviewContext={videoReviewContextById.get(message.id)} - /> - - {footer} -
, - ); - } else { - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - const footer = messageFooters?.[message.id] ?? null; - - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } + currentDayGroup?.elements.push( + renderTimelineMessageEntry({ + agentPubkeys, + channelId, + currentPubkey, + entry, + followThreadById, + footer: messageFooters?.[message.id] ?? null, + highlightedMessageId, + isFollowingThreadById, + messageKey: messageRenderKey, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + personaLookup, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + unfollowThreadById, + videoReviewContext: videoReviewContextById.get(message.id), + }), + ); } return dayGroups.map((group) => ( diff --git a/desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx b/desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx new file mode 100644 index 000000000..83972c091 --- /dev/null +++ b/desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx @@ -0,0 +1,266 @@ +import * as React from "react"; +import { + Virtuoso, + type FollowOutput, + type VirtuosoHandle, +} from "react-virtuoso"; + +import { + formatDayHeading, + isSameDay, +} from "@/features/messages/lib/dateFormatters"; +import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; +import { DayDivider } from "./DayDivider"; +import { + buildReviewCommentsByRootId, + buildVideoReviewContextById, + renderTimelineMessageEntry, + type TimelineMessageListProps, +} from "./TimelineMessageList"; + +type TimelineEntry = + | { + key: string; + type: "day"; + label: string; + } + | { + key: string; + type: "message"; + message: TimelineMessage; + summary: ReturnType[number]["summary"]; + }; + +type VirtualizedTimelineMessageListProps = TimelineMessageListProps & { + atBottomStateChange?: (atBottom: boolean) => void; + bottomFooterClassName?: string; + followOutput?: FollowOutput; + hasOlderMessages: boolean; + isFetchingOlder: boolean; + onStartReached?: () => void; + scrollerRef?: (element: HTMLDivElement | null) => void; + topHeader?: React.ReactNode; + virtuosoRef?: React.RefObject; +}; + +const FIRST_ITEM_INDEX_BASE = 1_000_000; + +const TimelineList = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(function TimelineList({ children, style, ...props }, ref) { + return ( +
+ {children} +
+ ); +}); +TimelineList.displayName = "VirtualizedTimelineList"; + +function buildTimelineItems(messages: TimelineMessage[]): TimelineEntry[] { + const entries = buildMainTimelineEntries(messages); + const items: TimelineEntry[] = []; + + for (let index = 0; index < entries.length; index += 1) { + const { message, summary } = entries[index]; + const prev = index > 0 ? entries[index - 1]?.message : null; + + if (!prev || !isSameDay(prev.createdAt, message.createdAt)) { + items.push({ + key: `day-${message.createdAt}`, + label: formatDayHeading(message.createdAt), + type: "day", + }); + } + + items.push({ + key: message.renderKey ?? message.id, + message, + summary, + type: "message", + }); + } + + return items; +} + +export const VirtualizedTimelineMessageList = React.memo( + function VirtualizedTimelineMessageList({ + agentPubkeys, + atBottomStateChange, + bottomFooterClassName, + channelId, + channelName, + channelType, + currentPubkey, + followOutput = false, + followThreadById, + hasOlderMessages, + highlightedMessageId = null, + isFetchingOlder, + isFollowingThreadById, + messageFooters, + messages, + onDelete, + onEdit, + onMarkUnread, + onReply, + onStartReached, + isSendingVideoReviewComment = false, + onSendVideoReviewComment, + onToggleReaction, + personaLookup, + profiles, + scrollerRef, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, + topHeader, + unfollowThreadById, + virtuosoRef, + }: VirtualizedTimelineMessageListProps) { + const items = React.useMemo(() => buildTimelineItems(messages), [messages]); + const reviewCommentsByRootId = React.useMemo( + () => buildReviewCommentsByRootId(messages), + [messages], + ); + const videoReviewContextById = React.useMemo( + () => + buildVideoReviewContextById({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, + }), + [ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, + ], + ); + + const firstItemIndexStateRef = React.useRef({ + anchorIndex: -1, + anchorKey: null as string | null, + firstItemIndex: FIRST_ITEM_INDEX_BASE, + items: [] as readonly TimelineEntry[], + }); + + const firstItemIndex = React.useMemo(() => { + const state = firstItemIndexStateRef.current; + const previousItems = state.items; + + if (items.length === 0) { + state.anchorIndex = -1; + state.anchorKey = null; + state.firstItemIndex = FIRST_ITEM_INDEX_BASE; + state.items = items; + return state.firstItemIndex; + } + + const anchorEntryIndex = items.findIndex( + (item) => item.type === "message", + ); + const anchorKey = + anchorEntryIndex >= 0 ? (items[anchorEntryIndex]?.key ?? null) : null; + + if (previousItems !== items) { + if (state.anchorKey) { + const nextAnchorIndex = items.findIndex( + (item) => item.key === state.anchorKey, + ); + if (nextAnchorIndex >= 0 && state.anchorIndex >= 0) { + state.firstItemIndex -= nextAnchorIndex - state.anchorIndex; + } else { + state.firstItemIndex = FIRST_ITEM_INDEX_BASE; + } + } + + state.anchorIndex = anchorEntryIndex; + state.anchorKey = anchorKey; + state.items = items; + } + + return state.firstItemIndex; + }, [items]); + + const components = React.useMemo( + () => ({ + Footer: () =>
, + Header: topHeader + ? () =>
{topHeader}
+ : undefined, + List: TimelineList, + }), + [bottomFooterClassName, topHeader], + ); + + return ( + + atBottomStateChange={atBottomStateChange} + atBottomThreshold={32} + className="h-full w-full" + components={components} + computeItemKey={(_, item) => item.key} + data={items} + data-scroll-restoration-id="virtualized-message-timeline" + data-testid="message-timeline" + defaultItemHeight={96} + firstItemIndex={firstItemIndex} + followOutput={followOutput} + initialTopMostItemIndex={{ align: "end", index: items.length - 1 }} + increaseViewportBy={{ bottom: 600, top: 900 }} + itemContent={(_index, item) => { + if (item.type === "day") { + return ; + } + + return renderTimelineMessageEntry({ + agentPubkeys, + channelId, + currentPubkey, + entry: item, + followThreadById, + footer: messageFooters?.[item.message.id] ?? null, + highlightedMessageId, + isFollowingThreadById, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + personaLookup, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + unfollowThreadById, + videoReviewContext: videoReviewContextById.get(item.message.id), + }); + }} + overscan={{ main: 800, reverse: 800 }} + ref={virtuosoRef} + scrollerRef={(element) => { + scrollerRef?.(element instanceof HTMLDivElement ? element : null); + }} + startReached={() => { + if (hasOlderMessages && !isFetchingOlder) { + onStartReached?.(); + } + }} + /> + ); + }, +); diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts deleted file mode 100644 index b4d0bb2ca..000000000 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ /dev/null @@ -1,200 +0,0 @@ -import * as React from "react"; - -type UseLoadOlderOnScrollOptions = { - fetchOlder?: () => Promise; - hasOlderMessages: boolean; - isLoading: boolean; - scrollContainerRef: React.RefObject; - sentinelRef: React.RefObject; -}; - -type ScrollAnchor = { - id: string; - top: number; -}; - -function getMessageTop(container: HTMLDivElement, message: HTMLElement) { - return ( - message.getBoundingClientRect().top - container.getBoundingClientRect().top - ); -} - -function captureFirstVisibleMessage( - container: HTMLDivElement, -): ScrollAnchor | null { - const containerRect = container.getBoundingClientRect(); - const messages = Array.from( - container.querySelectorAll("[data-message-id]"), - ); - - for (const message of messages) { - const rect = message.getBoundingClientRect(); - if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) { - continue; - } - - const id = message.dataset.messageId; - if (!id) { - continue; - } - - return { - id, - top: rect.top - containerRect.top, - }; - } - - return null; -} - -function restoreAnchor(container: HTMLDivElement, anchor: ScrollAnchor) { - const message = container.querySelector( - `[data-message-id="${CSS.escape(anchor.id)}"]`, - ); - if (!message) { - return false; - } - - const delta = getMessageTop(container, message) - anchor.top; - if (Math.abs(delta) > 0.5) { - // Use a relative write. On WebKit/macOS, reading scrollTop during active - // wheel input can be stale; scrollBy applies the measured DOM delta without - // deriving a new absolute scrollTop from that potentially stale value. - container.scrollBy(0, delta); - } - - return true; -} - -/** - * Triggers `fetchOlder` when a sentinel near the top of the scroll container - * enters the viewport, then preserves the user's visual position across the - * prepend by anchoring a stable message DOM node. - * - * The important invariant is: do not infer the user's viewport from global - * `scrollHeight` changes. Capture the first visible `[data-message-id]`, keep - * that anchor fresh while the user continues scrolling during the fetch, and - * after React commits the prepended rows, move the scroll container by the - * anchor node's visual delta exactly once per layout change. - */ -export function useLoadOlderOnScroll({ - fetchOlder, - hasOlderMessages, - isLoading, - scrollContainerRef, - sentinelRef, -}: UseLoadOlderOnScrollOptions) { - const [, scheduleLayoutCheck] = React.useReducer( - (count: number) => count + 1, - 0, - ); - const activeAnchorRef = React.useRef(null); - const fetchSettledRef = React.useRef(false); - const isRestoringRef = React.useRef(false); - - React.useLayoutEffect(() => { - const container = scrollContainerRef.current; - const anchor = activeAnchorRef.current; - if (!container || !anchor) { - return; - } - - isRestoringRef.current = true; - const restored = restoreAnchor(container, anchor); - requestAnimationFrame(() => { - isRestoringRef.current = false; - }); - - if (!restored) { - activeAnchorRef.current = null; - fetchSettledRef.current = false; - return; - } - - if (fetchSettledRef.current) { - activeAnchorRef.current = null; - fetchSettledRef.current = false; - } - }); - - React.useEffect(() => { - const container = scrollContainerRef.current; - if (!container) { - return; - } - - const updateAnchorFromUserScroll = () => { - if (!activeAnchorRef.current || isRestoringRef.current) { - return; - } - activeAnchorRef.current = captureFirstVisibleMessage(container); - }; - - container.addEventListener("scroll", updateAnchorFromUserScroll, { - passive: true, - }); - - return () => { - container.removeEventListener("scroll", updateAnchorFromUserScroll); - }; - }, [scrollContainerRef]); - - React.useEffect(() => { - const sentinel = sentinelRef.current; - const container = scrollContainerRef.current; - if ( - !sentinel || - !container || - !fetchOlder || - isLoading || - !hasOlderMessages - ) { - return; - } - - let disposed = false; - let currentObserver: IntersectionObserver | null = null; - - const observe = () => { - if (disposed) { - return; - } - - currentObserver = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting || disposed) { - return; - } - - currentObserver?.disconnect(); - fetchSettledRef.current = false; - activeAnchorRef.current = captureFirstVisibleMessage(container); - - void fetchOlder().finally(() => { - if (disposed) { - return; - } - fetchSettledRef.current = true; - scheduleLayoutCheck(); - observe(); - }); - }, - { root: container, rootMargin: "200px 0px 0px 0px" }, - ); - - currentObserver.observe(sentinel); - }; - - observe(); - return () => { - disposed = true; - currentObserver?.disconnect(); - }; - }, [ - fetchOlder, - hasOlderMessages, - isLoading, - scrollContainerRef, - sentinelRef, - ]); -} diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index e808272fa..4665ca5c5 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -99,7 +99,7 @@ test("preserves user scroll while older channel history loads", async ({ ...window.__BUZZ_E2E__, mock: { ...window.__BUZZ_E2E__?.mock, - historyDelayMs: 1_000, + historyDelayMs: 2_000, }, }; }); @@ -111,32 +111,30 @@ test("preserves user scroll while older channel history loads", async ({ return element && element.scrollHeight > element.clientHeight + 1000; }); - // Move away from the bottom before jumping near the top; otherwise the - // timeline's sticky-bottom guard can intentionally pin the first upward jump. + // Scroll with real wheel input instead of assigning `scrollTop` directly. + // Virtualized lists own their range model and may correct arbitrary absolute + // scroll offsets, but user-wheel scrolling is the behavior we must preserve. + const box = await timeline.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move( + (box?.x ?? 0) + (box?.width ?? 0) / 2, + (box?.y ?? 0) + (box?.height ?? 0) / 2, + ); + + for (let index = 0; index < 80; index += 1) { + await page.mouse.wheel(0, -1200); + await page.waitForTimeout(20); + const metrics = await getTimelineMetrics(page); + if (metrics.scrollTop <= 900) { + break; + } + } + const beforeFetch = await getTimelineMetrics(page); - await timeline.evaluate((element) => { - const timelineElement = element as HTMLDivElement; - timelineElement.scrollTop = timelineElement.scrollHeight; - timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); - }); - await page.waitForTimeout(50); + expect(beforeFetch.scrollTop).toBeLessThanOrEqual(900); - const nearTop = await timeline.evaluate((element) => { - const timelineElement = element as HTMLDivElement; - timelineElement.scrollTop = 180; - timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); - return timelineElement.scrollTop; - }); - expect(nearTop).toBeLessThan(260); - - await page.waitForTimeout(100); - const duringFetch = await timeline.evaluate((element) => { - const timelineElement = element as HTMLDivElement; - timelineElement.scrollTop = timelineElement.scrollTop + 160; - timelineElement.dispatchEvent(new Event("scroll", { bubbles: true })); - return timelineElement.scrollTop; - }); - expect(duringFetch).toBeGreaterThan(nearTop); + await page.mouse.wheel(0, 160); + await page.waitForTimeout(50); const anchorDuringFetch = await getFirstVisibleMessage(page); expect(anchorDuringFetch).not.toBeNull(); @@ -155,7 +153,7 @@ test("preserves user scroll while older channel history loads", async ({ : Number.POSITIVE_INFINITY; }, { - timeout: 3_000, + timeout: 4_000, }, ) .toBeLessThanOrEqual(2); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0cdeff65..49876452f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.15)(react@19.2.6) + react-virtuoso: + specifier: ^4.18.7 + version: 4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -2746,6 +2749,12 @@ packages: '@types/react': optional: true + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -5582,6 +5591,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + react-virtuoso@4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} readable-stream@4.7.0: