diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index d829717709f..519ff3a30ea 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -800,11 +800,21 @@ async function renderHeldTarget() { ), ); }; + let viewportWidth = 800; const resizeViewport = async () => { + viewportWidth -= 10; await act(async () => { for (const observer of resizeObservers) { - if (observer.targets.includes(scroller)) observer.callback(); + if (observer.targets.includes(scroller)) { + observer.callback([ + { + target: scroller, + contentRect: { width: viewportWidth, height: 600 }, + }, + ]); + } } + await new Promise((resolve) => setTimeout(resolve, 0)); }); }; diff --git a/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs b/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs index cabc5dccb55..9a736663a80 100644 --- a/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs +++ b/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs @@ -1,22 +1,207 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldSettleVirtualizedViewportResize } from "./useVirtualizedViewportResize.ts"; +import { + createVirtualizedViewportResizeHandler, + readObservedViewportSize, + shouldSettleVirtualizedViewportResize, +} from "./useVirtualizedViewportResize.ts"; test("viewport resize follows the virtualizer's explicit bottom state", () => { - for (const [virtualizerAtBottom, targetHeld, expected] of [ - [true, false, true], - [false, false, false], - // A held navigation target keeps the viewport even at the floor. - [true, true, false], - ]) { - assert.equal( - shouldSettleVirtualizedViewportResize({ - targetHeld, - virtualizerAtBottom, - }), - expected, - JSON.stringify({ targetHeld, virtualizerAtBottom }), - ); - } + assert.equal( + shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: true, + targetHeld: false, + }), + true, + ); + assert.equal( + shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: false, + targetHeld: false, + }), + false, + ); +}); + +test("a resize that does not change geometry does not settle", () => { + const size = { width: 800, height: 600 }; + assert.equal( + shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: true, + targetHeld: false, + previousSize: size, + nextSize: { ...size }, + }), + false, + ); +}); + +test("a real geometry change still settles", () => { + const previousSize = { width: 800, height: 600 }; + assert.equal( + shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: true, + targetHeld: false, + previousSize, + nextSize: { width: 800, height: 540 }, + }), + true, + ); + assert.equal( + shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: true, + targetHeld: false, + previousSize, + nextSize: { width: 783, height: 600 }, + }), + true, + ); +}); + +test("the first delivery settles because there is no baseline to compare", () => { + assert.equal( + shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: true, + targetHeld: false, + previousSize: null, + nextSize: { width: 800, height: 600 }, + }), + true, + ); +}); + +test("the observed content box takes precedence over the border box", () => { + assert.deepEqual( + readObservedViewportSize({ + borderBoxSize: [{ inlineSize: 800, blockSize: 600 }], + contentRect: { width: 1, height: 2 }, + }), + { width: 1, height: 2 }, + ); + assert.deepEqual( + readObservedViewportSize({ contentRect: { width: 640, height: 480 } }), + { width: 640, height: 480 }, + ); + assert.equal(readObservedViewportSize({}), null); +}); + +// ── Observer wiring ────────────────────────────────────────────────────────── + +function harness({ atBottom = true } = {}) { + const frames = []; + const cancelled = []; + let settles = 0; + let reanchors = 0; + const virtualizerAtBottomRef = { current: atBottom }; + const targetHoldRef = { current: false }; + const handler = createVirtualizedViewportResizeHandler({ + virtualizerAtBottomRef, + targetHoldRef, + settleAtBottom: () => { + settles += 1; + }, + reanchorHeldTarget: () => { + reanchors += 1; + }, + requestFrame: (cb) => { + frames.push(cb); + return frames.length; + }, + cancelFrame: (handle) => cancelled.push(handle), + }); + return { + handler, + cancelled, + virtualizerAtBottomRef, + targetHoldRef, + reanchors: () => reanchors, + settles: () => settles, + pendingFrames: () => frames.length, + runFrames: () => { + const queued = frames.splice(0); + for (const cb of queued) cb(); + }, + deliver: (width, height) => + handler.handleEntries([ + { borderBoxSize: [{ inlineSize: width, blockSize: height }] }, + ]), + }; +} + +test("the settle write is deferred out of the delivery pass", () => { + const h = harness(); + h.deliver(800, 600); + // Nothing may run synchronously — a scroll write here resizes the observed + // element inside the pass that is delivering to us. + assert.equal(h.settles(), 0); + assert.equal(h.pendingFrames(), 1); + h.runFrames(); + assert.equal(h.settles(), 1); +}); + +test("a re-entrant delivery does not queue a second frame", () => { + const h = harness(); + h.deliver(800, 600); + h.deliver(800, 540); + assert.equal(h.pendingFrames(), 1); + h.runFrames(); + assert.equal(h.settles(), 1); +}); + +test("a resize back to the same geometry settles nothing", () => { + const h = harness(); + h.deliver(800, 600); + h.runFrames(); + assert.equal(h.settles(), 1); + + h.deliver(800, 600); + assert.equal(h.pendingFrames(), 0); + h.runFrames(); + assert.equal(h.settles(), 1, "identical geometry must not re-settle"); +}); + +test("geometry is tracked even while the virtualizer is not at bottom", () => { + const h = harness({ atBottom: false }); + h.deliver(800, 600); + h.runFrames(); + assert.equal(h.settles(), 0); +}); + +test("cancel releases a pending frame", () => { + const h = harness(); + h.deliver(800, 600); + h.handler.cancel(); + assert.deepEqual(h.cancelled, [1]); + // A second cancel is a no-op rather than a double free. + h.handler.cancel(); + assert.deepEqual(h.cancelled, [1]); +}); + +test("a newly held target wins over a queued bottom settle", () => { + const h = harness(); + h.deliver(800, 600); + h.targetHoldRef.current = true; + h.runFrames(); + assert.equal(h.reanchors(), 1); + assert.equal(h.settles(), 0); +}); + +test("a held target re-anchors after viewport reflow away from the floor", () => { + const h = harness({ atBottom: false }); + h.targetHoldRef.current = true; + h.deliver(800, 600); + assert.equal(h.reanchors(), 0); + h.runFrames(); + assert.equal(h.reanchors(), 1); + assert.equal(h.settles(), 0); +}); + +test("reader scroll away from bottom cancels a pending settle", () => { + const h = harness(); + h.deliver(800, 600); + h.virtualizerAtBottomRef.current = false; + h.runFrames(); + assert.equal(h.reanchors(), 0); + assert.equal(h.settles(), 0); }); diff --git a/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts b/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts index e8d63c55343..07bcacc8a51 100644 --- a/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts +++ b/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts @@ -1,18 +1,104 @@ import * as React from "react"; +export type VirtualizedViewportSize = { width: number; height: number }; + export function shouldSettleVirtualizedViewportResize({ - targetHeld, virtualizerAtBottom, + targetHeld, + previousSize, + nextSize, }: { - targetHeld: boolean; virtualizerAtBottom: boolean; + targetHeld: boolean; + /** Last size delivered to this observer, or null before the first delivery. */ + previousSize?: VirtualizedViewportSize | null; + nextSize?: VirtualizedViewportSize | null; }): boolean { - return virtualizerAtBottom && !targetHeld; + if (!virtualizerAtBottom || targetHeld) return false; + // No baseline yet (or no readable box): the first delivery still settles, as + // it always has. + if (!previousSize || !nextSize) return true; + // A resize that round-trips to the same geometry has nothing to settle, and + // settling anyway is what keeps re-entering the observation pass. + return ( + previousSize.width !== nextSize.width || + previousSize.height !== nextSize.height + ); +} + +export function readObservedViewportSize( + entry: ResizeObserverEntry, +): VirtualizedViewportSize | null { + // observe() uses the content box, which can change when a scrollbar appears. + const rect = entry.contentRect; + if (rect) return { width: rect.width, height: rect.height }; + const borderBox = entry.borderBoxSize?.[0]; + return borderBox + ? { width: borderBox.inlineSize, height: borderBox.blockSize } + : null; } -/** Re-settles a bottom-pinned virtualized timeline after viewport reflow. - * While an accepted navigation target holds the viewport, it re-anchors - * that target instead. */ +/** + * The ResizeObserver callback, lifted out of the hook so it can be driven + * without a DOM. + * + * `settleAtBottom` scrolls, which re-measures rows and can toggle the + * scrollbar — resizing the observed element at a deeper depth of the pass that + * is delivering to us. Running the write on the next frame keeps it out of that + * pass, so the browser stops reporting "ResizeObserver loop completed with + * undelivered notifications". This mirrors the rAF defer the sibling observer in + * `useVirtualizedBottomSettle` already uses. + */ +export function createVirtualizedViewportResizeHandler({ + virtualizerAtBottomRef, + targetHoldRef, + settleAtBottom, + reanchorHeldTarget, + requestFrame, + cancelFrame, +}: { + virtualizerAtBottomRef: { current: boolean }; + targetHoldRef: { current: boolean }; + settleAtBottom: () => void; + reanchorHeldTarget?: () => void; + requestFrame: (cb: () => void) => number; + cancelFrame: (handle: number) => void; +}) { + let previousSize: VirtualizedViewportSize | null = null; + let frame: number | null = null; + + return { + handleEntries(entries: readonly ResizeObserverEntry[]) { + const entry = entries.at(-1); + const nextSize = entry ? readObservedViewportSize(entry) : null; + const settle = shouldSettleVirtualizedViewportResize({ + virtualizerAtBottom: virtualizerAtBottomRef.current, + targetHeld: targetHoldRef.current, + previousSize, + nextSize, + }); + if (nextSize) previousSize = nextSize; + if (frame !== null || (!settle && !targetHoldRef.current)) return; + frame = requestFrame(() => { + frame = null; + // Navigation and reader scroll can change which position we own. + if (targetHoldRef.current) { + reanchorHeldTarget?.(); + } else if (virtualizerAtBottomRef.current) { + settleAtBottom(); + } + }); + }, + cancel() { + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + }, + }; +} + +/** Re-anchors a held navigation target or bottom-pinned timeline after reflow. */ export function useVirtualizedViewportResize( scrollContainerRef: React.RefObject, virtualizerAtBottomRef: React.RefObject, @@ -30,20 +116,20 @@ export function useVirtualizedViewportResize( return; } - const observer = new ResizeObserver(() => { - if ( - shouldSettleVirtualizedViewportResize({ - targetHeld: targetHoldRef.current, - virtualizerAtBottom: virtualizerAtBottomRef.current, - }) - ) { - settleAtBottom(); - } else if (targetHoldRef.current) { - reanchorHeldTarget?.(); - } + const handler = createVirtualizedViewportResizeHandler({ + virtualizerAtBottomRef, + targetHoldRef, + settleAtBottom, + reanchorHeldTarget, + requestFrame: requestAnimationFrame, + cancelFrame: cancelAnimationFrame, }); + const observer = new ResizeObserver(handler.handleEntries); observer.observe(container); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + handler.cancel(); + }; }, [ reanchorHeldTarget, scrollContainerRef,