Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
122 changes: 104 additions & 18 deletions desktop/src/features/messages/ui/useVirtualizedViewportResize.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>,
virtualizerAtBottomRef: React.RefObject<boolean>,
Expand All @@ -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,
Expand Down
Loading