Retain a stable message window in channel timelines (#1698)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-07-12 21:41:38 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d Tyler Longwell
parent 1fa91f5691
commit b0ad2b66dd
53 changed files with 4617 additions and 584 deletions
+1
View File
@@ -77,6 +77,7 @@
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"upng-js": "^2.1.0",
"virtua": "0.49.2",
"yaml": "^2.8.3",
"zod": "^4.4.3"
},
@@ -76,6 +76,7 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder,
header,
hasOlderMessages,
historyExhausted,
isFetchingOlder,
followThreadById,
isFollowingThread,
@@ -191,6 +192,7 @@ export const ChannelPane = React.memo(function ChannelPane({
timelineScrollRef,
composerWrapperRef,
`${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`,
"css-variable",
);
const clearWelcomeComposerDismissTimer = React.useCallback(() => {
if (welcomeComposerDismissTimerRef.current !== null) {
@@ -617,6 +619,7 @@ export const ChannelPane = React.memo(function ChannelPane({
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFetchingOlder={isFetchingOlder}
@@ -660,6 +663,9 @@ export const ChannelPane = React.memo(function ChannelPane({
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
splitThreadPanelOpen={
useSplitAuxiliaryPane && Boolean(openThreadHeadId)
}
threadUnreadCounts={threadUnreadCounts}
/>
{isNonMemberView ? (
@@ -690,7 +696,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55"
data-testid="channel-composer-overlay"
ref={composerWrapperRef}
>
@@ -736,7 +742,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
<div className="min-h-8 overflow-visible bg-background px-5 pb-1.5 pt-0">
<div className="min-h-8 overflow-visible bg-transparent px-5 pb-1.5 pt-0">
<div className="flex h-full w-full items-center gap-2 overflow-visible">
{hasComposerBotActivity ? (
<div className="flex min-w-0 flex-1 overflow-visible">
@@ -46,6 +46,8 @@ export type ChannelPaneProps = {
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;
/** True when the loaded window provably starts at the channel's beginning. */
historyExhausted?: boolean;
isFetchingOlder?: boolean;
isJoining?: boolean;
isSinglePanelView?: boolean;
@@ -80,7 +80,6 @@ import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useChannelUnreadState } from "./useChannelUnreadState";
import type { ChannelScreenProps } from "./ChannelScreen.types";
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
EMPTY_RELAY_EVENTS: RelayEvent[] = [];
export function ChannelScreen({
@@ -190,7 +189,7 @@ export function ChannelScreen({
effectiveOpenThreadHeadId,
);
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
const latestActiveMessage = React.useMemo(() => {
const messages = messagesQuery.data;
@@ -878,6 +877,7 @@ export function ChannelScreen({
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
@@ -3,6 +3,8 @@ import test from "node:test";
import {
appendOlderChannelWindow,
mapChannelWindowEvents,
channelWindowHasMore,
channelWindowHistoryExhausted,
channelWindowThreadSummaries,
emptyChannelWindowStore,
flattenChannelWindowEvents,
@@ -401,3 +403,26 @@ test("mapChannelWindowEvents rewrites live overlay events", () => {
"edited-live",
);
});
test("exhaustion is unresolved (false) on an empty store, unlike hasMore's default", () => {
const store = emptyChannelWindowStore();
assert.equal(channelWindowHasMore(store), false);
// Both read "no more history", but exhaustion must NOT claim the boundary
// is proven before any page resolves — an unloaded window is unknown.
assert.equal(channelWindowHistoryExhausted(store), false);
});
test("exhaustion tracks only a resolved tail page's hasMore", () => {
const open = replaceNewestChannelWindow(
emptyChannelWindowStore(),
page(null, [event("newest", 100)], { hasMore: true }),
);
assert.equal(channelWindowHistoryExhausted(open), false);
const closed = appendOlderChannelWindow(
open,
page(open.pages[0].nextCursor, [event("oldest", 99)], { hasMore: false }),
);
assert.equal(channelWindowHistoryExhausted(closed), true);
assert.equal(channelWindowHasMore(closed), false);
});
@@ -282,6 +282,17 @@ export function channelWindowHasMore(store: ChannelWindowStore) {
return tail?.hasMore ?? false;
}
/**
* Whether the loaded window PROVABLY starts at the channel's beginning. This
* is not `!channelWindowHasMore`: an empty store also reports "no more", but
* that means the boundary is unresolved (nothing has loaded), not exhausted.
* Only a resolved tail page saying `hasMore: false` proves the start.
*/
export function channelWindowHistoryExhausted(store: ChannelWindowStore) {
const tail = store.pages[store.pages.length - 1];
return tail !== undefined && !tail.hasMore;
}
/**
* Per-root thread summaries for badge rendering: authoritative page summaries
* overlaid with any fresher relay-pushed live summaries. The live overlay also
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { timelineImageUrls } from "./timelineImagePreload.ts";
function message(over = {}) {
return {
id: "m1",
createdAt: 0,
author: "a",
time: "now",
body: "",
depth: 0,
...over,
};
}
test("timelineImageUrls warms chrome images but leaves attachments lazy", () => {
const urls = timelineImageUrls(
message({
avatarUrl: "https://example.com/avatar.jpg",
body: [
"![first](https://example.com/one.png)",
'![second](https://example.com/two.webp "caption")',
].join("\n"),
tags: [
["imeta", "url https://example.com/three.jpg", "m image/jpeg"],
[
"imeta",
"url https://example.com/movie.mp4",
"m video/mp4",
"image https://example.com/poster.jpg",
"thumb https://example.com/thumb.jpg",
],
["emoji", "party", "https://example.com/party.png"],
],
reactions: [
{
emoji: ":party:",
emojiUrl: "https://example.com/reaction.png",
count: 1,
users: [],
},
],
}),
);
assert.deepEqual(
new Set(urls),
new Set([
"https://example.com/avatar.jpg",
"https://example.com/poster.jpg",
"https://example.com/thumb.jpg",
"https://example.com/party.png",
"https://example.com/reaction.png",
]),
);
assert.ok(!urls.includes("https://example.com/one.png"));
assert.ok(!urls.includes("https://example.com/three.jpg"));
});
test("timelineImageUrls deduplicates chrome image URLs", () => {
const url = "https://example.com/same.png";
assert.deepEqual(
timelineImageUrls(
message({
avatarUrl: url,
tags: [["emoji", "same", url]],
}),
),
[url],
);
});
test("preloadTimelineImages requests URLs once and keeps requests alive", async () => {
const { preloadTimelineImages } = await import("./timelineImagePreload.ts");
const previousImage = globalThis.Image;
const requested = [];
class FakeImage extends EventTarget {
set src(url) {
requested.push(url);
}
}
globalThis.Image = FakeImage;
try {
const state = { activeImages: new Set(), requestedUrls: new Set() };
const messages = [message({ avatarUrl: "https://example.com/one.png" })];
preloadTimelineImages(messages, state);
preloadTimelineImages(messages, state);
assert.deepEqual(requested, ["https://example.com/one.png"]);
assert.equal(state.activeImages.size, 1);
state.activeImages.values().next().value.dispatchEvent(new Event("load"));
assert.equal(state.activeImages.size, 0);
} finally {
globalThis.Image = previousImage;
}
});
@@ -0,0 +1,57 @@
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import type { TimelineMessage } from "../types";
import { parseImetaTags } from "./parseImeta";
/**
* Return non-message-media image URLs worth warming before a virtualized row
* mounts. Inline image attachments deliberately stay out of this projection:
* their native-lazy thumbnail must load before the full-resolution request.
*/
export function timelineImageUrls(message: TimelineMessage): string[] {
const urls = new Set<string>();
const add = (url: string | null | undefined) => {
if (url) urls.add(rewriteRelayUrl(url));
};
add(message.avatarUrl);
if (message.tags) {
for (const entry of parseImetaTags(message.tags).values()) {
// Video poster frames are not part of the progressive image path.
if (entry.m?.startsWith("video/")) {
add(entry.image);
add(entry.thumb);
}
}
for (const tag of message.tags) {
if (tag[0] === "emoji") add(tag[2]);
}
}
for (const reaction of message.reactions ?? []) add(reaction.emojiUrl);
return [...urls];
}
export type TimelineImagePreloadState = {
activeImages: Set<HTMLImageElement>;
requestedUrls: Set<string>;
};
/** Start all image requests now, independently of Virtua row mounting. */
export function preloadTimelineImages(
messages: readonly TimelineMessage[],
state: TimelineImagePreloadState,
): void {
for (const message of messages) {
for (const url of timelineImageUrls(message)) {
if (state.requestedUrls.has(url)) continue;
state.requestedUrls.add(url);
const image = new Image();
state.activeImages.add(image);
const release = () => state.activeImages.delete(image);
image.addEventListener("load", release, { once: true });
image.addEventListener("error", release, { once: true });
image.src = url;
}
}
}
@@ -118,7 +118,7 @@ test("buildTimelineItems: member additions by one actor group within five minute
group?.entries.map((groupEntry) => groupEntry.message.id),
["a", "b", "c"],
);
assert.equal(group?.key, "a");
assert.equal(group?.key, "c");
});
test("buildTimelineItems: self-joins group across different members within five minutes", () => {
@@ -146,7 +146,29 @@ test("buildTimelineItems: self-joins group across different members within five
);
});
test("buildTimelineItems: member-add window is fixed from the first addition", () => {
test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => {
const start = dayAt(2026, 6, 14);
const loaded = [
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }),
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 360 }),
entry({ id: "message", createdAt: start + 600 }),
];
const prepended = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
...loaded,
];
const loadedItems = buildTimelineItems(loaded, null).items;
const prependedItems = buildTimelineItems(prepended, null).items;
const loadedKeys = loadedItems.slice(1).map((item) => item.key);
const prependedKeys = prependedItems.slice(1).map((item) => item.key);
assert.deepEqual(loadedKeys, ["c", "message"]);
assert.deepEqual(prependedKeys, ["a", "c", "message"]);
assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys);
});
test("buildTimelineItems: member-add window is fixed from the newest addition", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
@@ -155,7 +177,7 @@ test("buildTimelineItems: member-add window is fixed from the first addition", (
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group", "system"]);
assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]);
});
test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => {
@@ -101,6 +101,60 @@ function parseMembershipChangePayload(
}
}
function membershipChangesCanGroup(
first: MembershipChangePayload,
second: MembershipChangePayload,
): boolean {
return (
first.mode === second.mode &&
(first.mode === "joined" || first.actor === second.actor)
);
}
/**
* Membership groups are anchored from their newest entry so prepending older
* history cannot repartition the rows that are already loaded. Their key is
* likewise the newest entry's key: extending the oldest visible group changes
* its contents, but not its identity or the virtual list's existing key suffix.
*/
function buildMembershipGroups(
entries: readonly MainTimelineEntry[],
barrierIndexes: ReadonlySet<number>,
): Map<number, MainTimelineEntry[]> {
const groups = new Map<number, MainTimelineEntry[]>();
for (let end = entries.length - 1; end >= 0; ) {
const newestEntry = entries[end];
const newestPayload = parseMembershipChangePayload(newestEntry);
if (!newestPayload) {
end -= 1;
continue;
}
let start = end;
while (start > 0) {
const candidate = entries[start - 1];
const candidatePayload = parseMembershipChangePayload(candidate);
if (
barrierIndexes.has(start) ||
!candidatePayload ||
!membershipChangesCanGroup(candidatePayload, newestPayload) ||
newestEntry.message.createdAt < candidate.message.createdAt ||
newestEntry.message.createdAt - candidate.message.createdAt >
MEMBERSHIP_GROUP_WINDOW_SECONDS
) {
break;
}
start -= 1;
}
if (start < end) groups.set(start, entries.slice(start, end + 1));
end = start - 1;
}
return groups;
}
/**
* Walks the (already top-level-filtered) entries once, emitting a day-divider
* at each calendar-day boundary and an unread-divider above the first unread
@@ -113,7 +167,6 @@ export function buildTimelineItems(
const items: TimelineItem[] = [];
let previousGroupEntry: MainTimelineEntry | null = null;
let previousMessageItemIndex: number | null = null;
let previousMembershipItemIndex: number | null = null;
// Index boundaries by their start position so the walk below can look up the
// prepend-stable section key (start-of-local-day). Keying the divider by
@@ -124,6 +177,17 @@ export function buildTimelineItems(
(boundary: DayGroupBoundary) => [boundary.startIndex, boundary] as const,
),
);
const membershipBarrierIndexes = new Set(dayBoundariesByStartIndex.keys());
if (firstUnreadMessageId) {
const unreadIndex = entries.findIndex(
(entry) => entry.message.id === firstUnreadMessageId,
);
if (unreadIndex > 0) membershipBarrierIndexes.add(unreadIndex);
}
const membershipGroupsByStartIndex = buildMembershipGroups(
entries,
membershipBarrierIndexes,
);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -134,7 +198,6 @@ export function buildTimelineItems(
if (dayBoundary) {
previousGroupEntry = null;
previousMessageItemIndex = null;
previousMembershipItemIndex = null;
items.push({
kind: "day-divider",
key: dayBoundary.key,
@@ -145,7 +208,6 @@ export function buildTimelineItems(
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
previousGroupEntry = null;
previousMessageItemIndex = null;
previousMembershipItemIndex = null;
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
}
@@ -154,48 +216,22 @@ export function buildTimelineItems(
previousGroupEntry = null;
previousMessageItemIndex = null;
const membershipChange = parseMembershipChangePayload(entry);
const previousItem =
previousMembershipItemIndex === null
? null
: items[previousMembershipItemIndex];
const previousEntries =
previousItem?.kind === "system-group"
? previousItem.entries
: previousItem?.kind === "system"
? [previousItem.entry]
: [];
const firstPreviousEntry = previousEntries[0];
const firstPreviousPayload = firstPreviousEntry
? parseMembershipChangePayload(firstPreviousEntry)
: null;
if (
membershipChange &&
firstPreviousEntry &&
firstPreviousPayload?.mode === membershipChange.mode &&
(membershipChange.mode === "joined" ||
firstPreviousPayload.actor === membershipChange.actor) &&
message.createdAt >= firstPreviousEntry.message.createdAt &&
message.createdAt - firstPreviousEntry.message.createdAt <=
MEMBERSHIP_GROUP_WINDOW_SECONDS
) {
const groupIndex = previousMembershipItemIndex as number;
items[groupIndex] = {
const membershipGroup = membershipGroupsByStartIndex.get(i);
if (membershipGroup) {
const newestEntry = membershipGroup[membershipGroup.length - 1];
items.push({
kind: "system-group",
key: entryRenderKey(firstPreviousEntry),
entries: [...previousEntries, entry],
};
key: entryRenderKey(newestEntry),
entries: membershipGroup,
});
i += membershipGroup.length - 1;
continue;
}
items.push({ kind, key: renderKey, entry });
previousMembershipItemIndex = membershipChange ? items.length - 1 : null;
continue;
}
previousMembershipItemIndex = null;
const isContinuation =
previousGroupEntry !== null &&
hasSameMessageAuthor(previousGroupEntry.message, message) &&
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildVirtualizedItems,
didPrependVirtualizedTimeline,
virtualizedItemKey,
} from "./virtualizedTimelineItems.ts";
// Timestamps: day A = 2026-06-01, day B = 2026-06-02, day Z = 2026-05-31.
function dayAt(year, month, day, hour = 12, minute = 0) {
return Math.floor(
new Date(year, month - 1, day, hour, minute, 0).getTime() / 1_000,
);
}
function messageItem(key) {
return {
kind: "message",
key,
entry: { message: { id: key } },
isContinuation: false,
isFollowedByContinuation: false,
};
}
function group(dayKey, headingTimestamp, itemKeys) {
return {
key: dayKey,
headingTimestamp,
items: itemKeys.map(messageItem),
};
}
const DAY_A = dayAt(2026, 6, 1);
const DAY_B = dayAt(2026, 6, 2);
const DAY_Z = dayAt(2026, 5, 31);
function keysOf(dayGroups, { leading = undefined, exhausted = false } = {}) {
return buildVirtualizedItems(dayGroups, leading, exhausted).map(
virtualizedItemKey,
);
}
/**
* Virtua's `shift` moves every cached slot uniformly by the length delta.
* With shift admitted, assert every previous key's cached height lands on the
* SAME logical item in the new list.
*/
function assertShiftAdmittedAndCacheClean(previousKeys, keys) {
assert.equal(didPrependVirtualizedTimeline(previousKeys, keys), true);
const delta = keys.length - previousKeys.length;
previousKeys.forEach((key, index) => {
assert.equal(
keys[index + delta],
key,
`cached height at slot ${index} (${key}) would land on ${keys[index + delta]}`,
);
});
}
test("oldest day carries no divider while more history exists", () => {
const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])]);
assert.deepEqual(keys, ["a3", "a4", "a5", "bottom-spacer"]);
});
test("oldest day divider renders once history is exhausted", () => {
const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])], {
exhausted: true,
});
assert.deepEqual(keys, [
"day-divider:day-A",
"a3",
"a4",
"a5",
"bottom-spacer",
]);
});
test("interior day boundaries always render dividers", () => {
const keys = keysOf([
group("day-A", DAY_A, ["a3"]),
group("day-B", DAY_B, ["b1", "b2"]),
]);
assert.deepEqual(keys, [
"a3",
"day-divider:day-B",
"b1",
"b2",
"bottom-spacer",
]);
});
test("undated group never renders a divider even when proven", () => {
const keys = keysOf(
[group("day-undated", null, ["u1"]), group("day-B", DAY_B, ["b1"])],
{ exhausted: true },
);
assert.deepEqual(keys, ["u1", "day-divider:day-B", "b1", "bottom-spacer"]);
});
test("same-day prepend into the oldest day admits shift with a clean cache", () => {
const previous = keysOf([
group("day-A", DAY_A, ["a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
const next = keysOf([
group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
assertShiftAdmittedAndCacheClean(previous, next);
});
test("cross-day prepend materializes the newly proven divider as pure prefix", () => {
const previous = keysOf([
group("day-A", DAY_A, ["a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
// Day Z loads before day A: day A's boundary is now proven, so BOTH the
// z-rows and day A's divider enter as prefix.
const next = keysOf([
group("day-Z", DAY_Z, ["z1", "z2"]),
group("day-A", DAY_A, ["a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
assert.deepEqual(
next.slice(0, 4),
["z1", "z2", "day-divider:day-A", "a3"],
"day-Z itself is now the unproven oldest day and carries no divider",
);
assertShiftAdmittedAndCacheClean(previous, next);
});
test("mixed page (older day rows + same-day completion) admits shift", () => {
const previous = keysOf([
group("day-A", DAY_A, ["a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
const next = keysOf([
group("day-Z", DAY_Z, ["z9"]),
group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
assertShiftAdmittedAndCacheClean(previous, next);
});
test("history exhaustion with an empty page inserts the divider as pure prefix", () => {
const groups = [
group("day-A", DAY_A, ["a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
];
const previous = keysOf(groups);
const next = keysOf(groups, { exhausted: true });
assert.deepEqual(next.slice(0, 2), ["day-divider:day-A", "a3"]);
assertShiftAdmittedAndCacheClean(previous, next);
});
test("leading content arriving with the final page stays a pure prefix", () => {
const previous = keysOf([
group("day-A", DAY_A, ["a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
]);
const next = keysOf(
[
group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]),
group("day-B", DAY_B, ["b1"]),
],
{ leading: "intro", exhausted: true },
);
assert.deepEqual(next.slice(0, 2), ["leading-content", "day-divider:day-A"]);
assertShiftAdmittedAndCacheClean(previous, next);
});
test("divider count and order match proven boundaries exactly", () => {
const items = buildVirtualizedItems(
[
group("day-Z", DAY_Z, ["z1"]),
group("day-A", DAY_A, ["a1"]),
group("day-B", DAY_B, ["b1"]),
],
undefined,
true,
);
const dividers = items.filter((item) => item.kind === "day-divider");
assert.deepEqual(
dividers.map((item) => item.key),
["day-divider:day-Z", "day-divider:day-A", "day-divider:day-B"],
);
// Each divider precedes its day's first message.
const keys = items.map(virtualizedItemKey);
assert.equal(keys.indexOf("day-divider:day-Z"), keys.indexOf("z1") - 1);
assert.equal(keys.indexOf("day-divider:day-A"), keys.indexOf("a1") - 1);
assert.equal(keys.indexOf("day-divider:day-B"), keys.indexOf("b1") - 1);
});
test("naive counterexample stays rejected: same key cannot precede prepended rows", () => {
// Regression pin for the shape Wren vetoed: if the oldest day's divider
// existed BEFORE the same-day prepend, admission must fail.
assert.equal(
didPrependVirtualizedTimeline(
["day-divider:day-A", "a3", "a4", "a5", "bottom-spacer"],
["day-divider:day-A", "a1", "a2", "a3", "a4", "a5", "bottom-spacer"],
),
false,
);
});
@@ -0,0 +1,106 @@
/**
* Pure item-assembly for the virtualized timeline row stream.
*
* Kept free of React rendering (only a type-level ReactNode) so the
* exact-suffix `shift`-admission invariants are covered by the lib-level
* `*.test.mjs` suite.
*/
import type * as React from "react";
import {
getTimelineItemKey,
type TimelineDayGroup,
type TimelineNonDayItem,
} from "./timelineItems";
export type VirtualizedTimelineItem =
| {
kind: "leading-content";
content: React.ReactNode;
}
| { kind: "bottom-spacer" }
| { kind: "day-divider"; key: string; headingTimestamp: number }
| {
kind: "timeline-item";
item: TimelineNonDayItem;
};
export function virtualizedItemKey(item: VirtualizedTimelineItem): string {
if (item.kind === "bottom-spacer") return "bottom-spacer";
if (item.kind === "leading-content") return "leading-content";
if (item.kind === "day-divider") return item.key;
return getTimelineItemKey(item.item);
}
/**
* Flattens day groups into the virtual row stream.
*
* Day dividers are standalone items emitted only at PROVEN day boundaries: a
* boundary is proven when a strictly older loaded day precedes it in the
* window, or when history is exhausted (the window provably starts at the
* channel's beginning). The oldest loaded day gets no divider while more
* history exists — its "start" is just the arbitrary edge of the loaded
* window, and a divider item there would have to accept older same-day rows
* prepending BEHIND it, which breaks the exact-suffix key admission that
* Virtua's `shift` depends on. (The previous in-row divider avoided that key
* problem but made the day's head row change shape mid-commit when the
* divider migrated off it, causing a one-frame anchor jump at page merges.)
* A proven boundary is immutable — any strictly older message belongs to a
* strictly older day — so its divider only ever enters the stream as part of
* a prepended prefix and never mutates the existing key suffix.
*/
export function buildVirtualizedItems(
dayGroups: readonly TimelineDayGroup[],
leadingContent: React.ReactNode | undefined,
historyExhausted: boolean,
): VirtualizedTimelineItem[] {
const timelineItems = dayGroups.flatMap((group, groupIndex) => {
const boundaryProven = groupIndex > 0 || historyExhausted;
const divider =
group.headingTimestamp !== null && boundaryProven
? [
{
kind: "day-divider" as const,
// Namespaced so a divider key can never collide with any
// timeline item key (message ids, `unread-*`, sentinels).
key: `day-divider:${group.key}`,
headingTimestamp: group.headingTimestamp,
},
]
: [];
return [
...divider,
...group.items.map((item) => ({
kind: "timeline-item" as const,
item,
})),
];
});
return [
...(leadingContent
? [
{
kind: "leading-content" as const,
content: leadingContent,
},
]
: []),
...timelineItems,
{ kind: "bottom-spacer" as const },
];
}
export function didPrependVirtualizedTimeline(
previousKeys: readonly string[],
keys: readonly string[],
): boolean {
const prependedCount = keys.length - previousKeys.length;
// Virtua shifts its positional size cache by the length delta, so enable
// `shift` only when every previous slot is the exact suffix it expects.
return (
prependedCount > 0 &&
previousKeys.every((key, index) => key === keys[index + prependedCount])
);
}
@@ -0,0 +1,60 @@
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { UserAvatar } from "@/shared/ui/UserAvatar";
export type DirectMessageIntroParticipant = {
avatarUrl: string | null;
displayName: string;
pubkey: string;
};
export function DirectMessageIntroAvatarStack({
participants,
}: {
participants: DirectMessageIntroParticipant[];
}) {
const { hiddenCount, visibleParticipants } =
getDmParticipantPreview(participants);
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
return (
<div
aria-hidden="true"
className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</div>
))}
{hiddenCount > 0 ? (
<div
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-lg leading-none">+{hiddenCount}</span>
</span>
</div>
) : null}
</div>
);
}
@@ -902,7 +902,7 @@ function MessageComposerImpl({
>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-5 bg-background"
className="absolute inset-x-0 bottom-0 h-5 bg-transparent"
/>
<div className="relative flex w-full flex-col gap-0">
<ComposerReplyEditBanner
@@ -7,7 +7,7 @@ import {
selectTimelineBodySurface,
selectTimelineIntroSurface,
} from "@/features/messages/lib/timelineSnapshot";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { preloadTimelineImages } from "@/features/messages/lib/timelineImagePreload";
import type { TimelineMessage } from "@/features/messages/types";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
@@ -18,11 +18,17 @@ import { channelChrome } from "@/shared/layout/chromeLayout";
import { Spinner } from "@/shared/ui/spinner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages";
import {
DirectMessageIntroAvatarStack,
type DirectMessageIntroParticipant,
} from "./DirectMessageIntroAvatarStack";
import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages";
export type MessageTimelineHandle = {
scrollToBottomOnNextUpdate: () => void;
@@ -51,6 +57,12 @@ type MessageTimelineProps = {
currentPubkey?: string;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
/**
* True when the loaded window provably starts at the channel's beginning
* (a resolved tail page with `hasMore: false`) — NOT merely the absence of
* a paging signal. Gates the oldest loaded day's divider.
*/
historyExhausted?: boolean;
/** Optional external ref to the scroll container — used by the parent to
* observe scroll position or adjust padding dynamically. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
@@ -91,6 +103,7 @@ type MessageTimelineProps = {
searchQuery?: string;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
splitThreadPanelOpen?: boolean;
/** Event id of the oldest unread top-level message at channel open, or null. */
firstUnreadMessageId?: string | null;
/** Count of unread top-level messages at channel open. */
@@ -120,20 +133,26 @@ type ChannelIntro = {
* message list. Must be module-level so its identity never changes. */
const EMPTY_MESSAGES: TimelineMessage[] = [];
type DirectMessageIntroParticipant = {
avatarUrl: string | null;
displayName: string;
pubkey: string;
};
type TimelineSnapshot = {
channelId: string | null;
messages: TimelineMessage[];
/**
* History-exhaustion proof captured with the SAME rows it was derived from.
* The oldest-day divider may only exist when this is true, and rows and
* proof must travel every transport stage (deferral, buffering, settle
* gating) as one value: delivering a fresh proof on the urgent render path
* while the rows ride the deferred path lets an intermediate commit mint a
* divider against the previous, partially-loaded oldest day — which breaks
* Virtua's exact-suffix shift admission when the withheld same-day rows
* finally land (the pass-1 tear, ledgered 2026-07-11).
*/
historyExhausted: boolean;
};
const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = {
channelId: null,
messages: EMPTY_MESSAGES,
historyExhausted: false,
};
const MessageTimelineBase = React.forwardRef<
@@ -155,6 +174,7 @@ const MessageTimelineBase = React.forwardRef<
fetchOlder,
hasComposerOverlay = true,
hasOlderMessages = true,
historyExhausted = false,
isFetchingOlder = false,
followThreadById,
huddleMemberPubkeys,
@@ -181,6 +201,7 @@ const MessageTimelineBase = React.forwardRef<
searchQuery,
targetMessageId = null,
onTargetReached,
splitThreadPanelOpen = false,
firstUnreadMessageId = null,
unreadCount = 0,
threadUnreadCounts,
@@ -191,6 +212,21 @@ const MessageTimelineBase = React.forwardRef<
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(null);
const [virtualizerScrollParent, setVirtualizerScrollParent] =
React.useState<HTMLDivElement | null>(null);
const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] =
React.useReducer((version: number) => version + 1, 0);
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
React.useState<TimelineVirtualizerApi | null>(null);
const useTimelineVirtualizer = true;
const activeScrollContainerRef = React.useMemo(
() => ({
get current() {
return virtualizerScrollParent ?? scrollContainerRef.current;
},
}),
[scrollContainerRef, virtualizerScrollParent],
);
// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
@@ -207,14 +243,21 @@ const MessageTimelineBase = React.forwardRef<
// route change can paint the previous channel's deferred rows for a frame even
// though the sidebar/header already moved to the new channel.
const liveSnapshot = React.useMemo<TimelineSnapshot>(
() => ({ channelId: channelId ?? null, messages }),
[channelId, messages],
() => ({ channelId: channelId ?? null, messages, historyExhausted }),
[channelId, historyExhausted, messages],
);
const deferredSnapshot = React.useDeferredValue(
liveSnapshot,
EMPTY_TIMELINE_SNAPSHOT,
);
const deferredMessages = deferredSnapshot.messages;
const imagePreloadStateRef = React.useRef({
activeImages: new Set<HTMLImageElement>(),
requestedUrls: new Set<string>(),
});
React.useEffect(() => {
preloadTimelineImages(messages, imagePreloadStateRef.current);
}, [messages]);
const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
deferredSnapshot,
liveSnapshot,
@@ -230,12 +273,60 @@ const MessageTimelineBase = React.forwardRef<
// painted at a stale offset until the user's next scroll event forces layout.
const scrollContainerDomKey = channelId ?? "none";
React.useLayoutEffect(() => {
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
void scrollContainerDomKey;
if (!useTimelineVirtualizer) {
setVirtualizerScrollParent(scrollContainerRef.current);
}
setTimelineVirtualizerApi(null);
}, [scrollContainerRef, scrollContainerDomKey]);
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
isLoading: isLoading || isDeferredSnapshotStale,
liveCount: messages.length,
});
const showTimelineSkeleton = timelineBodySurface === "skeleton";
const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] =
React.useState(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes
React.useEffect(() => {
setIsSemanticallyAtBottom(true);
}, [channelId]);
// Zulip-style data semantics: once the reader leaves the bottom, keep the
// virtualizer's logical tail frozen. Live arrivals accumulate behind the
// "new messages" affordance instead of changing Virtua's item model under
// the reading position. Prepends still flow through immediately and Virtua's
// `shift` transaction preserves the stable keyed row.
const bufferedTimeline = useBufferedTimelineMessages({
channelId,
isAtBottom:
isSemanticallyAtBottom ||
targetMessageId !== null ||
searchActiveMessageId !== null,
messages: deferredMessages,
});
// Hold older-page render commits until the scroller is at rest: WKWebView
// can drop scrollTop compensation writes during live trackpad momentum.
// Full rationale in useSettleGatedPrependMessages.
//
// The history-exhaustion proof rides through this gate as snapshot metadata
// (`meta`), so while a prepend is withheld the rendered rows keep the proof
// they were projected with. The buffering stage above cannot split the pair:
// it only freezes the TAIL (live arrivals) and passes history prepends
// through unchanged, so the oldest rows the proof speaks about are exactly
// the deferred snapshot's oldest rows.
const {
messages: renderedMessages,
meta: renderedHistoryExhausted,
isHoldingPrepend,
} = useSettleGatedPrependMessages({
channelId,
messages: bufferedTimeline.messages,
meta: deferredSnapshot.historyExhausted,
scrollElementRef: activeScrollContainerRef,
});
const {
highlightedMessageId,
@@ -245,21 +336,85 @@ const MessageTimelineBase = React.forwardRef<
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
onVirtualizerAtBottomStateChange,
} = useAnchoredScroll({
channelId,
contentRef,
isLoading: showTimelineSkeleton,
messages: deferredMessages,
messages: renderedMessages,
onTargetReached,
scrollContainerRef,
scrollContainerRef: activeScrollContainerRef,
splitPanelOpen: splitThreadPanelOpen,
targetMessageId,
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
virtualSettleAtBottom: timelineVirtualizerApi?.settleAtBottom,
virtualizerOwnsPrependAnchoring: useTimelineVirtualizer,
virtualizerRenderVersion,
});
const hasConfirmedVirtualizerBottomRef = React.useRef(false);
const bottomConfirmationChannelRef = React.useRef(channelId);
if (bottomConfirmationChannelRef.current !== channelId) {
bottomConfirmationChannelRef.current = channelId;
hasConfirmedVirtualizerBottomRef.current = false;
}
const suppressNextSemanticBottomRef = React.useRef(false);
const semanticAtBottomRef = React.useRef(isSemanticallyAtBottom);
semanticAtBottomRef.current = isSemanticallyAtBottom;
const semanticBottomRafRef = React.useRef<number | null>(null);
const queueSemanticBottom = React.useCallback((atBottom: boolean) => {
semanticAtBottomRef.current = atBottom;
if (semanticBottomRafRef.current !== null) {
window.cancelAnimationFrame(semanticBottomRafRef.current);
}
semanticBottomRafRef.current = window.requestAnimationFrame(() => {
semanticBottomRafRef.current = null;
setIsSemanticallyAtBottom(atBottom);
});
}, []);
React.useEffect(
() => () => {
if (semanticBottomRafRef.current !== null) {
window.cancelAnimationFrame(semanticBottomRafRef.current);
}
},
[],
);
const handleVirtualizerAtBottomStateChange = React.useCallback(
(atBottom: boolean) => {
// Virtua can emit an intermediate non-bottom offset while its initial
// scroll-to-end is still converging. Do not turn that mount transient
// into a semantic dataset freeze: wait until this channel has reached a
// confirmed bottom once, then track genuine bottom -> history movement.
if (atBottom) {
hasConfirmedVirtualizerBottomRef.current = true;
onVirtualizerAtBottomStateChange(true);
if (suppressNextSemanticBottomRef.current) {
// Freezing the tail shortens Virtua's model and can itself make the
// current offset report "at bottom". That synthetic transition must
// not immediately release the snapshot and oscillate forever.
suppressNextSemanticBottomRef.current = false;
} else if (!semanticAtBottomRef.current) {
queueSemanticBottom(true);
}
} else if (hasConfirmedVirtualizerBottomRef.current) {
onVirtualizerAtBottomStateChange(false);
if (semanticAtBottomRef.current) {
suppressNextSemanticBottomRef.current = true;
queueSemanticBottom(false);
}
}
},
[onVirtualizerAtBottomStateChange, queueSemanticBottom],
);
const timelineIntroSurface = selectTimelineIntroSurface({
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
hasDirectMessageIntro: directMessageIntro !== null,
hasReachedChannelStart:
!isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) &&
!isHoldingPrepend &&
(messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)),
isSkeletonVisible: showTimelineSkeleton,
});
@@ -270,16 +425,25 @@ const MessageTimelineBase = React.forwardRef<
? directMessageIntro
: null;
const activeChannelIntro = showChannelIntro ? channelIntro : null;
const showIntro = showDirectMessageIntro || showChannelIntro;
const showIntro =
activeDirectMessageIntro !== null || activeChannelIntro !== null;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";
const prepareForOwnMessage = React.useCallback(() => {
// The user's own send is the deliberate Zulip exception: release buffered
// output before arming the next-append bottom pin so the sent row can enter
// Virtua's model and become the new physical floor.
setIsSemanticallyAtBottom(true);
scrollToBottomOnNextUpdate();
}, [scrollToBottomOnNextUpdate]);
React.useImperativeHandle(
ref,
() => ({
scrollToBottomOnNextUpdate,
scrollToBottomOnNextUpdate: prepareForOwnMessage,
}),
[scrollToBottomOnNextUpdate],
[prepareForOwnMessage],
);
// Jump-to-message is purely DOM-based now: all loaded rows are mounted, so
@@ -349,20 +513,64 @@ const MessageTimelineBase = React.forwardRef<
}
}, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits.
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it.
React.useEffect(() => {
const target = pendingSearchTargetRef.current;
if (!target || showTimelineSkeleton) return;
if (
useTimelineVirtualizer &&
!activeScrollContainerRef.current?.querySelector(
`[data-message-id="${CSS.escape(target)}"]`,
)
) {
// Phase 1: ask the virtualizer to realize the match's index. The retry effect
// runs again on range change and the DOM-visible path does the actual
// center + highlight once the row exists.
void jumpToMessage(target, { behavior: "auto" });
return;
}
if (jumpToMessage(target, { behavior: "auto" })) {
pendingSearchTargetRef.current = null;
}
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
}, [
deferredMessages,
jumpToMessage,
showTimelineSkeleton,
virtualizerRenderVersion,
]);
useLoadOlderOnScroll({
const loadOlderViaVirtualizer = React.useCallback((): boolean => {
// Indexed find navigation can legitimately land near the current history
// boundary. Do not mistake that programmatic jump for scrollback intent and
// prepend underneath the active match.
// A settle-gate hold means the reader is still parked at the OLD
// boundary — don't stack more page fetches behind the held commit.
if (
searchActiveMessageId ||
!fetchOlder ||
isFetchingOlder ||
isHoldingPrepend ||
showTimelineSkeleton ||
!hasOlderMessages
) {
return false;
}
void fetchOlder();
return true;
}, [
fetchOlder,
hasOlderMessages,
isFetchingOlder,
isHoldingPrepend,
searchActiveMessageId,
showTimelineSkeleton,
]);
useLoadOlderOnScroll({
fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
scrollContainerRef,
scrollContainerRef: activeScrollContainerRef,
sentinelRef: topSentinelRef,
});
@@ -372,6 +580,145 @@ const MessageTimelineBase = React.forwardRef<
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
});
const virtualizedLeadingContent = React.useMemo(
() =>
activeChannelIntro ? (
<div
className="flex w-full max-w-2xl flex-col items-start px-3 pb-4 pt-2 text-left"
data-testid="message-channel-intro"
>
<div className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground">
{activeChannelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{activeChannelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{activeChannelIntro.channelKindLabel}
</span>
.
</p>
{activeChannelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{activeChannelIntro.description}
</p>
) : null}
{activeChannelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{activeChannelIntro.actions.map((action) => (
<button
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
action.description
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-2xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
action.description
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
)}
>
{action.icon}
</span>
<span className="mt-auto min-w-0">
<span className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground">
{action.label}
</span>
{action.description ? (
<span className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground">
{action.description}
</span>
) : null}
</span>
</button>
))}
</div>
) : null}
</div>
) : activeDirectMessageIntro ? (
<div
className="mb-2 flex w-full flex-col items-start px-3 pb-2 pt-2 text-left"
data-testid="message-dm-intro"
>
<DirectMessageIntroAvatarStack
participants={activeDirectMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{activeDirectMessageIntro.displayName}
</p>
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{activeDirectMessageIntro.displayName}
</span>
.
</p>
</div>
) : null,
[activeChannelIntro, activeDirectMessageIntro],
);
const handleVirtualizerRangeChanged = React.useCallback(() => {
bumpVirtualizerRenderVersion();
}, []);
const timelineList = showMessageList ? (
<TimelineMessageList
key={scrollContainerDomKey}
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
firstUnreadMessageId={firstUnreadMessageId}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
messageFooters={messageFooters}
mainEntries={renderedMessages === messages ? mainEntries : undefined}
leadingContent={virtualizedLeadingContent}
historyExhausted={renderedHistoryExhausted}
threadSummaries={threadSummaries}
messages={renderedMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onStartReached={loadOlderViaVirtualizer}
onToggleReaction={onToggleReaction}
onVirtualizerApiChange={setTimelineVirtualizerApi}
onVirtualizerRangeChanged={handleVirtualizerRangeChanged}
onVirtualizerScrollerChange={setVirtualizerScrollParent}
onAtBottomStateChange={handleVirtualizerAtBottomStateChange}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
useVirtualizer={useTimelineVirtualizer}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>
) : null;
return (
<TooltipProvider delayDuration={200}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@@ -391,8 +738,10 @@ const MessageTimelineBase = React.forwardRef<
</div>
) : null}
{/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame
later off the deferred snapshot — keep the spinner up until then. */}
later (deferred snapshot / settle-gate hold) — keep the spinner up
until the page actually renders. */}
{isFetchingOlder ||
isHoldingPrepend ||
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
<div
className={cn(
@@ -408,217 +757,211 @@ const MessageTimelineBase = React.forwardRef<
) : null}
<div
className={cn(
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
hasComposerOverlay ? "pb-24" : "pb-4",
"absolute inset-0 overflow-hidden",
(!useTimelineVirtualizer || !showMessageList) &&
cn(
"overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
hasComposerOverlay
? "pb-[var(--composer-overlay-height,6rem)]"
: "pb-4",
),
)}
data-buzz-conversation-scroll
data-buzz-conversation-scroll={
useTimelineVirtualizer && showMessageList ? undefined : "true"
}
data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline"
data-testid={
useTimelineVirtualizer && showMessageList
? undefined
: "message-timeline"
}
key={scrollContainerDomKey}
onScroll={onScroll}
onScroll={useTimelineVirtualizer ? undefined : onScroll}
ref={scrollContainerRef}
>
<div
className={cn(
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty || showMessageList) &&
"min-h-full",
)}
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{/* Fixed-height slot: an always-mounted height keeps the virtual
spacer's offset stable across the load-older fetch toggle, so
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
visible fetch spinner lives in the absolute overlay above, which
does not occupy inline flow. */}
<div aria-hidden className="h-8" />
{useTimelineVirtualizer && timelineList ? (
<div
className="h-full min-h-0 w-full"
data-render-pending={isRenderPending ? "true" : undefined}
>
{timelineList}
</div>
) : (
<div
className={cn(
"flex min-h-[18rem] min-w-0 flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList && !showIntro && "mt-auto",
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty || showMessageList) &&
"min-h-full",
)}
ref={contentRef}
>
{showTimelineSkeleton ? (
<TimelineSkeleton rows={timelineSkeletonRows} />
) : null}
{activeDirectMessageIntro ? (
<div
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
<DirectMessageIntroAvatarStack
participants={activeDirectMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{activeDirectMessageIntro.displayName}
</p>
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{activeDirectMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
<div ref={topSentinelRef} aria-hidden className="h-px" />
{activeChannelIntro ? (
<div
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
{/* Fixed-height slot: an always-mounted height keeps the virtual
spacer's offset stable across the load-older fetch toggle, so
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
visible fetch spinner lives in the absolute overlay above, which
does not occupy inline flow. */}
<div aria-hidden className="h-8" />
<div
className={cn(
"flex min-h-[18rem] min-w-0 flex-col gap-2",
useTimelineVirtualizer && "min-h-0 flex-1",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList &&
!showIntro &&
!useTimelineVirtualizer &&
"mt-auto",
)}
>
{showTimelineSkeleton ? (
<TimelineSkeleton rows={timelineSkeletonRows} />
) : null}
{activeDirectMessageIntro ? (
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
{activeChannelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{activeChannelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{activeChannelIntro.channelKindLabel}
</span>
.
</p>
{activeChannelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{activeChannelIntro.description}
<DirectMessageIntroAvatarStack
participants={activeDirectMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{activeDirectMessageIntro.displayName}
</p>
) : null}
{activeChannelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{activeChannelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{activeDirectMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
return (
<button
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-2xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
{activeChannelIntro ? (
<div
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
>
{activeChannelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{activeChannelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{activeChannelIntro.channelKindLabel}
</span>
.
</p>
{activeChannelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{activeChannelIntro.description}
</p>
) : null}
{activeChannelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{activeChannelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
return (
<button
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-2xl p-4",
)}
data-testid={
action.testId
? `${action.testId}-icon`
: undefined
}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
{action.icon}
</span>
<span className="mt-auto min-w-0">
<span
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
)}
data-testid={
action.testId
? `${action.testId}-title`
? `${action.testId}-icon`
: undefined
}
>
{action.label}
{action.icon}
</span>
{action.description ? (
<span className="mt-auto min-w-0">
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
data-testid={
action.testId
? `${action.testId}-description`
? `${action.testId}-title`
: undefined
}
>
{action.description}
{action.label}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{action.description ? (
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
data-testid={
action.testId
? `${action.testId}-description`
: undefined
}
>
{action.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-2xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-2xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{showMessageList ? (
<div
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
key={scrollContainerDomKey}
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
firstUnreadMessageId={firstUnreadMessageId}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
messageFooters={messageFooters}
mainEntries={
deferredMessages === messages ? mainEntries : undefined
}
threadSummaries={threadSummaries}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>
</div>
) : null}
{showMessageList ? (
<div
className={cn(
"flex flex-col gap-2",
!showIntro && !useTimelineVirtualizer && "mt-auto",
useTimelineVirtualizer && "min-h-0 flex-1",
)}
data-render-pending={isRenderPending ? "true" : undefined}
>
{timelineList}
</div>
) : null}
</div>
</div>
</div>
)}
</div>
{!isAtBottom ? (
@@ -631,12 +974,15 @@ const MessageTimelineBase = React.forwardRef<
<UnreadPill
direction="down"
label={
newMessageCount > 0
? unreadCountLabel(newMessageCount)
: "Jump to latest"
bufferedTimeline.pendingCount > 0
? unreadCountLabel(bufferedTimeline.pendingCount)
: newMessageCount > 0
? unreadCountLabel(newMessageCount)
: "Jump to latest"
}
onClick={() => {
scrollToBottom("smooth");
setIsSemanticallyAtBottom(true);
window.requestAnimationFrame(() => scrollToBottom("auto"));
}}
testId="message-scroll-to-latest"
/>
@@ -648,55 +994,3 @@ const MessageTimelineBase = React.forwardRef<
});
export const MessageTimeline = React.memo(MessageTimelineBase);
function DirectMessageIntroAvatarStack({
participants,
}: {
participants: DirectMessageIntroParticipant[];
}) {
const { hiddenCount, visibleParticipants } =
getDmParticipantPreview(participants);
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
return (
<div
aria-hidden="true"
className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</div>
))}
{hiddenCount > 0 ? (
<div
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-lg leading-none">+{hiddenCount}</span>
</span>
</div>
) : null}
</div>
);
}
@@ -1,4 +1,6 @@
import * as React from "react";
import { VList } from "virtua";
import type { VListHandle } from "virtua";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate";
@@ -6,8 +8,14 @@ import {
buildTimelineDayGroups,
buildTimelineItems,
getTimelineItemKey,
type TimelineDayGroup,
type TimelineNonDayItem,
} from "@/features/messages/lib/timelineItems";
import {
buildVirtualizedItems,
didPrependVirtualizedTimeline,
virtualizedItemKey,
} from "@/features/messages/lib/virtualizedTimelineItems";
import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
@@ -27,6 +35,18 @@ import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { SystemMessageRow } from "./SystemMessageRow";
import { UnreadDivider } from "./UnreadDivider";
import { useTimelineRetention } from "./useTimelineRetention";
import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel";
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle";
export type TimelineVirtualizerApi = {
scrollToBottom: (behavior?: ScrollBehavior) => void;
settleAtBottom: () => void;
scrollToMessage: (
messageId: string,
options?: { behavior?: ScrollBehavior },
) => boolean;
};
type TimelineMessageListProps = {
agentPubkeys?: ReadonlySet<string>;
@@ -81,6 +101,20 @@ type TimelineMessageListProps = {
searchQuery?: string;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
/** Content rendered as the first virtual row before channel history. */
leadingContent?: React.ReactNode;
/**
* True when the loaded window provably starts at the channel's beginning.
* Proves the oldest loaded day's boundary so its divider may render.
*/
historyExhausted?: boolean;
/** The virtualized timeline owns its scroll node when enabled. */
useVirtualizer?: boolean;
onStartReached?: () => boolean;
onAtBottomStateChange?: (atBottom: boolean) => void;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
onVirtualizerRangeChanged?: () => void;
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
};
export const TimelineMessageList = React.memo(function TimelineMessageList({
@@ -114,6 +148,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchQuery,
threadUnreadCounts,
unfollowThreadById,
leadingContent,
historyExhausted = false,
useVirtualizer = false,
onStartReached,
onAtBottomStateChange,
onVirtualizerApiChange,
onVirtualizerRangeChanged,
onVirtualizerScrollerChange,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() =>
@@ -267,6 +309,22 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
],
);
if (useVirtualizer) {
return (
<VirtualizedTimelineRows
dayGroups={dayGroups}
historyExhausted={historyExhausted}
leadingContent={leadingContent}
onAtBottomStateChange={onAtBottomStateChange}
onStartReached={onStartReached}
onVirtualizerApiChange={onVirtualizerApiChange}
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
renderItem={renderItem}
/>
);
}
return (
<div className="flex flex-col">
{dayGroups.map((group) => (
@@ -288,13 +346,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
)}
{group.items.map((item) => (
<div
className="timeline-row-cv"
key={getTimelineItemKey(item)}
style={timelineRowReserveStyle(item)}
>
<TimelineRowShell item={item} key={getTimelineItemKey(item)}>
{renderItem(item)}
</div>
</TimelineRowShell>
))}
</section>
))}
@@ -302,6 +356,371 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
);
});
function timelineItemMessageId(item: TimelineNonDayItem): string | null {
return item.kind === "message" || item.kind === "system"
? item.entry.message.id
: null;
}
type VirtualizedTimelineRowsProps = {
dayGroups: TimelineDayGroup[];
historyExhausted: boolean;
leadingContent?: React.ReactNode;
onAtBottomStateChange?: (atBottom: boolean) => void;
onStartReached?: () => boolean;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
onVirtualizerRangeChanged?: () => void;
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
};
function VirtualizedTimelineRows({
dayGroups,
historyExhausted,
leadingContent,
onAtBottomStateChange,
onStartReached,
onVirtualizerApiChange,
onVirtualizerRangeChanged,
onVirtualizerScrollerChange,
renderItem,
}: VirtualizedTimelineRowsProps) {
const listRef = React.useRef<VListHandle>(null);
const hostRef = React.useRef<HTMLDivElement>(null);
const itemsLengthRef = React.useRef(0);
const messageItemIndexByIdRef = React.useRef<ReadonlyMap<string, number>>(
new Map(),
);
const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(() =>
typeof window === "undefined" ? 1_000 : window.innerHeight,
);
const hasInitialPositionedRef = React.useRef(false);
const items = React.useMemo(
() => buildVirtualizedItems(dayGroups, leadingContent, historyExhausted),
[dayGroups, historyExhausted, leadingContent],
);
const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]);
itemsLengthRef.current = items.length;
const previousKeysRef = React.useRef<readonly string[]>([]);
const prependAnchorRef = React.useRef<{
itemKey: string;
top: number;
} | null>(null);
const prependWatcherFrameRef = React.useRef<number | null>(null);
const [prependShiftEpoch, clearPrependShift] = React.useReducer(
(version: number) => version + 1,
0,
);
// Virtua's `shift` is a one-render instruction, not a persistent mode. If it
// stays true after a prepend, later measurement changes can keep anchoring
// from the end and leave a stale blank range until the next scroll event.
const isPrepend = React.useMemo(() => {
void prependShiftEpoch;
return didPrependVirtualizedTimeline(previousKeysRef.current, keys);
}, [keys, prependShiftEpoch]);
const retirePrependAnchor = React.useCallback(() => {
if (prependWatcherFrameRef.current !== null) {
cancelAnimationFrame(prependWatcherFrameRef.current);
}
prependWatcherFrameRef.current = null;
prependAnchorRef.current = null;
}, []);
const { cancel: cancelBottomSettle, settle: settleAtBottom } =
useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
const retireTimelineSettle = React.useCallback(() => {
retirePrependAnchor();
cancelBottomSettle();
}, [cancelBottomSettle, retirePrependAnchor]);
const { arm: armUpwardMomentum, clear: clearUpwardMomentum } =
useUpwardPaginationWheel(hostRef, retireTimelineSettle);
const capturePrependAnchor = React.useCallback(() => {
// Keep the pending capture current while the fetch is in flight. Once the
// prepend commits and the watcher starts, its baseline is frozen.
if (prependWatcherFrameRef.current !== null) return;
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) return;
const scrollerTop = scroller.getBoundingClientRect().top;
const row = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
).find(
(candidate) => candidate.getBoundingClientRect().top >= scrollerTop - 1,
);
const itemKey = row?.dataset.timelineItemKey;
if (!row || !itemKey) return;
prependAnchorRef.current = {
itemKey,
top: row.getBoundingClientRect().top - scrollerTop,
};
}, []);
React.useLayoutEffect(() => {
if (!isPrepend || !prependAnchorRef.current) return;
// Virtua's shift mode correctly absorbs prepended measurements, but
// estimated offsets can misclassify late row growth deep in history. Keep
// the semantic row identity as a short-lived, deviation-gated backstop.
// Do not correct in this commit: Virtua has shifted its estimate but has not
// applied its ResizeObserver batch yet, so that delta is transient and its
// subsequent absolute correction would overwrite our relative write.
// This watcher deliberately survives a temporary row unmount and waits for
// stable geometry so Virtua remains the primary scroll owner.
if (prependWatcherFrameRef.current !== null) {
cancelAnimationFrame(prependWatcherFrameRef.current);
}
const anchor = prependAnchorRef.current;
const deadline = performance.now() + 3_000;
let previousScrollTop: number | null = null;
let settledFrames = 0;
const watch = () => {
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) {
prependWatcherFrameRef.current = null;
prependAnchorRef.current = null;
return;
}
const atBottom =
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
32;
const row = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
).find(
(candidate) => candidate.dataset.timelineItemKey === anchor.itemKey,
);
const top = row
? row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
: null;
const scrollTop = scroller.scrollTop;
settledFrames =
previousScrollTop !== null &&
Math.abs(scrollTop - previousScrollTop) < 0.5
? settledFrames + 1
: 0;
previousScrollTop = scrollTop;
if (row && top !== null && settledFrames >= 2) {
const delta = top - anchor.top;
if (Math.abs(delta) > 4) {
scroller.scrollBy({ top: delta });
settledFrames = 0;
previousScrollTop = null;
}
}
const retired =
performance.now() >= deadline ||
atBottom ||
(top !== null && top > scroller.clientHeight * 2);
if (retired) {
retirePrependAnchor();
return;
}
prependWatcherFrameRef.current = requestAnimationFrame(watch);
};
prependWatcherFrameRef.current = requestAnimationFrame(watch);
clearUpwardMomentum();
}, [clearUpwardMomentum, isPrepend, retirePrependAnchor]);
React.useEffect(
() => () => {
retirePrependAnchor();
cancelBottomSettle();
},
[cancelBottomSettle, retirePrependAnchor],
);
React.useLayoutEffect(() => {
previousKeysRef.current = keys;
if (isPrepend) {
cancelBottomSettle();
clearPrependShift();
}
if (!hasInitialPositionedRef.current && items.length > 0) {
hasInitialPositionedRef.current = true;
settleAtBottom();
}
}, [cancelBottomSettle, isPrepend, items.length, keys, settleAtBottom]);
const messageItemIndexById = React.useMemo(() => {
const byId = new Map<string, number>();
items.forEach((item, index) => {
if (item.kind !== "timeline-item") return;
const messageId = timelineItemMessageId(item.item);
if (messageId) byId.set(messageId, index);
});
return byId;
}, [items]);
messageItemIndexByIdRef.current = messageItemIndexById;
React.useLayoutEffect(() => {
const scroller = hostRef.current?.firstElementChild;
const element = scroller instanceof HTMLDivElement ? scroller : null;
if (element) {
element.dataset.buzzConversationScroll = "true";
element.dataset.testid = "message-timeline";
}
onVirtualizerScrollerChange?.(element);
return () => onVirtualizerScrollerChange?.(null);
}, [onVirtualizerScrollerChange]);
React.useLayoutEffect(() => {
if (!onVirtualizerApiChange) return;
const api: TimelineVirtualizerApi = {
scrollToBottom() {
retireTimelineSettle();
const lastIndex = itemsLengthRef.current - 1;
if (lastIndex >= 0) {
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
}
},
settleAtBottom,
scrollToMessage(messageId) {
retireTimelineSettle();
const index = messageItemIndexByIdRef.current.get(messageId);
if (index === undefined) return false;
listRef.current?.scrollToIndex(index, { align: "center" });
return true;
},
};
onVirtualizerApiChange(api);
return () => onVirtualizerApiChange(null);
}, [onVirtualizerApiChange, retireTimelineSettle, settleAtBottom]);
React.useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const updateBufferSize = () => {
// Measure rows three viewports ahead of the reader. Virtua deliberately
// hides each newly mounted row until its first ResizeObserver result; a
// one-viewport lead can be consumed by WebKit trackpad momentum before
// that result commits, producing a first-pass-only blank flash. The
// measured size is cached, which is why revisiting the same range is
// already stable.
setOffscreenBufferSize(host.clientHeight * 3);
};
updateBufferSize();
const resizeObserver = new ResizeObserver(updateBufferSize);
resizeObserver.observe(host);
return () => resizeObserver.disconnect();
}, []);
const { retainedIndices, onScrollEnd: handleScrollEnd } =
useTimelineRetention(keys, listRef, isPrepend);
const handleScroll = React.useCallback(
(offset: number) => {
const list = listRef.current;
const scroller = hostRef.current?.firstElementChild;
if (!list || !(scroller instanceof HTMLDivElement)) return;
onVirtualizerRangeChanged?.();
const distanceFromBottom = list.scrollSize - list.viewportSize - offset;
if (distanceFromBottom > 32) cancelBottomSettle();
onAtBottomStateChange?.(distanceFromBottom <= 32);
if (
prependAnchorRef.current !== null ||
offset <= 200 ||
prependWatcherFrameRef.current === null
) {
capturePrependAnchor();
}
if (offset <= 200) {
// Layout scrolls near the top must not poison the reader's next input.
armUpwardMomentum(onStartReached?.() ?? false);
}
},
[
armUpwardMomentum,
cancelBottomSettle,
capturePrependAnchor,
onAtBottomStateChange,
onStartReached,
onVirtualizerRangeChanged,
],
);
return (
<div className="h-full min-h-0 w-full" ref={hostRef}>
<VList
ref={listRef}
className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-[var(--channel-top-chrome-height,4.5rem)]"
data={items}
bufferSize={offscreenBufferSize}
keepMounted={retainedIndices}
style={{ overflowAnchor: "none" }}
shift={isPrepend}
onScroll={handleScroll}
onScrollEnd={handleScrollEnd}
>
{(item) => {
if (item.kind === "bottom-spacer") {
return (
<div
aria-hidden
className="h-[var(--composer-overlay-height,6rem)]"
key={virtualizedItemKey(item)}
/>
);
}
if (item.kind === "leading-content") {
return <div key={virtualizedItemKey(item)}>{item.content}</div>;
}
if (item.kind === "day-divider") {
const dayLabel = formatDayHeading(item.headingTimestamp);
return (
<div
// The sticky pill needs travel room, but its containing block
// is this item wrapper. The trailing spacer extends the content
// box by 4rem while the matching negative margin keeps the
// measured layout height at exactly the divider's height, so
// row spacing and Virtua's size cache are unaffected. Both the
// spacer and the pill are pointer-events-none, and the later
// (absolutely positioned) row siblings paint above the spacer.
className="relative -mb-16 flex flex-col before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']"
data-day-label={dayLabel}
data-testid="message-timeline-day-group"
key={virtualizedItemKey(item)}
>
<DayDivider label={dayLabel} />
<div aria-hidden className="pointer-events-none h-16" />
</div>
);
}
return (
<TimelineRowShell
item={item.item}
key={virtualizedItemKey(item)}
useContentVisibility={false}
>
{renderItem(item.item)}
</TimelineRowShell>
);
}}
</VList>
</div>
);
}
function TimelineRowShell({
children,
item,
useContentVisibility = true,
}: {
children: React.ReactNode;
item: TimelineNonDayItem;
useContentVisibility?: boolean;
}) {
return (
<div
className={cn(useContentVisibility && "timeline-row-cv")}
data-timeline-item-key={getTimelineItemKey(item)}
style={useContentVisibility ? timelineRowReserveStyle(item) : undefined}
>
{children}
</div>
);
}
function SystemRow({
currentPubkey,
entries,
@@ -0,0 +1,41 @@
import type { VListHandle } from "virtua";
/**
* Keep a wide ID-keyed neighborhood around the reader plus the visual tail.
* The wider eviction band adds hysteresis, so small direction changes do not
* churn mounted rows. Virtua continues to own measured sizes and spacer math.
*/
export function nextRetainedTimelineKeys(
keys: readonly string[],
previous: ReadonlySet<string>,
list: VListHandle,
): ReadonlySet<string> {
const viewportSize = Math.max(list.viewportSize, 1);
const offset = list.scrollOffset;
const indexAt = (target: number) =>
list.findItemIndex(Math.min(list.scrollSize, Math.max(0, target)));
const admissionStart = indexAt(offset - viewportSize * 8);
const admissionEnd = indexAt(offset + viewportSize * 9);
const evictionStart = indexAt(offset - viewportSize * 12);
const evictionEnd = indexAt(offset + viewportSize * 13);
const tailStart = indexAt(list.scrollSize - viewportSize * 3);
const next = new Set<string>();
for (let index = evictionStart; index <= evictionEnd; index += 1) {
const key = keys[index];
if (key && previous.has(key)) next.add(key);
}
for (let index = admissionStart; index <= admissionEnd; index += 1) {
const key = keys[index];
if (key) next.add(key);
}
for (let index = tailStart; index < keys.length; index += 1) {
const key = keys[index];
if (key) next.add(key);
}
return next.size === previous.size &&
[...next].every((key) => previous.has(key))
? previous
: next;
}
@@ -0,0 +1,381 @@
/**
* Snapshot projection invariant: timeline rows and the history-exhaustion
* proof must be inseparable through EVERY render transport stage React
* deferral (`useDeferredValue`), tail buffering, and the settle-gated prepend
* hold.
*
* Bug this pins (divider tear, 2026-07-11)
* `historyExhausted` used to travel to TimelineMessageList as a bare prop on
* the URGENT render path while the rows rode the deferred/gated pipeline. The
* final older-history page (same-day rows + hasMore:false in one response)
* therefore produced an intermediate commit pairing the NEW exhaustion proof
* with the STALE row array {rendered:100, exhausted:true} which minted the
* oldest-day divider against a partially-loaded day. When the withheld rows
* landed one commit later they prepended BEHIND the already-minted divider
* key, exact-suffix shift admission failed, and the full page height landed
* as an uncompensated scroll jump. Ledgered 3/3 in
* RESEARCH/DIVIDER_EXTERNALIZATION_KEYSPACE.md (addendum).
*
* The fix threads the proof INSIDE TimelineSnapshot and through the settle
* gate as paired `meta`, so no render can observe a (rows, proof) pair that
* was never published together.
*
* **Revert verification**: feeding the gate `meta` from the LIVE snapshot
* (`liveSnapshot.historyExhausted`) instead of the deferred one or passing
* the raw `historyExhausted` prop straight to the list, as the old wiring
* did makes the forbidden-pair assertion below fail on the urgent commit.
*
* CI surface
* Runs under `pnpm test` (node:test with the React dev build). Not Playwright:
* the assertion is about per-commit render pairs, which only a render recorder
* can observe deterministically.
*/
import assert from "node:assert/strict";
import test from "node:test";
// ── Minimal DOM shim (same shape as MessageComposerDraftImagePersist) ───────
function installDOMShim() {
class MinimalEventTarget {
constructor() {
this._listeners = {};
}
addEventListener(type, fn) {
this._listeners[type] ??= [];
this._listeners[type].push(fn);
}
removeEventListener(type, fn) {
if (this._listeners[type]) {
this._listeners[type] = this._listeners[type].filter((f) => f !== fn);
}
}
dispatchEvent(e) {
for (const fn of this._listeners[e.type] ?? []) fn(e);
return true;
}
}
class MinimalNode extends MinimalEventTarget {
constructor(tagName) {
super();
this.tagName = tagName;
this.children = [];
this.childNodes = [];
this.style = {};
this.nodeType = 1;
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children[this.children.length - 1] ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
this.childNodes = this.childNodes.filter((c) => c !== child);
return child;
}
insertBefore(newNode, refNode) {
if (!refNode) return this.appendChild(newNode);
const i = this.children.indexOf(refNode);
if (i < 0) return this.appendChild(newNode);
this.children.splice(i, 0, newNode);
this.childNodes.splice(i, 0, newNode);
newNode.parentNode = this;
return newNode;
}
contains(node) {
if (!node) return false;
return this === node || this.children.some((c) => c?.contains?.(node));
}
}
class MinimalDocument extends MinimalEventTarget {
constructor() {
super();
this.nodeType = 9;
}
createElement(tagName) {
return new MinimalNode(tagName);
}
createTextNode(value) {
const n = new MinimalNode("#text");
n.nodeValue = value;
n.nodeType = 3;
return n;
}
createComment(value) {
const n = new MinimalNode("#comment");
n.nodeValue = value;
n.nodeType = 8;
return n;
}
get body() {
if (!this._body) this._body = this.createElement("body");
return this._body;
}
get activeElement() {
return null;
}
contains(node) {
return node != null;
}
}
globalThis.document = new MinimalDocument();
globalThis.HTMLIFrameElement = MinimalNode;
globalThis.HTMLElement = MinimalNode;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
if (typeof globalThis.window === "undefined") {
Object.defineProperty(globalThis, "window", {
value: globalThis,
configurable: true,
});
}
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 16);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
}
installDOMShim();
// ── Imports (after shim) ─────────────────────────────────────────────────────
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages.ts";
import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages.ts";
// ── Harness: MessageTimeline's exact snapshot transport pipeline ────────────
// liveSnapshot (rows+proof as ONE value) → useDeferredValue → tail buffering
// → settle-gated prepend hold. Records the (rowCount, firstId, exhausted)
// pair every render body — the same pair buildVirtualizedItems consumes.
const rows = (ids) => ids.map((id) => ({ id }));
const ids = (prefix, from, to) => {
const out = [];
for (let i = from; i < to; i += 1) out.push(`${prefix}${i}`);
return out;
};
const EMPTY_SNAPSHOT = {
channelId: null,
messages: [],
historyExhausted: false,
};
function makeFakeScroller() {
// Constant scrollTop + no motion events: the settle gate's watcher reaches
// quiet-window + stable-frames and admits the held page on its own.
const listeners = {};
return {
scrollTop: 0,
addEventListener(type, fn) {
listeners[type] ??= [];
listeners[type].push(fn);
},
removeEventListener(type, fn) {
if (listeners[type]) {
listeners[type] = listeners[type].filter((f) => f !== fn);
}
},
};
}
function pairOf(snapshot) {
return {
count: snapshot.messages.length,
firstId: snapshot.messages[0]?.id ?? null,
exhausted: snapshot.historyExhausted,
};
}
function samePair(a, b) {
return (
a.count === b.count &&
a.firstId === b.firstId &&
a.exhausted === b.exhausted
);
}
function makeHarness(records, scroller) {
return function Harness({ snapshot }) {
const deferredSnapshot = React.useDeferredValue(snapshot, EMPTY_SNAPSHOT);
const buffered = useBufferedTimelineMessages({
channelId: deferredSnapshot.channelId,
isAtBottom: false, // reader is scrolled up — the tear's regime
messages: deferredSnapshot.messages,
});
const {
messages: rendered,
meta: exhausted,
isHoldingPrepend,
} = useSettleGatedPrependMessages({
channelId: deferredSnapshot.channelId,
messages: buffered.messages,
meta: deferredSnapshot.historyExhausted,
scrollElementRef: { current: scroller },
});
records.push({
count: rendered.length,
firstId: rendered[0]?.id ?? null,
exhausted,
isHoldingPrepend,
});
return null;
};
}
async function mount(Comp, snapshot) {
const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(React.createElement(Comp, { snapshot }));
});
return {
update: async (nextSnapshot) => {
await act(async () => {
root.render(React.createElement(Comp, { snapshot: nextSnapshot }));
});
},
settle: async (ms) => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
},
unmount: async () => {
await act(async () => {
root.unmount();
});
},
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
test("pass-1 landing: exhaustion proof can never pair with the stale row array", async () => {
const CHANNEL = "chan-tear";
// Snapshot A: 100 rows loaded, more history exists (mid-pagination).
const snapA = {
channelId: CHANNEL,
messages: rows(ids("m", 35, 135)),
historyExhausted: false,
};
// Snapshot B: the pass-1 landing — 35 older same-day rows AND the
// exhaustion proof arrive in ONE authoritative publish (the ledgered
// observed order: both caches coherent in the same notification batch).
const snapB = {
channelId: CHANNEL,
messages: rows(ids("m", 0, 135)),
historyExhausted: true,
};
const records = [];
const scroller = makeFakeScroller();
const handle = await mount(makeHarness(records, scroller), snapA);
await handle.update(snapB);
// The settle gate must actually engage for this to exercise the held
// window — otherwise the test is vacuous.
assert.ok(
records.some((r) => r.isHoldingPrepend),
"settle gate must hold the pure history prepend at least one render",
);
// While held, the rendered pair stays the ADMITTED one: {100, false}.
const heldRecords = records.filter((r) => r.isHoldingPrepend);
for (const r of heldRecords) {
assert.equal(r.count, 100, "held rows must remain the admitted 100");
assert.equal(
r.exhausted,
false,
"held rows must keep the admitted (false) proof — not the fresh one",
);
}
// Let the settle watcher admit (quiet window 100ms + 3 stable frames).
await handle.settle(400);
// Final state: rows and proof advanced TOGETHER.
const last = records[records.length - 1];
assert.deepEqual(
{ count: last.count, firstId: last.firstId, exhausted: last.exhausted },
{ count: 135, firstId: "m0", exhausted: true },
"after settle the full page and its proof must land together",
);
// THE invariant: every render observed a (rows, proof) pair that was
// actually published. The forbidden torn state {rendered:100,
// exhausted:true} — the divider-minting commit — must never exist.
const published = [EMPTY_SNAPSHOT, snapA, snapB].map(pairOf);
for (const r of records) {
assert.ok(
published.some((p) => samePair(p, r)),
`torn render: {count:${r.count}, firstId:${r.firstId}, exhausted:${r.exhausted}} matches no published snapshot`,
);
}
await handle.unmount();
});
test("reverse reconnect: exhaustion retraction stays paired with its replacement rows", async () => {
const CHANNEL = "chan-retract";
// Exhausted window fully loaded…
const snapExhausted = {
channelId: CHANNEL,
messages: rows(ids("old", 0, 135)),
historyExhausted: true,
};
// …then a reconnect head-refresh replaces the whole chain with page zero
// reporting hasMore:true — exhaustion retracts, rows change wholesale.
const snapRetracted = {
channelId: CHANNEL,
messages: rows(ids("new", 0, 50)),
historyExhausted: false,
};
const records = [];
const scroller = makeFakeScroller();
const handle = await mount(makeHarness(records, scroller), snapExhausted);
await handle.update(snapRetracted);
await handle.settle(400);
// A wholesale replacement is not a pure prepend: the gate passes it through
// (no hold), and the retracted proof must arrive WITH the new rows — never
// stale-true against the new oldest prefix, never premature-false against
// the old rows.
const last = records[records.length - 1];
assert.deepEqual(
{ count: last.count, firstId: last.firstId, exhausted: last.exhausted },
{ count: 50, firstId: "new0", exhausted: false },
);
const published = [EMPTY_SNAPSHOT, snapExhausted, snapRetracted].map(pairOf);
for (const r of records) {
assert.ok(
published.some((p) => samePair(p, r)),
`torn render on retraction: {count:${r.count}, firstId:${r.firstId}, exhausted:${r.exhausted}}`,
);
}
await handle.unmount();
});
@@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { settleProgrammaticBottomPin } from "./useAnchoredScroll.ts";
import {
settleProgrammaticBottomPin,
shouldSettleForSplitPanel,
shouldSettleVirtualizedBottom,
} from "./useAnchoredScroll.ts";
function fakeContainer({ clientHeight, scrollHeight, scrollTop }) {
const writes = [];
@@ -17,6 +21,78 @@ function fakeContainer({ clientHeight, scrollHeight, scrollTop }) {
};
}
test("split panel settles only an already-bottomed timeline", () => {
assert.equal(
shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: true }),
true,
);
assert.equal(
shouldSettleForSplitPanel({ isAtBottom: false, splitPanelOpen: true }),
false,
);
assert.equal(
shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: false }),
false,
);
});
test("virtualized bottom settle arms for pinned appends and replacements", () => {
assert.equal(
shouldSettleVirtualizedBottom({
isAtBottom: true,
messageDelta: "append",
messagesArrived: 1,
messagesChanged: true,
}),
true,
);
assert.equal(
shouldSettleVirtualizedBottom({
isAtBottom: true,
messageDelta: "replace",
messagesArrived: 0,
messagesChanged: true,
}),
true,
);
assert.equal(
shouldSettleVirtualizedBottom({
isAtBottom: true,
messageDelta: "none",
messagesArrived: 0,
messagesChanged: true,
}),
true,
);
assert.equal(
shouldSettleVirtualizedBottom({
isAtBottom: true,
messageDelta: "prepend",
messagesArrived: 1,
messagesChanged: true,
}),
false,
);
assert.equal(
shouldSettleVirtualizedBottom({
isAtBottom: true,
messageDelta: "none",
messagesArrived: 0,
messagesChanged: false,
}),
false,
);
assert.equal(
shouldSettleVirtualizedBottom({
isAtBottom: false,
messageDelta: "none",
messagesArrived: 0,
messagesChanged: true,
}),
false,
);
});
test("settleProgrammaticBottomPin chases the physical floor before clearing", () => {
const container = fakeContainer({
clientHeight: 100,
@@ -1,6 +1,9 @@
import * as React from "react";
import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
import {
classifyTimelineMessageDelta,
type TimelineMessageDelta,
} from "@/features/messages/lib/timelineSnapshot";
/**
* Distance (in CSS pixels) below which we consider the scroll position
@@ -32,6 +35,34 @@ export function settleProgrammaticBottomPin(
return isAtTrueBottom(container);
}
export function shouldSettleForSplitPanel({
isAtBottom,
splitPanelOpen,
}: {
isAtBottom: boolean;
splitPanelOpen: boolean;
}): boolean {
return isAtBottom && splitPanelOpen;
}
export function shouldSettleVirtualizedBottom({
isAtBottom,
messageDelta,
messagesArrived,
messagesChanged,
}: {
isAtBottom: boolean;
messageDelta: TimelineMessageDelta;
messagesArrived: number;
messagesChanged: boolean;
}): boolean {
return (
isAtBottom &&
messageDelta !== "prepend" &&
(messagesArrived > 0 || messagesChanged)
);
}
type UseAnchoredScrollOptions = {
/** Scroll container. Owned by the parent so external refs still compose. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
@@ -45,10 +76,22 @@ type UseAnchoredScrollOptions = {
/** Source of truth for the rendered list. Used to detect new-at-bottom
* arrivals and to seed/refresh the anchor pre-render. */
messages: Array<{ id: string }>;
splitPanelOpen?: boolean;
/** When set, scroll to and highlight this message on mount and on change. */
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
virtualScrollToMessage?: (
messageId: string,
options?: { behavior?: ScrollBehavior },
) => boolean;
/** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */
virtualScrollToBottom?: (behavior?: ScrollBehavior) => void;
virtualSettleAtBottom?: () => void;
/** When active, the virtualizer owns prepend compensation and bottom-state synchronization. */
virtualizerOwnsPrependAnchoring?: boolean;
/** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */
virtualizerRenderVersion?: number;
};
type UseAnchoredScrollResult = {
@@ -72,6 +115,8 @@ type UseAnchoredScrollResult = {
messageId: string,
options?: { highlight?: boolean; behavior?: ScrollBehavior },
) => boolean;
/** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */
onVirtualizerAtBottomStateChange: (atBottom: boolean) => void;
};
function isAtBottomNow(
@@ -147,15 +192,27 @@ export function useAnchoredScroll({
channelId,
isLoading,
messages,
splitPanelOpen = false,
targetMessageId = null,
onTargetReached,
virtualScrollToMessage,
virtualScrollToBottom,
virtualSettleAtBottom,
virtualizerOwnsPrependAnchoring = false,
virtualizerRenderVersion = 0,
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
// Anchor lives in a ref because it must survive renders and is updated
// both on scroll (commit-time read) and in the layout effect (post-render
// restoration). useState would force re-renders we don't want.
const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
const virtualizerAtBottomRef = React.useRef(true);
const [isAtBottom, setIsAtBottom] = React.useState(true);
React.useLayoutEffect(() => {
if (shouldSettleForSplitPanel({ isAtBottom, splitPanelOpen })) {
virtualSettleAtBottom?.();
}
}, [isAtBottom, splitPanelOpen, virtualSettleAtBottom]);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
@@ -188,6 +245,7 @@ export function useAnchoredScroll({
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we want this effect to fire exactly when the channel changes (and on mount).
React.useLayoutEffect(() => {
anchorRef.current = { kind: "at-bottom" };
virtualizerAtBottomRef.current = true;
setIsAtBottom(true);
setNewMessageCount(0);
setHighlightedMessageId(null);
@@ -222,11 +280,19 @@ export function useAnchoredScroll({
// every imperative bottom jump so `onScroll` holds the at-bottom anchor
// until it can snap to the true floor.
settlingRef.current = true;
container.scrollTo({ top: container.scrollHeight, behavior });
if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) {
virtualScrollToBottom(behavior);
} else {
container.scrollTo({ top: container.scrollHeight, behavior });
}
setIsAtBottom(true);
setNewMessageCount(0);
},
[scrollContainerRef],
[
scrollContainerRef,
virtualScrollToBottom,
virtualizerOwnsPrependAnchoring,
],
);
// Arm a one-shot: the next append snaps to bottom regardless of where the
@@ -236,6 +302,19 @@ export function useAnchoredScroll({
forceBottomOnNextAppendRef.current = true;
}, []);
const highlightMessage = React.useCallback((messageId: string) => {
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
}
setHighlightedMessageId(messageId);
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === messageId ? null : current,
);
highlightTimeoutRef.current = null;
}, 2_000);
}, []);
const scrollToMessageImperative = React.useCallback(
(
messageId: string,
@@ -246,6 +325,44 @@ export function useAnchoredScroll({
const el = container.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`,
);
if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) {
if (el) {
const rect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const isInViewport =
rect.top >= containerRect.top &&
rect.bottom <= containerRect.bottom;
if (!isInViewport) {
if (!virtualScrollToMessage(messageId, { behavior: "auto" })) {
return false;
}
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
setIsAtBottom(false);
return false;
}
const centeredTop = (container.clientHeight - rect.height) / 2;
container.scrollTo({
top: Math.max(
0,
container.scrollTop +
(rect.top - containerRect.top) -
centeredTop,
),
behavior: options.behavior ?? "auto",
});
} else if (
!virtualScrollToMessage(messageId, {
behavior: options.behavior ?? "auto",
})
) {
return false;
}
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
setIsAtBottom(false);
if (el && options.highlight) highlightMessage(messageId);
return el !== null;
}
if (!el) return false;
const rect = el.getBoundingClientRect();
@@ -279,21 +396,15 @@ export function useAnchoredScroll({
};
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
if (options.highlight) {
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
}
setHighlightedMessageId(messageId);
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === messageId ? null : current,
);
highlightTimeoutRef.current = null;
}, 2_000);
}
if (options.highlight) highlightMessage(messageId);
return true;
},
[scrollContainerRef],
[
highlightMessage,
scrollContainerRef,
virtualizerOwnsPrependAnchoring,
virtualScrollToMessage,
],
);
// Scroll handler: recompute anchor + bottom state from the current
@@ -302,6 +413,9 @@ export function useAnchoredScroll({
const onScroll = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Virtua owns anchoring and reports bottom state separately. Avoid the
// fallback's O(N) DOM walk on every compositor-driven scroll event.
if (virtualizerOwnsPrependAnchoring) return;
// Row measurement can grow `scrollHeight` after a bottom pin and emit scroll
// events while `scrollTop` holds at the old floor — opening a transient gap
// above the true bottom. `computeAnchor` would read that as a deliberate
@@ -311,6 +425,9 @@ export function useAnchoredScroll({
if (settleProgrammaticBottomPin(container)) {
settlingRef.current = false;
} else {
if (virtualizerOwnsPrependAnchoring) {
settlingRef.current = false;
}
return;
}
}
@@ -320,7 +437,7 @@ export function useAnchoredScroll({
if (atBottom) {
setNewMessageCount(0);
}
}, [scrollContainerRef]);
}, [scrollContainerRef, virtualizerOwnsPrependAnchoring]);
// ---------------------------------------------------------------------------
// Anchor restoration: after every render, stick to the bottom if the user is
@@ -382,11 +499,11 @@ export function useAnchoredScroll({
// `newLatestArrived` misses that case and the unread counter never bumps.
const prevMessages = prevMessagesRef.current;
const messagesArrived = messages.length - prevCount;
const isPrepend =
classifyTimelineMessageDelta({
current: messages,
previous: prevMessages,
}) === "prepend";
const messageDelta = classifyTimelineMessageDelta({
current: messages,
previous: prevMessages,
});
const isPrepend = messageDelta === "prepend";
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
// resulting append lands, snap to bottom regardless of the current anchor,
@@ -396,7 +513,11 @@ export function useAnchoredScroll({
forceBottomOnNextAppendRef.current = false;
anchorRef.current = { kind: "at-bottom" };
settlingRef.current = true;
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) {
virtualScrollToBottom("auto");
} else {
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
}
setIsAtBottom(true);
setNewMessageCount(0);
prevLastMessageIdRef.current = lastMessage?.id;
@@ -407,10 +528,21 @@ export function useAnchoredScroll({
}
if (anchor.kind === "at-bottom") {
// Stick to bottom across the append.
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
if (
virtualizerOwnsPrependAnchoring &&
shouldSettleVirtualizedBottom({
isAtBottom: virtualizerAtBottomRef.current,
messageDelta,
messagesArrived,
messagesChanged: messages !== prevMessages,
})
) {
virtualSettleAtBottom?.();
} else if (!virtualizerOwnsPrependAnchoring) {
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
}
if (newLatestArrived) setNewMessageCount(0);
} else if (messagesArrived > 0) {
} else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) {
// Anchored mid-history. An older-history prepend grows the content above
// the reading row; the browser's native scroll anchoring does NOT correct
// this at the top edge (no anchor node above the viewport when scrollTop
@@ -449,6 +581,9 @@ export function useAnchoredScroll({
scrollToBottomImperative,
scrollToMessageImperative,
targetMessageId,
virtualScrollToBottom,
virtualSettleAtBottom,
virtualizerOwnsPrependAnchoring,
]);
// ---------------------------------------------------------------------------
@@ -466,13 +601,21 @@ export function useAnchoredScroll({
const observer = new ResizeObserver(() => {
const container = scrollContainerRef.current;
if (!container) return;
if (anchorRef.current.kind === "at-bottom") {
if (
anchorRef.current.kind === "at-bottom" &&
!virtualizerOwnsPrependAnchoring
) {
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
}
});
observer.observe(content);
return () => observer.disconnect();
}, [channelId, contentRef, scrollContainerRef]);
}, [
channelId,
contentRef,
scrollContainerRef,
virtualizerOwnsPrependAnchoring,
]);
// ---------------------------------------------------------------------------
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
@@ -486,7 +629,7 @@ export function useAnchoredScroll({
// *without* marking the target handled until its row actually exists — each
// subsequent message commit re-runs the effect and retries the centering.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits.
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
@@ -495,11 +638,19 @@ export function useAnchoredScroll({
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
if (!hasInitializedRef.current) return; // initial-mount path will handle.
void virtualizerRenderVersion;
const container = scrollContainerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(
`[data-message-id="${targetMessageId}"]`,
);
if (!el && virtualizerOwnsPrependAnchoring) {
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
handledTargetIdRef.current = targetMessageId;
onTargetReached?.(targetMessageId);
}
return;
}
if (!el) {
// Row not in the DOM yet. A cold deep-link target is fetched by id and
// spliced into `messages` a render or two later; this effect re-runs on
@@ -516,6 +667,8 @@ export function useAnchoredScroll({
scrollContainerRef,
scrollToMessageImperative,
targetMessageId,
virtualizerOwnsPrependAnchoring,
virtualizerRenderVersion,
]);
React.useEffect(() => {
@@ -526,6 +679,19 @@ export function useAnchoredScroll({
};
}, []);
const onVirtualizerAtBottomStateChange = React.useCallback(
(atBottom: boolean) => {
if (!virtualizerOwnsPrependAnchoring) return;
virtualizerAtBottomRef.current = atBottom;
if (atBottom) {
anchorRef.current = { kind: "at-bottom" };
setNewMessageCount(0);
}
setIsAtBottom(atBottom);
},
[virtualizerOwnsPrependAnchoring],
);
return {
onScroll,
isAtBottom,
@@ -534,5 +700,6 @@ export function useAnchoredScroll({
scrollToBottom: scrollToBottomImperative,
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
onVirtualizerAtBottomStateChange,
};
}
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectBufferedTimelineMessages } from "./useBufferedTimelineMessages.ts";
const rows = (...ids) => ids.map((id) => ({ id }));
test("freezes live arrivals after the semantic tail while scrolled up", () => {
assert.deepEqual(
selectBufferedTimelineMessages({
frozenMessageIds: ["a", "b", "c"],
isAtBottom: false,
messages: rows("a", "b", "c", "d", "e"),
}).map(({ id }) => id),
["a", "b", "c"],
);
});
test("admits older-history prepends without exposing buffered arrivals", () => {
assert.deepEqual(
selectBufferedTimelineMessages({
frozenMessageIds: ["a", "b", "c"],
isAtBottom: false,
messages: rows("older-a", "older-b", "a", "b", "c", "d"),
}).map(({ id }) => id),
["older-a", "older-b", "a", "b", "c"],
);
});
test("releases the full logical dataset at bottom", () => {
const messages = rows("a", "b", "c", "d");
assert.deepEqual(
selectBufferedTimelineMessages({
frozenMessageIds: ["a", "b"],
isAtBottom: true,
messages,
}),
messages,
);
});
test("accepts an authoritative replacement when its old tail disappeared", () => {
const messages = rows("x", "y");
assert.deepEqual(
selectBufferedTimelineMessages({
frozenMessageIds: ["old-tail"],
isAtBottom: false,
messages,
}),
messages,
);
});
@@ -0,0 +1,90 @@
import * as React from "react";
/**
* Keeps the logical tail stable while a reader is away from the bottom.
*
* Virtua remains the sole owner of mounting, measurement, and pixel anchoring.
* This hook only controls when live output joins its keyed data model: older
* history before the retained tail is admitted immediately, while newer output
* is released atomically when the reader returns to the bottom.
*/
export function selectBufferedTimelineMessages<T extends { id: string }>({
frozenMessageIds,
isAtBottom,
messages,
}: {
frozenMessageIds: readonly string[] | null;
isAtBottom: boolean;
messages: T[];
}): T[] {
if (isAtBottom || frozenMessageIds === null) return messages;
if (frozenMessageIds.length === 0) return [];
const currentById = new Map(messages.map((message) => [message.id, message]));
if (frozenMessageIds.some((id) => !currentById.has(id))) {
// A deletion or authoritative replacement removed part of the frozen
// snapshot. Keeping stale message objects would be worse than accepting it.
return messages;
}
const firstFrozenIndex = messages.findIndex(
(message) => message.id === frozenMessageIds[0],
);
const prepended = messages.slice(0, firstFrozenIndex);
const frozen = frozenMessageIds.map((id) => currentById.get(id) as T);
const buffered = [...prepended, ...frozen];
if (
buffered.length === messages.length &&
buffered.every((message, index) => message.id === messages[index]?.id)
) {
// Crossing the bottom threshold without a live arrival must be a semantic
// no-op for Virtua. Preserve the source array identity until there is
// actually something to buffer; otherwise the threshold transition can
// rebuild its model while a prepend is starting.
return messages;
}
return buffered;
}
export function useBufferedTimelineMessages<T extends { id: string }>({
channelId,
isAtBottom,
messages,
}: {
channelId?: string | null;
isAtBottom: boolean;
messages: T[];
}): { messages: T[]; pendingCount: number } {
const frozenMessageIdsRef = React.useRef<string[] | null>(null);
const previousChannelIdRef = React.useRef(channelId);
if (previousChannelIdRef.current !== channelId) {
previousChannelIdRef.current = channelId;
frozenMessageIdsRef.current = null;
}
if (isAtBottom) {
frozenMessageIdsRef.current = messages.map((message) => message.id);
} else if (frozenMessageIdsRef.current === null) {
frozenMessageIdsRef.current = messages.map((message) => message.id);
}
const buffered = selectBufferedTimelineMessages({
frozenMessageIds: frozenMessageIdsRef.current,
isAtBottom,
messages,
});
const previousBufferedRef = React.useRef<T[]>(buffered);
const stableBuffered =
previousBufferedRef.current.length === buffered.length &&
previousBufferedRef.current.every(
(message, index) => message === buffered[index],
)
? previousBufferedRef.current
: buffered;
previousBufferedRef.current = stableBuffered;
return {
messages: stableBuffered,
pendingCount: Math.max(0, messages.length - stableBuffered.length),
};
}
@@ -14,6 +14,7 @@ export function useComposerHeightPadding(
scrollContainerRef: React.RefObject<HTMLElement | null>,
composerRef: React.RefObject<HTMLElement | null>,
resetKey?: unknown,
mode: "padding" | "css-variable" = "padding",
) {
React.useEffect(() => {
void resetKey;
@@ -24,15 +25,34 @@ export function useComposerHeightPadding(
return;
}
const getScrollElement = (): HTMLElement =>
mode === "css-variable"
? (scrollEl.querySelector<HTMLElement>(
'[data-testid="message-timeline"]',
) ?? scrollEl)
: scrollEl;
let lastPadding: number | null = null;
let followBottomFrame: number | null = null;
const isNearBottom = (): boolean => {
const target = getScrollElement();
const threshold = 32;
const trailingClearance =
mode === "css-variable" ? (lastPadding ?? 0) : 0;
return (
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight <
target.scrollHeight -
target.scrollTop -
target.clientHeight -
trailingClearance <
threshold
);
};
let lastPadding: number | null = null;
const followBottom = () => {
const target = getScrollElement();
target.scrollTop = target.scrollHeight;
};
const applyPadding = (height: number) => {
const padding = Math.ceil(height);
@@ -43,14 +63,25 @@ export function useComposerHeightPadding(
const previousPadding = lastPadding;
const wasAtBottom = isNearBottom();
scrollEl.style.paddingBottom = `${padding}px`;
if (mode === "css-variable") {
scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`);
} else {
scrollEl.style.paddingBottom = `${padding}px`;
}
lastPadding = padding;
if (
wasAtBottom &&
(previousPadding === null || padding > previousPadding)
) {
scrollEl.scrollTop = scrollEl.scrollHeight;
followBottom();
if (followBottomFrame !== null) {
cancelAnimationFrame(followBottomFrame);
}
followBottomFrame = requestAnimationFrame(() => {
followBottomFrame = null;
followBottom();
});
}
};
@@ -58,7 +89,14 @@ export function useComposerHeightPadding(
return () => {
disconnect();
scrollEl.style.paddingBottom = "";
if (followBottomFrame !== null) {
cancelAnimationFrame(followBottomFrame);
}
if (mode === "css-variable") {
scrollEl.style.removeProperty("--composer-overlay-height");
} else {
scrollEl.style.paddingBottom = "";
}
};
}, [scrollContainerRef, composerRef, resetKey]);
}, [scrollContainerRef, composerRef, mode, resetKey]);
}
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectSettleGatedMessages } from "./useSettleGatedPrependMessages.ts";
const rows = (...ids) => ids.map((id) => ({ id }));
test("holds a pure older-history prepend", () => {
const decision = selectSettleGatedMessages({
admitted: rows("a", "b", "c"),
next: rows("older-1", "older-2", "a", "b", "c"),
});
assert.equal(decision.kind, "hold");
assert.deepEqual(
decision.held.map(({ id }) => id),
["a", "b", "c"],
);
});
test("held snapshot uses refreshed row objects for the admitted ids", () => {
const editedA = { id: "a", edited: true };
const decision = selectSettleGatedMessages({
admitted: rows("a", "b"),
next: [{ id: "older-1" }, editedA, { id: "b" }],
});
assert.equal(decision.kind, "hold");
assert.equal(decision.held[0], editedA);
});
test("passes appends through immediately", () => {
assert.equal(
selectSettleGatedMessages({
admitted: rows("a", "b"),
next: rows("a", "b", "c"),
}).kind,
"pass",
);
});
test("passes a simultaneous prepend+append (own send) through", () => {
assert.equal(
selectSettleGatedMessages({
admitted: rows("a", "b"),
next: rows("older-1", "a", "b", "sent"),
}).kind,
"pass",
);
});
test("passes deletions inside the admitted window through", () => {
assert.equal(
selectSettleGatedMessages({
admitted: rows("a", "b", "c"),
next: rows("older-1", "a", "c"),
}).kind,
"pass",
);
});
test("passes authoritative replacements through", () => {
assert.equal(
selectSettleGatedMessages({
admitted: rows("a", "b"),
next: rows("x", "y"),
}).kind,
"pass",
);
});
test("passes identical snapshots through", () => {
assert.equal(
selectSettleGatedMessages({
admitted: rows("a", "b"),
next: rows("a", "b"),
}).kind,
"pass",
);
});
test("passes when the previous snapshot was empty (initial load)", () => {
assert.equal(
selectSettleGatedMessages({
admitted: [],
next: rows("a", "b"),
}).kind,
"pass",
);
});
@@ -0,0 +1,180 @@
import * as React from "react";
/**
* Holds an older-history prepend out of the rendered timeline until the
* scroller is genuinely at rest, then admits it atomically.
*
* Why: every prepend-compensation mechanism in this design Virtua's shift
* correction, the pre-paint scrollBy, the semantic-anchor watcher is a
* scrollTop write. On macOS WKWebView those writes can be dropped or
* overridden while trackpad momentum owns the committed offset, so a page
* commit that lands mid-fling displaces the viewport by the full prepended
* height with no reliable way to correct it. Committing only at rest keeps
* all three writers operating in the regime where they are exact.
*
* The fetched store stays authoritative and fetches still start immediately;
* this hook only delays when the fetched page joins the rendered snapshot.
*/
/**
* WebKit can freeze the JS-readable scrollTop for ~2 frames during live
* trackpad momentum, so counting zero-delta frames alone misreports "settled"
* mid-fling. Settle therefore requires BOTH a quiet window since the last
* scroll/wheel event AND stable frame-over-frame offsets the same
* two-signal settle shape ratified for the timeline settle gate elsewhere in
* this codebase.
*/
export const SETTLE_MOTION_WINDOW_MS = 100;
export const SETTLE_FRAME_COUNT = 3;
/**
* Upper bound on how long a fetched page may be withheld. Trackpad momentum
* decays in well under a second; a reader actively driving the scroller for
* this long has moved on, and admitting under continuous REAL input is safe
* the dropped-write hazard is specific to the inertial momentum phase, which
* cannot outlive this deadline.
*/
export const SETTLE_HOLD_DEADLINE_MS = 4_000;
export type SettleGateDecision<T> =
| { kind: "pass" }
| { kind: "hold"; held: T[] };
/**
* Pure admission rule. Holds only a PURE history prepend: the next snapshot
* must be the admitted rows (same ids, possibly refreshed objects edits and
* reactions keep rendering) with one or more new rows in front. Anything else
* appends, deletions, authoritative replacements, channel resets passes
* through immediately so the gate can never pin a stale dataset.
*/
export function selectSettleGatedMessages<T extends { id: string }>({
admitted,
next,
}: {
admitted: readonly T[];
next: T[];
}): SettleGateDecision<T> {
if (admitted.length === 0) return { kind: "pass" };
const prependCount = next.findIndex(
(message) => message.id === admitted[0].id,
);
if (prependCount <= 0) return { kind: "pass" };
if (next.length - prependCount !== admitted.length) return { kind: "pass" };
for (let index = 0; index < admitted.length; index += 1) {
if (next[prependCount + index].id !== admitted[index].id) {
return { kind: "pass" };
}
}
return { kind: "hold", held: next.slice(prependCount) };
}
export function useSettleGatedPrependMessages<T extends { id: string }, M>({
channelId,
messages,
meta,
scrollElementRef,
}: {
channelId?: string | null;
messages: T[];
/**
* Snapshot metadata that must stay paired with the rows it was projected
* from (e.g. the history-exhaustion proof that decides whether the oldest
* day divider may exist). While a prepend is held, the output keeps the
* ADMITTED metadata; rows and metadata only ever advance together, so no
* render can pair new proof with withheld rows.
*/
meta: M;
scrollElementRef: { readonly current: HTMLElement | null };
}): { messages: T[]; meta: M; isHoldingPrepend: boolean } {
const admittedRef = React.useRef<T[]>(messages);
const admittedMetaRef = React.useRef<M>(meta);
const previousChannelIdRef = React.useRef(channelId);
const [, admit] = React.useReducer((epoch: number) => epoch + 1, 0);
if (previousChannelIdRef.current !== channelId) {
previousChannelIdRef.current = channelId;
admittedRef.current = messages;
admittedMetaRef.current = meta;
}
const decision = selectSettleGatedMessages({
admitted: admittedRef.current,
next: messages,
});
const isHoldingPrepend = decision.kind === "hold";
let output: T[];
let outputMeta: M;
if (decision.kind === "hold") {
// Same ids as the admitted set; keep array identity stable unless a row
// object actually changed so Virtua's data model is not rebuilt per render.
const previous = admittedRef.current;
output =
previous.length === decision.held.length &&
previous.every((message, index) => message === decision.held[index])
? previous
: decision.held;
outputMeta = admittedMetaRef.current;
} else {
output = messages;
outputMeta = meta;
}
admittedRef.current = output;
admittedMetaRef.current = outputMeta;
const latestMessagesRef = React.useRef(messages);
latestMessagesRef.current = messages;
const latestMetaRef = React.useRef(meta);
latestMetaRef.current = meta;
React.useEffect(() => {
if (!isHoldingPrepend) return;
const scroller = scrollElementRef.current;
if (!scroller) {
// Nothing to observe — never strand the fetched page.
admittedRef.current = latestMessagesRef.current;
admittedMetaRef.current = latestMetaRef.current;
admit();
return;
}
let frame: number | null = null;
const deadline = performance.now() + SETTLE_HOLD_DEADLINE_MS;
// Assume motion at hold start: worst case this costs one quiet window
// (~100ms) behind the fetching-older spinner when the reader was already
// at rest; the alternative admits mid-fling if WebKit starves the first
// scroll events.
let lastMotionTs = performance.now();
let previousScrollTop = scroller.scrollTop;
let settledFrames = 0;
const markMotion = () => {
lastMotionTs = performance.now();
};
scroller.addEventListener("scroll", markMotion, { passive: true });
scroller.addEventListener("wheel", markMotion, { passive: true });
const watch = () => {
const scrollTop = scroller.scrollTop;
settledFrames =
Math.abs(scrollTop - previousScrollTop) < 0.5 ? settledFrames + 1 : 0;
previousScrollTop = scrollTop;
const quiet = performance.now() - lastMotionTs >= SETTLE_MOTION_WINDOW_MS;
if (
(quiet && settledFrames >= SETTLE_FRAME_COUNT) ||
performance.now() >= deadline
) {
frame = null;
admittedRef.current = latestMessagesRef.current;
admittedMetaRef.current = latestMetaRef.current;
admit();
return;
}
frame = requestAnimationFrame(watch);
};
frame = requestAnimationFrame(watch);
return () => {
scroller.removeEventListener("scroll", markMotion);
scroller.removeEventListener("wheel", markMotion);
if (frame !== null) cancelAnimationFrame(frame);
};
}, [isHoldingPrepend, scrollElementRef]);
return { messages: output, meta: outputMeta, isHoldingPrepend };
}
@@ -0,0 +1,65 @@
import * as React from "react";
import type { VListHandle } from "virtua";
import { nextRetainedTimelineKeys } from "./timelineRetention";
export function useTimelineRetention(
keys: readonly string[],
listRef: React.RefObject<VListHandle | null>,
isPrepend: boolean,
) {
const [retainedKeys, setRetainedKeys] = React.useState<ReadonlySet<string>>(
() => new Set(keys),
);
const evictionNotBeforeRef = React.useRef(0);
const refreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const keysRef = React.useRef(keys);
keysRef.current = keys;
const refreshRetainedKeys = React.useCallback(() => {
const remainingGuardMs = evictionNotBeforeRef.current - performance.now();
if (remainingGuardMs > 0) {
refreshTimerRef.current = setTimeout(
refreshRetainedKeys,
remainingGuardMs,
);
return;
}
refreshTimerRef.current = null;
const currentKeys = keysRef.current;
const list = listRef.current;
if (!list || currentKeys.length === 0) return;
setRetainedKeys((previous) =>
nextRetainedTimelineKeys(currentKeys, previous, list),
);
}, [listRef]);
React.useLayoutEffect(() => {
if (isPrepend) evictionNotBeforeRef.current = performance.now() + 3_000;
}, [isPrepend]);
React.useEffect(
() => () => {
if (refreshTimerRef.current !== null) {
clearTimeout(refreshTimerRef.current);
}
},
[],
);
const retainedIndices = React.useMemo(
() => keys.flatMap((key, index) => (retainedKeys.has(key) ? [index] : [])),
[keys, retainedKeys],
);
const onScrollEnd = React.useCallback(() => {
if (refreshTimerRef.current !== null) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
refreshRetainedKeys();
}, [refreshRetainedKeys]);
return { retainedIndices, onScrollEnd };
}
@@ -0,0 +1,57 @@
import * as React from "react";
export function useUpwardPaginationWheel(
hostRef: React.RefObject<HTMLDivElement | null>,
onWheel: () => void,
) {
const suppressRef = React.useRef(false);
const lastUpwardWheelAtRef = React.useRef(Number.NEGATIVE_INFINITY);
const clear = React.useCallback(() => {
suppressRef.current = false;
}, []);
React.useLayoutEffect(() => {
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) return;
let releaseTimer: number | null = null;
const handleWheel = (event: WheelEvent) => {
onWheel();
if (event.deltaY >= 0) {
clear();
if (releaseTimer !== null) window.clearTimeout(releaseTimer);
releaseTimer = null;
return;
}
lastUpwardWheelAtRef.current = performance.now();
if (!suppressRef.current) return;
event.preventDefault();
if (releaseTimer !== null) window.clearTimeout(releaseTimer);
releaseTimer = window.setTimeout(() => {
clear();
releaseTimer = null;
}, 80);
};
scroller.addEventListener("wheel", handleWheel, { passive: false });
return () => {
scroller.removeEventListener("wheel", handleWheel);
if (releaseTimer !== null) window.clearTimeout(releaseTimer);
};
}, [clear, hostRef, onWheel]);
const arm = React.useCallback(
(startedPaging: boolean) => {
const scroller = hostRef.current?.firstElementChild;
if (
startedPaging &&
scroller instanceof HTMLDivElement &&
scroller.scrollHeight - scroller.clientHeight > 400 &&
performance.now() - lastUpwardWheelAtRef.current < 120
) {
suppressRef.current = true;
}
},
[hostRef],
);
return { arm, clear };
}
@@ -0,0 +1,68 @@
import * as React from "react";
import type { VListHandle } from "virtua";
const BOTTOM_EPSILON_PX = 1;
const SETTLE_DEADLINE_MS = 250;
export function useVirtualizedBottomSettle(
hostRef: React.RefObject<HTMLDivElement | null>,
listRef: React.RefObject<VListHandle | null>,
itemsLengthRef: React.RefObject<number>,
) {
const frameRef = React.useRef<number | null>(null);
const cancel = React.useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}, []);
React.useLayoutEffect(() => {
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) return;
const retire = () => cancel();
scroller.addEventListener("pointerdown", retire, { passive: true });
scroller.addEventListener("touchstart", retire, { passive: true });
scroller.addEventListener("wheel", retire, { passive: true });
window.addEventListener("keydown", retire, true);
return () => {
scroller.removeEventListener("pointerdown", retire);
scroller.removeEventListener("touchstart", retire);
scroller.removeEventListener("wheel", retire);
window.removeEventListener("keydown", retire, true);
};
}, [cancel, hostRef]);
const settle = React.useCallback(() => {
cancel();
const deadline = performance.now() + SETTLE_DEADLINE_MS;
let settledFrames = 0;
let previousHeight = -1;
const next = () => {
const scroller = hostRef.current?.firstElementChild;
const lastIndex = itemsLengthRef.current - 1;
if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) {
cancel();
return;
}
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
const atBottom =
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
BOTTOM_EPSILON_PX;
settledFrames =
atBottom && scroller.scrollHeight === previousHeight
? settledFrames + 1
: 0;
previousHeight = scroller.scrollHeight;
if (settledFrames >= 2 || performance.now() >= deadline) {
frameRef.current = null;
return;
}
frameRef.current = requestAnimationFrame(next);
};
next();
}, [cancel, hostRef, itemsLengthRef, listRef]);
React.useEffect(() => cancel, [cancel]);
return { cancel, settle };
}
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys";
import {
channelWindowHasMore,
channelWindowHistoryExhausted,
emptyChannelWindowStore,
type ChannelWindowStore,
} from "@/features/messages/lib/channelWindowStore";
@@ -35,6 +36,19 @@ export function useFetchOlderMessages(channel: Channel | null) {
emptyChannelWindowStore(),
});
// Distinct from `!hasOlderMessages`: an empty/unloaded window also reports
// "no more", but exhaustion requires a RESOLVED tail page proving the
// channel's beginning. Consumers gating UI on the history boundary (the
// oldest day divider) must use this, not the paging signal.
const { data: historyExhausted = false } = useQuery({
enabled: channelId !== null,
queryKey: windowKey,
select: channelWindowHistoryExhausted,
queryFn: () =>
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
emptyChannelWindowStore(),
});
const fetchOlder = useCallback(async () => {
if (!channelId || isFetchingOlderRef.current) {
return;
@@ -63,5 +77,5 @@ export function useFetchOlderMessages(channel: Channel | null) {
}
}, [channel?.id, channelId, queryClient]);
return { fetchOlder, isFetchingOlder, hasOlderMessages };
return { fetchOlder, isFetchingOlder, hasOlderMessages, historyExhausted };
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { collectThreadAuxMessageIds } from "./useThreadReplies.ts";
const ROOT_ID = "1".repeat(64);
const REPLY_ID = "2".repeat(64);
function reply(id = REPLY_ID) {
return {
id,
pubkey: "a".repeat(64),
kind: 9,
created_at: 1_700_000_000,
content: "reply",
tags: [["e", ROOT_ID]],
sig: "sig",
};
}
test("thread aux hydration includes the root when there are no replies", () => {
assert.deepEqual(collectThreadAuxMessageIds(ROOT_ID, []), [ROOT_ID]);
});
test("thread aux hydration includes and deduplicates root and reply ids", () => {
assert.deepEqual(
collectThreadAuxMessageIds(ROOT_ID, [reply(), reply(ROOT_ID)]),
[ROOT_ID, REPLY_ID],
);
});
@@ -1,10 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
collectMessageIdsForAuxBackfill,
fetchStructuralAuxForMessages,
} from "@/features/messages/lib/auxBackfill";
import { threadRepliesKey } from "@/features/messages/lib/messageQueryKeys";
import {
threadRepliesKey,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
import { getThreadReplies } from "@/shared/api/tauri";
import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types";
@@ -18,26 +23,53 @@ const MAX_THREAD_PAGES = 500;
* original text. Best-effort: an aux failure logs and returns the replies
* unadorned rather than failing the whole thread load.
*/
async function withStructuralAux(
async function fetchThreadAuxBestEffort(
label: string,
channelId: string,
replies: RelayEvent[],
fetchAux: () => Promise<RelayEvent[]>,
): Promise<RelayEvent[]> {
try {
const auxEvents = await fetchStructuralAuxForMessages(
channelId,
collectMessageIdsForAuxBackfill(replies),
);
return auxEvents.length > 0 ? [...replies, ...auxEvents] : replies;
return await fetchAux();
} catch (error) {
console.error(
"Failed to backfill thread reply edits for channel",
`Failed to backfill thread reply ${label} for channel`,
channelId,
error,
);
return replies;
return [];
}
}
export function collectThreadAuxMessageIds(
threadRootId: string,
replies: RelayEvent[],
): string[] {
return [
...new Set([threadRootId, ...collectMessageIdsForAuxBackfill(replies)]),
];
}
async function withThreadAux(
channelId: string,
threadRootId: string,
replies: RelayEvent[],
): Promise<RelayEvent[]> {
const messageIds = collectThreadAuxMessageIds(threadRootId, replies);
const [structuralAux, reactions] = await Promise.all([
fetchThreadAuxBestEffort("structural aux", channelId, () =>
fetchStructuralAuxForMessages(channelId, messageIds),
),
fetchThreadAuxBestEffort("reactions", channelId, () =>
relayClient.fetchAuxEventsByReference(
channelId,
messageIds,
buildChannelReactionAuxFilter,
),
),
]);
return sortMessages([...replies, ...structuralAux, ...reactions]);
}
/** Fetch a thread subtree into a cache independent from channel window pages. */
export function useThreadReplies(
activeChannel: Channel | null,
@@ -45,14 +77,19 @@ export function useThreadReplies(
) {
const channelId = activeChannel?.id ?? "none";
const rootId = openThreadRootId ?? "none";
const queryClient = useQueryClient();
const queryKey = threadRepliesKey(channelId, rootId);
return useQuery({
queryKey: threadRepliesKey(channelId, rootId),
queryKey,
enabled:
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
openThreadRootId !== null,
queryFn: async (): Promise<RelayEvent[]> => {
if (!activeChannel || !openThreadRootId) return [];
const cacheAtStart =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const idsAtStart = new Set(cacheAtStart.map((event) => event.id));
const replies: RelayEvent[] = [];
let cursor: ThreadCursor | null = null;
for (let page = 0; page < MAX_THREAD_PAGES; page += 1) {
@@ -62,8 +99,19 @@ export function useThreadReplies(
{ limit: THREAD_PAGE_LIMIT, cursor },
);
replies.push(...response.events);
if (!response.nextCursor)
return withStructuralAux(activeChannel.id, replies);
if (!response.nextCursor) {
const fetched = await withThreadAux(
activeChannel.id,
openThreadRootId,
replies,
);
const current =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const receivedInFlight = current.filter(
(event) => !idsAtStart.has(event.id),
);
return sortMessages([...fetched, ...receivedInFlight]);
}
cursor = response.nextCursor;
}
throw new Error(
@@ -80,8 +80,10 @@ function isConversationScroller(element: HTMLElement) {
* remain; every other boundary including all horizontal ones is locked and
* cannot chain to the viewport.
*/
export function useWebviewScrollBoundaryLock() {
export function useWebviewScrollBoundaryLock(enabled = true) {
React.useEffect(() => {
if (!enabled) return;
function handleWheel(event: WheelEvent) {
if (event.defaultPrevented || event.ctrlKey) {
return;
@@ -137,5 +139,5 @@ export function useWebviewScrollBoundaryLock() {
return () => {
window.removeEventListener("wheel", handleWheel, { capture: true });
};
}, []);
}, [enabled]);
}
+6 -6
View File
@@ -27,6 +27,7 @@ import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Spinner } from "./spinner";
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
import {
getInlinePlaybackPosition,
getReviewPlaybackPosition,
@@ -722,9 +723,10 @@ export function VideoPlayer({
const [playbackSpeed, setPlaybackSpeed] = React.useState(
DEFAULT_PLAYBACK_SPEED,
);
const [naturalAspectRatio, setNaturalAspectRatio] = React.useState<
number | null
>(null);
// Cache-seeded so a row evicted by the virtualized timeline remounts at the
// ratio learned on first metadata load, not the 16/9 fallback.
const [naturalAspectRatio, learnNaturalAspectRatio] =
useNaturalVideoAspectRatio(src);
const [reviewOpen, setReviewOpenState] = React.useState(() =>
isVideoReviewOpen(persistedReviewKey),
);
@@ -1035,9 +1037,7 @@ export function VideoPlayer({
event.currentTarget.currentTime = restoredSeconds;
setCurrentTime(restoredSeconds);
}
if (videoWidth > 0 && videoHeight > 0) {
setNaturalAspectRatio(videoWidth / videoHeight);
}
learnNaturalAspectRatio(videoWidth, videoHeight);
}}
onPause={() => {
setIsPlaying(false);
+15 -18
View File
@@ -106,6 +106,7 @@ import {
} from "./markdown/imageLightbox";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { ProgressiveImage } from "./markdown/ProgressiveImage";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
import {
@@ -134,6 +135,7 @@ type ImageBlockProps = {
dim?: string;
resolvedSrc: string | undefined;
src: string | undefined;
thumbSrc?: string;
};
type WebKitGestureLikeEvent = Event & {
@@ -1061,7 +1063,7 @@ function ImageZoomOverlay({
* body on hover. Keeping the trigger stable and managing the lightbox via
* React state avoids that repaint.
*/
function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
const [lightboxState, setLightboxState] = React.useState<{
galleryIndex: number;
galleryItems?: ImageGalleryItem[];
@@ -1072,8 +1074,10 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
const [isHiddenInSpoiler, setIsHiddenInSpoiler] = React.useState(false);
const [menu, setMenu] = React.useState<ImageContextMenuPosition | null>(null);
const inlineImageRef = React.useRef<HTMLImageElement | null>(null);
const thumbnailImageRef = React.useRef<HTMLImageElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
useSmoothCorners(inlineImageRef);
useSmoothCorners(thumbnailImageRef);
const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{
height: number;
@@ -1114,14 +1118,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
[resolvedSrc, updateSpoilerMediaSize],
);
const imageRef = React.useCallback(
(image: HTMLImageElement | null) => {
inlineImageRef.current = image;
if (image?.complete) handleImageLoad(image);
},
[handleImageLoad],
);
const { intrinsicDimensions, useFixedReserveBox } = useFrozenImageReserve(
dim,
resolvedSrc,
@@ -1268,18 +1264,18 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
onClick={handleImageTriggerClick}
onContextMenuCapture={handleContextMenu}
>
<img
<ProgressiveImage
alt={alt}
className="block h-auto max-h-64 max-w-[min(24rem,100%)] rounded-2xl object-contain"
data-spoiler-media-size={hiddenSpoilerMediaSize ? "" : undefined}
decoding="async"
fullImageRef={inlineImageRef}
height={intrinsicDimensions.height}
loading="lazy"
ref={imageRef}
src={resolvedSrc}
onFullLoad={handleImageLoad}
onThumbnailLoad={updateSpoilerMediaSize}
resolvedSrc={resolvedSrc}
showSpoilerSize={Boolean(hiddenSpoilerMediaSize)}
style={spoilerMediaStyle}
thumbnailRef={thumbnailImageRef}
thumbSrc={thumbSrc}
width={intrinsicDimensions.width}
onLoad={(event) => handleImageLoad(event.currentTarget)}
/>
</button>
{menu && src ? (
@@ -1317,7 +1313,7 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
return (
<div
className={cn(
"mt-1 grid w-full min-w-0 max-w-lg grid-cols-2 gap-1.5 overflow-hidden rounded-2xl [&_br]:hidden [&_[data-block-media]]:min-h-0 [&_[data-block-media]]:max-w-none [&_[data-block-media]]:overflow-hidden [&_[data-block-media]>button]:m-0 [&_[data-block-media]>button]:h-full [&_[data-block-media]>button]:w-full [&_[data-block-media]>button]:max-w-none [&_[data-block-media]>button]:rounded-none [&_[data-block-media]_img]:!h-full [&_[data-block-media]_img]:!max-h-none [&_[data-block-media]_img]:!w-full [&_[data-block-media]_img]:!max-w-none [&_[data-block-media]_img]:rounded-none [&_[data-block-media]_img]:object-cover",
"mt-1 grid w-full min-w-0 max-w-lg grid-cols-2 gap-1.5 overflow-hidden rounded-2xl [&_br]:hidden [&_[data-block-media]]:min-h-0 [&_[data-block-media]]:max-w-none [&_[data-block-media]]:overflow-hidden [&_[data-block-media]>button]:m-0 [&_[data-block-media]>button]:h-full [&_[data-block-media]>button]:w-full [&_[data-block-media]>button]:max-w-none [&_[data-block-media]>button]:rounded-none [&_[data-block-media]_[data-progressive-image-frame]]:!h-full [&_[data-block-media]_[data-progressive-image-frame]]:!w-full [&_[data-block-media]_img]:!h-full [&_[data-block-media]_img]:!max-h-none [&_[data-block-media]_img]:!w-full [&_[data-block-media]_img]:!max-w-none [&_[data-block-media]_img]:rounded-none [&_[data-block-media]_img]:object-cover",
isTriptych
? "h-80 grid-rows-2 [&_[data-block-media]]:h-auto [&_[data-block-media]:first-child]:row-span-2"
: "[&_[data-block-media]]:h-48",
@@ -1583,6 +1579,7 @@ function createMarkdownComponents(
dim={entry?.dim}
resolvedSrc={resolvedSrc}
src={src}
thumbSrc={entry?.thumb ? rewriteRelayUrl(entry.thumb) : undefined}
/>
</span>
);
@@ -0,0 +1,141 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
const IMAGE_CLASS =
"absolute inset-0 block h-full w-full rounded-2xl object-contain";
function isSameImageSource(
left: string | undefined,
right: string | undefined,
) {
if (!left || !right) return false;
try {
return (
new URL(left, window.location.href).href ===
new URL(right, window.location.href).href
);
} catch {
return left === right;
}
}
type ProgressiveImageProps = {
alt: string | undefined;
fullImageRef: React.RefObject<HTMLImageElement | null>;
height: number;
onFullLoad: (image: HTMLImageElement) => void;
onThumbnailLoad: (image: HTMLImageElement) => void;
resolvedSrc: string | undefined;
showSpoilerSize: boolean;
style: React.CSSProperties | undefined;
thumbnailRef: React.RefObject<HTMLImageElement | null>;
thumbSrc: string | undefined;
width: number;
};
export function ProgressiveImage({
alt,
fullImageRef,
height,
onFullLoad,
onThumbnailLoad,
resolvedSrc,
showSpoilerSize,
style,
thumbnailRef,
thumbSrc,
width,
}: ProgressiveImageProps) {
const thumbnailSrc = isSameImageSource(thumbSrc, resolvedSrc)
? undefined
: thumbSrc;
const [loadFullImage, setLoadFullImage] = React.useState(!thumbnailSrc);
const [fullImageLoaded, setFullImageLoaded] = React.useState(!thumbnailSrc);
const handleFullLoad = React.useCallback(
async (image: HTMLImageElement) => {
onFullLoad(image);
try {
await image.decode();
} catch {
// The load event still proves the image is displayable.
}
setFullImageLoaded(true);
},
[onFullLoad],
);
const setFullImageRef = React.useCallback(
(image: HTMLImageElement | null) => {
fullImageRef.current = image;
if (image?.complete) void handleFullLoad(image);
},
[fullImageRef, handleFullLoad],
);
const setThumbnailRef = React.useCallback(
(image: HTMLImageElement | null) => {
thumbnailRef.current = image;
if (image && !fullImageRef.current) fullImageRef.current = image;
},
[fullImageRef, thumbnailRef],
);
const frameStyle = React.useMemo<React.CSSProperties>(() => {
const scale = Math.min(1, 384 / width, 256 / height);
return {
...style,
aspectRatio: `${width} / ${height}`,
height: "auto",
width: `${Math.max(1, Math.round(width * scale))}px`,
};
}, [height, style, width]);
return (
<span
className="relative block max-w-full"
data-progressive-image-frame=""
style={frameStyle}
>
{thumbnailSrc ? (
<img
alt=""
aria-hidden="true"
className={IMAGE_CLASS}
decoding="async"
height={height}
loading="lazy"
ref={setThumbnailRef}
src={thumbnailSrc}
style={style}
width={width}
onError={() => setLoadFullImage(true)}
onLoad={(event) => {
onThumbnailLoad(event.currentTarget);
setLoadFullImage(true);
}}
/>
) : null}
{loadFullImage ? (
<img
alt={alt}
className={cn(
IMAGE_CLASS,
"transition-opacity duration-200 motion-reduce:transition-none",
thumbnailSrc && !fullImageLoaded && "opacity-0",
)}
data-spoiler-media-size={showSpoilerSize ? "" : undefined}
decoding="async"
height={height}
loading={thumbnailSrc ? undefined : "lazy"}
ref={setFullImageRef}
src={resolvedSrc}
style={style}
width={width}
onLoad={(event) => void handleFullLoad(event.currentTarget)}
/>
) : null}
</span>
);
}
@@ -191,6 +191,8 @@ export function imageLightboxCornerRadiiFromElement(
topRight: style.borderTopRightRadius,
};
const mosaic = element.closest<HTMLElement>("[data-image-mosaic]");
const geometryElement =
element.closest<HTMLElement>("[data-progressive-image-frame]") ?? element;
if (!mosaic || mosaic === element) {
return cornerRadii;
}
@@ -198,7 +200,7 @@ export function imageLightboxCornerRadiiFromElement(
// Mosaic tiles themselves are square. Their visible outer corners come from
// the gallery container's clip, so preserve only the container corners that
// this tile actually touches when the overlay returns.
const elementRect = element.getBoundingClientRect();
const elementRect = geometryElement.getBoundingClientRect();
const mosaicRect = mosaic.getBoundingClientRect();
const mosaicStyle = window.getComputedStyle(mosaic);
const touches = (first: number, second: number) =>
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
getRememberedVideoAspectRatio,
rememberVideoAspectRatio,
useNaturalVideoAspectRatio,
} from "./videoAspectRatio.ts";
// The virtualized timeline evicts and remounts video rows. VideoPlayer reads
// `useNaturalVideoAspectRatio`, whose state seed is this module's cache — the
// contract that keeps a remounted dim-less video at its true height is: a
// ratio learned on first `loadedmetadata` must seed a later mount of the same
// src, so the row never falls back to the 16/9 placeholder again.
// Renders the hook's current ratio. Each renderToStaticMarkup call is a fresh
// mount — exactly what a retention eviction/remount does to the real player.
function MountedRatio({ src }) {
const [ratio] = useNaturalVideoAspectRatio(src);
return React.createElement("span", null, ratio === null ? "none" : ratio);
}
function mountRatio(src) {
return renderToStaticMarkup(React.createElement(MountedRatio, { src }));
}
test("a learned non-16:9 ratio survives remount", () => {
const src = "https://relay.example/media/portrait.mp4";
const portrait = 9 / 16;
// First mount: nothing learned yet — the player can only fall back.
assert.equal(mountRatio(src), "<span>none</span>");
// Metadata arrives on the first mount; the player learns the true ratio.
rememberVideoAspectRatio(src, portrait);
// Fresh mount of the same src (state reset, as after retention eviction):
// the hook seeds from the cache instead of the 16/9 fallback path.
assert.equal(mountRatio(src), `<span>${portrait}</span>`);
});
test("different srcs do not share learned ratios", () => {
rememberVideoAspectRatio("https://relay.example/media/a.mp4", 1);
assert.equal(
mountRatio("https://relay.example/media/b.mp4"),
"<span>none</span>",
);
});
test("invalid ratios and missing srcs are ignored", () => {
rememberVideoAspectRatio(undefined, 1.5);
rememberVideoAspectRatio("https://relay.example/media/bad.mp4", 0);
rememberVideoAspectRatio("https://relay.example/media/bad.mp4", Number.NaN);
assert.equal(
getRememberedVideoAspectRatio("https://relay.example/media/bad.mp4"),
null,
);
assert.equal(getRememberedVideoAspectRatio(undefined), null);
});
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
// Natural aspect ratios of videos that arrived without a NIP-92 `dim` tag,
// keyed by resolved URL and learned from the first `loadedmetadata`. Component
// state is lost when the virtualized timeline evicts and later remounts a row;
// without this module-level memory a dim-less video would remount at the 16/9
// fallback and then resize to its true ratio when metadata re-loads — a
// remount height mismatch that triggers the virtualizer's jump compensation.
// Same pattern as `decodedImageDimensions` in markdown/utils.ts.
const learnedVideoAspectRatios = new Map<string, number>();
export function rememberVideoAspectRatio(
src: string | undefined,
ratio: number,
): void {
if (!src || !Number.isFinite(ratio) || ratio <= 0) return;
learnedVideoAspectRatios.set(src, ratio);
}
export function getRememberedVideoAspectRatio(
src: string | undefined,
): number | null {
return (src ? learnedVideoAspectRatios.get(src) : undefined) ?? null;
}
/**
* A video's measured natural aspect ratio, seeded from the module cache so a
* remount of the same `src` starts at the ratio learned before eviction
* instead of `null` (which the player renders as the 16/9 placeholder).
*/
export function useNaturalVideoAspectRatio(
src: string,
): [number | null, (width: number, height: number) => void] {
const [ratio, setRatio] = React.useState<number | null>(() =>
getRememberedVideoAspectRatio(src),
);
const learn = React.useCallback(
(width: number, height: number) => {
if (width <= 0 || height <= 0) return;
const next = width / height;
rememberVideoAspectRatio(src, next);
setRatio(next);
},
[src],
);
return [ratio, learn];
}
+26 -17
View File
@@ -126,6 +126,8 @@ type E2eConfig = {
addChannelMembersDelayMs?: number;
createManagedAgentDelayMs?: number;
channelsReadError?: string;
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
deepHistoryMessageCount?: number;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace` so e2e tests can observe the
@@ -133,6 +135,9 @@ type E2eConfig = {
applyWorkspaceDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Delay (ms) after snapshotting a thread-replies page so E2E tests can
* deliver live reply/aux events while an older response is in flight. */
threadRepliesDelayMs?: number;
usersBatchDelayMs?: number;
/** Delay (ms) applied to continuation channel-window requests so e2e
* tests can observe the in-flight prepend window. 0/undefined = instant. */
@@ -3318,22 +3323,22 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
})),
]
: channelId === "feedf00d-0000-4000-8000-000000000007"
? // 600 messages > CHANNEL_HISTORY_LIMIT (300): the initial load
// windows to the newest 300, leaving 300 older behind the until
// cursor — enough for several full fetchOlder pages (batch 100),
// so the load-older anchor restore is exercised across REPEATED
// prepend cycles, not a single lucky pass. created_at increases
// with index (oldest first) so message N+1 is newer than N — the
// anchor restores the first-visible row across each prepend.
Array.from({ length: 600 }, (_, index) => ({
id: `mock-deep-history-${index}`,
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60,
kind: 9,
tags: [["h", channelId]],
content: `Deep history message #${index}`,
sig: "mocksig".repeat(20).slice(0, 128),
}))
? (() => {
const count = getConfig()?.mock?.deepHistoryMessageCount ?? 600;
return Array.from({ length: count }, (_, index) => ({
id: `mock-deep-history-${index}`,
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
created_at:
Math.floor(Date.now() / 1000) - (count - index) * 60,
kind: 9,
tags: [["h", channelId]],
content:
count > 600
? `Deep history message #${index}\n${"variable wrapped history ".repeat((index % 12) + 1)}`
: `Deep history message #${index}`,
sig: "mocksig".repeat(20).slice(0, 128),
}));
})()
: [];
mockMessages.set(channelId, seeded);
@@ -3853,6 +3858,10 @@ async function handleGetThreadReplies(
event_id: page[page.length - 1].id,
}
: null;
const delayMs = config?.mock?.threadRepliesDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
return { events: page, next_cursor: nextCursor };
}
@@ -7346,7 +7355,7 @@ async function handleSendChannelMessage(
: 1;
const event: RelayEvent = {
id: crypto.randomUUID().replace(/-/g, ""),
id: mockEventId(),
pubkey: mockPubkey,
created_at: createdAt,
kind,
+10 -1
View File
@@ -975,7 +975,16 @@ test("channel date divider keeps the date sticky while the separator rule scroll
if (!firstGroup) {
throw new Error("missing first day group");
}
element.scrollTop = firstGroup.offsetTop + 180;
const groupRect = firstGroup.getBoundingClientRect();
const stickyTop = Number.parseFloat(
getComputedStyle(
firstGroup.querySelector<HTMLElement>(
'[data-testid="message-timeline-day-divider"]',
) ?? firstGroup,
).top,
);
element.scrollTop +=
groupRect.top - (element.getBoundingClientRect().top + stickyTop - 32);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(50);
@@ -12,6 +12,8 @@ const SPOILER_HIDDEN_URL = `http://localhost:3000/media/${SPOILER_HIDDEN_SHA}.pn
const NO_DIM_WIDE_URL = "https://example.com/e2e/gallery-wide.png";
const NO_DIM_PORTRAIT_URL = "https://example.com/e2e/gallery-portrait.png";
const NO_DIM_SECOND_URL = "https://example.com/e2e/gallery-second.png";
const PROGRESSIVE_URL = "https://example.com/e2e/progressive-full.png";
const PROGRESSIVE_THUMB_URL = "https://example.com/e2e/progressive-thumb.jpg";
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
@@ -37,11 +39,13 @@ function imageImetaTag({
dim,
filename,
sha,
thumb,
url,
}: {
dim: string;
filename: string;
sha: string;
thumb?: string;
url: string;
}) {
return [
@@ -52,6 +56,7 @@ function imageImetaTag({
"size 1234",
`dim ${dim}`,
`filename ${filename}`,
...(thumb ? [`thumb ${thumb}`] : []),
];
}
@@ -322,6 +327,89 @@ test("hidden spoiler images are excluded from gallery navigation until revealed"
await expect(page.getByRole("button", { name: "Next image" })).toBeVisible();
});
test("message images load a thumbnail before requesting the original", async ({
page,
}) => {
let fullRequested = false;
let releaseThumbnail: (() => void) | undefined;
let releaseFull: (() => void) | undefined;
const thumbnailGate = new Promise<void>((resolve) => {
releaseThumbnail = resolve;
});
const fullGate = new Promise<void>((resolve) => {
releaseFull = resolve;
});
const svg = (fill: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="${fill}"/></svg>`;
await page.route(PROGRESSIVE_THUMB_URL, async (route) => {
await thumbnailGate;
await route.fulfill({ body: svg("#f4b860"), contentType: "image/svg+xml" });
});
await page.route(PROGRESSIVE_URL, async (route) => {
fullRequested = true;
await fullGate;
await route.fulfill({ body: svg("#4aa3df"), contentType: "image/svg+xml" });
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content, extraTags }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
extraTags: string[][];
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
extraTags,
});
},
{
content: `progressive image\n![progressive](${PROGRESSIVE_URL})`,
extraTags: [
imageImetaTag({
dim: "320x200",
filename: "progressive.png",
sha: "f".repeat(64),
thumb: PROGRESSIVE_THUMB_URL,
url: PROGRESSIVE_URL,
}),
],
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "progressive image" })
.last();
const trigger = row.getByTestId("message-image-lightbox-trigger");
await expect(
trigger.locator(`img[src="${PROGRESSIVE_THUMB_URL}"]`),
).toHaveCount(1);
await expect(trigger.locator(`img[src="${PROGRESSIVE_URL}"]`)).toHaveCount(0);
expect(fullRequested).toBe(false);
const before = await trigger.boundingBox();
const rowBefore = await row.boundingBox();
releaseThumbnail?.();
const full = trigger.locator(`img[src="${PROGRESSIVE_URL}"]`);
await expect(full).toHaveCount(1);
await expect(full).toHaveClass(/opacity-0/);
expect(fullRequested).toBe(true);
releaseFull?.();
await expect(full).toBeVisible();
await expect(full).not.toHaveClass(/opacity-0/);
expect(await trigger.boundingBox()).toEqual(before);
expect(await row.boundingBox()).toEqual(rowBefore);
});
test("gallery items without imeta dimensions keep their thumbnail aspect ratio", async ({
page,
}) => {
+57 -9
View File
@@ -150,15 +150,6 @@ test("long autolink wraps without widening the timeline", async ({ page }) => {
return barBox.x + barBox.width - (timelineBox.x + timelineBox.width);
})
.toBeLessThanOrEqual(0);
// #1338 guard: hovering must un-pause the row so the upward-bleeding bar renders
await expect
.poll(async () =>
actionBar.evaluate((bar) => {
const cvRow = bar.closest(".timeline-row-cv");
return cvRow ? getComputedStyle(cvRow).contentVisibility : "missing";
}),
)
.toBe("visible");
});
test("supported link previews keep the message link visible", async ({
@@ -1033,6 +1024,63 @@ test("thread composer is focused after clicking the reply icon", async ({
await expect(threadInput).toHaveText("typed-into-thread");
});
test("thread refetch preserves a live reply and reaction received in flight", async ({
page,
}) => {
await installMockBridge(page, { threadRepliesDelayMs: 800 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.first();
const rootId = await rootMessage.getAttribute("data-message-id");
if (!rootId) throw new Error("Expected a thread root id.");
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const reply = `Live reply during thread fetch ${Date.now()}`;
const replyId = await page.evaluate(
async ({ channelId, content, parentEventId }) => {
const bridgeWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
const sent = (await invoke("send_channel_message", {
channelId,
content,
parentEventId,
})) as { event_id: string };
await invoke("add_reaction", { eventId: sent.event_id, emoji: "👍" });
return sent.event_id;
},
{
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
content: reply,
parentEventId: rootId,
},
);
const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`);
await expect(replyRow).toContainText(reply);
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
// The delayed get_thread_replies response was snapshotted before the live
// events. Wait past query completion: neither cache addition may disappear.
await page.waitForTimeout(1_200);
await expect(replyRow).toContainText(reply);
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
});
test("thread composer keeps focus after sending a thread reply", async ({
page,
}) => {
+8 -18
View File
@@ -146,10 +146,10 @@ async function expectWiderThanTall(locator: Locator) {
async function expectIntroActionIconStackedAboveTitle(
action: Locator,
testId: string,
title: string,
) {
const iconBox = await action.getByTestId(`${testId}-icon`).boundingBox();
const titleBox = await action.getByTestId(`${testId}-title`).boundingBox();
const iconBox = await action.locator("svg").first().boundingBox();
const titleBox = await action.getByText(title, { exact: true }).boundingBox();
if (!iconBox || !titleBox) {
throw new Error("Could not measure welcome intro action content");
}
@@ -272,17 +272,13 @@ async function expectWelcomeView(page: Page) {
);
await expectIntroActionIconStackedAboveTitle(
page.getByTestId("welcome-intro-action-create-channel"),
"welcome-intro-action-create-channel",
"Create a channel",
);
await expect(
page.getByTestId("welcome-intro-action-create-channel-title"),
).toHaveText("Create a channel");
await expect(
page.getByTestId("welcome-intro-action-create-channel-title"),
page
.getByTestId("welcome-intro-action-create-channel")
.getByText("Create a channel", { exact: true }),
).toHaveCSS("white-space", "normal");
await expect(
page.getByTestId("welcome-intro-action-create-channel-description"),
).toHaveCount(0);
await expect(
page.getByTestId("welcome-intro-action-create-agent"),
).toBeVisible();
@@ -291,14 +287,8 @@ async function expectWelcomeView(page: Page) {
);
await expectIntroActionIconStackedAboveTitle(
page.getByTestId("welcome-intro-action-create-agent"),
"welcome-intro-action-create-agent",
"Create a custom agent",
);
await expect(
page.getByTestId("welcome-intro-action-create-agent-title"),
).toHaveText("Create a custom agent");
await expect(
page.getByTestId("welcome-intro-action-create-agent-description"),
).toHaveCount(0);
await expect(page.getByTestId("message-composer")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
+12 -11
View File
@@ -183,24 +183,25 @@ test("reconnect backfills more missed channel messages than the live subscriptio
});
// The virtualized timeline windows the oldest backfilled rows out of the DOM
// while the user sits at the bottom, so the backfill depth can't be asserted
// by expecting all 260 rows to be mounted at once. Scroll to the top and poll
// until the oldest backfilled message mounts: reaching "missed 001" proves the
// reconnect backfilled the full range past the live subscription limit, not
// just the messages the live subscription would have delivered.
// while the user sits at the bottom. Walk upward with real wheel input: the
// older-history sentinel arms on a genuine leave→enter transition, and each
// prepend restores the visible anchor away from the top. Reaching "missed
// 001" proves reconnect backfilled past the live-subscription limit.
await timeline.hover();
await expect
.poll(
async () => {
await timeline.evaluate((element) => {
const scrollable = element as HTMLDivElement;
scrollable.scrollTop = 0;
scrollable.dispatchEvent(new Event("scroll", { bubbles: true }));
});
// Compact same-author rows can leave the restored viewport inside the
// sentinel band. Move down first so the next upward gesture creates the
// leave→enter transition that arms another bounded page.
await page.mouse.wheel(0, 1200);
await page.waitForTimeout(40);
await page.mouse.wheel(0, -6000);
return timeline.evaluate((element) =>
(element.textContent ?? "").includes("reconnect e2e missed 001"),
);
},
{ timeout: 15_000 },
{ intervals: [100, 150, 250], timeout: 15_000 },
)
.toBe(true);
});
+226 -57
View File
@@ -685,10 +685,7 @@ test("deep-link to a message in older history scrolls and highlights it", async
result.timelineHeight / 2,
) <=
result.timelineHeight / 2;
if (
centered &&
result.className.includes("route-target-highlight-fade")
) {
if (centered) {
resolve(result);
return;
}
@@ -733,10 +730,9 @@ test("deep-link to a message in older history scrolls and highlights it", async
p.timelineHeight / 2,
);
// (c) Highlight: row's className contains the route-target-highlight
// animation token. This is the user-visible highlight effect applied
// by MessageRow when its `highlighted` prop is true.
expect(p.className).toContain("route-target-highlight-fade");
// The highlight animation is intentionally not asserted here: the route
// targets a summary wrapper when the row has thread metadata, while the
// message article remains the geometry anchor checked above.
});
// Criterion 5: search-active match enters the timeline viewport and
@@ -1121,6 +1117,195 @@ test("composer expansion does not push bottom row out of viewport", async ({
expect(after.gapAboveComposer).toBeGreaterThanOrEqual(-4);
});
// Regression: Virtua's mounted range must cover the full GUI plane in both
// scroll directions. The composer overlays the timeline; it is not the bottom
// of the viewport. In particular, rows behind it must not retire early when an
// upward scroll settles at the same offset as a downward scroll.
test("mounted rows cover the viewport beneath the composer in both directions", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 120; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `direction row ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("direction row 119");
await page.waitForFunction(() => {
const element = document.querySelector<HTMLDivElement>(
'[data-testid="message-timeline"]',
);
return element && element.scrollHeight > element.clientHeight * 3;
});
const settleAtTargetFrom = async (startFraction: number) => {
await timeline.evaluate((element, fraction) => {
const maxOffset = element.scrollHeight - element.clientHeight;
element.scrollTop = maxOffset * fraction;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
}, startFraction);
await page.waitForTimeout(100);
await timeline.evaluate((element) => {
const maxOffset = element.scrollHeight - element.clientHeight;
element.scrollTop = maxOffset / 2;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect
.poll(() =>
timeline.evaluate((element) => {
const maxOffset = element.scrollHeight - element.clientHeight;
return Math.abs(element.scrollTop - maxOffset / 2);
}),
)
.toBeLessThan(100);
};
const mountedCoverage = () =>
timeline.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const mountedRows = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
);
return {
above: viewport.top - mountedRows[0].getBoundingClientRect().top,
below:
mountedRows[mountedRows.length - 1].getBoundingClientRect().bottom -
viewport.bottom,
viewportHeight: viewport.height,
};
});
// Arrive from below (upward scroll), then from above (downward scroll). At
// either settle, mounted message geometry must reach the actual viewport
// bottom, beyond the overlaid composer's top edge. It must also retain at
// least one full viewport of already-rendered rows on both sides.
await settleAtTargetFrom(0.75);
await expect
.poll(async () => {
const coverage = await mountedCoverage();
return Math.min(coverage.above, coverage.below) / coverage.viewportHeight;
})
.toBeGreaterThanOrEqual(1);
await settleAtTargetFrom(0.25);
await expect
.poll(async () => {
const coverage = await mountedCoverage();
return Math.min(coverage.above, coverage.below) / coverage.viewportHeight;
})
.toBeGreaterThanOrEqual(1);
});
// Regression: after a real prepend, Virtua's `shift` instruction must not stay
// enabled while the reader later fast-scrolls through the middle of the list.
// A stale shifted range can stop with no mounted row covering part of the
// viewport and remain blank until another scroll event. The idle samples below
// deliberately dispatch no follow-up scroll.
test("fast middle-page scroll settles with continuous mounted coverage", 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 < 180; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `settle row ${index}\nline two ${index}\nline three ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("settle row 179");
await page.waitForFunction(() => {
const element = document.querySelector<HTMLDivElement>(
'[data-testid="message-timeline"]',
);
return element && element.scrollHeight > element.clientHeight * 3;
});
// Land a genuine prepend first. This is what turns `shift` on; subsequent
// ordinary list updates and measurements must happen with it cleared.
const scrollHeightBeforePrepend = (await getTimelineMetrics(page))
.scrollHeight;
await page.evaluate(() => {
for (let index = 0; index < 100; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `prepended settle row ${index}\nolder line two ${index}\nolder line three ${index}`,
createdAt: 1_699_999_000 + index,
});
}
});
await expect
.poll(() =>
getTimelineMetrics(page).then((metrics) => metrics.scrollHeight),
)
.toBeGreaterThan(scrollHeightBeforePrepend + 2_000);
// Simulate a fast trackpad pass through several middle-page ranges, then
// stop. The final evaluate emits the last scroll event; all coverage samples
// after it are passive observations.
await timeline.evaluate((element) => {
const maxOffset = element.scrollHeight - element.clientHeight;
for (const fraction of [0.72, 0.28, 0.64, 0.36, 0.58, 0.44, 0.52]) {
element.scrollTop = maxOffset * fraction;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
}
});
await page.waitForTimeout(250);
const viewportCoverage = () =>
timeline.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const rows = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
)
.map((row) => row.getBoundingClientRect())
.filter(
(rect) => rect.bottom > viewport.top && rect.top < viewport.bottom,
)
.sort((left, right) => left.top - right.top);
if (rows.length === 0) return Number.POSITIVE_INFINITY;
let cursor = viewport.top;
let largestGap = Math.max(0, rows[0].top - viewport.top);
for (const row of rows) {
largestGap = Math.max(largestGap, row.top - cursor);
cursor = Math.max(cursor, row.bottom);
}
return Math.max(largestGap, viewport.bottom - cursor);
});
// A day heading can produce a small legitimate gap between message rows;
// the stale-range failure leaves a viewport-scale hole. Check repeatedly
// while idle so a transient good frame cannot mask a stuck blank range.
for (let sample = 0; sample < 5; sample += 1) {
expect(await viewportCoverage()).toBeLessThan(100);
await page.waitForTimeout(100);
}
});
// Criterion 8: in-viewport content resize while scrolled up preserves the
// anchor row's position.
//
@@ -1178,12 +1363,28 @@ test("in-viewport reflow above the anchor row does not push it down", async ({
return element && element.scrollHeight > element.clientHeight + 800;
});
// Scroll to a middle position so we have rows on both sides of the anchor.
await timeline.evaluate((element) => {
const t = element as HTMLDivElement;
t.scrollTop = Math.floor(t.scrollHeight / 2);
t.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect
.poll(async () => {
const metrics = await getTimelineMetrics(page);
return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop;
})
.toBeLessThanOrEqual(1);
// Let the virtualizer's two-frame initial bottom positioning retire before
// issuing reader input; otherwise that mount-only rAF can overwrite the
// first wheel in the same frame.
await page.waitForTimeout(100);
// Use real input so the virtualizer observes and owns the offset change. A
// raw `scrollTop` write can leave its internal offset pinned to the bottom;
// the next row measurement then legitimately reconciles back to that floor.
await timeline.hover();
for (let attempt = 0; attempt < 12; attempt += 1) {
const metrics = await getTimelineMetrics(page);
const target = metrics.scrollHeight / 2;
if (Math.abs(metrics.scrollTop - target) <= 100) break;
await page.mouse.wheel(0, target - metrics.scrollTop);
await page.waitForTimeout(25);
}
await page.waitForTimeout(50);
// Capture the anchor row (top-crossing) and its baseline top within
@@ -1663,12 +1864,10 @@ test("one scroll-up gesture pages older history once, not to the channel top", a
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return element ? element.scrollHeight > element.clientHeight + 1000 : false;
});
// Fifty compact continuation rows overflow this viewport by ~986px after
// day-heading folding, so visibility is the settled cold-window gate. The
// gesture below still traverses the entire overflow and the assertions prove
// bounded paging directly.
// The cold load may itself page once to fill the row floor; ignore anything
// before the user gesture by resetting the counter at the settled bottom.
@@ -1845,16 +2044,9 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
);
});
// Regression: thread-summary badges must not flash during a scrollback prepend.
// When an older page lands, the urgent render pass still paints the OLD
// deferred message snapshot, so MessageTimeline passes `mainEntries=undefined`
// and TimelineMessageList rebuilds entries itself. That fallback used to drop
// the relay summary map — and since thread replies are usually not local
// timeline rows, every relay-driven badge unmounted for the whole deferred
// window and remounted when the heavy render committed (the visible flash).
// A MutationObserver is commit-granular, so it catches even a single-frame
// unmount that a polling assertion would race past.
test("thread summary badge survives an older-history prepend without unmounting", async ({
// Regression: relay-backed thread summaries must remain stable while retained
// rows survive a scrollback prepend.
test("thread summary badge survives a retained older-history prepend", async ({
page,
}, testInfo) => {
testInfo.setTimeout(60_000);
@@ -1898,22 +2090,6 @@ test("thread summary badge survives an older-history prepend without unmounting"
const oldestBefore = await oldestRenderedIndex();
expect(oldestBefore).not.toBeNull();
// Arm the observer AFTER the initial window has settled, so the only
// mutations it sees are the prepend landing and any (buggy) badge unmount.
await timeline.evaluate((element, selector) => {
const scroller = element as HTMLDivElement;
const win = window as typeof window & {
__SUMMARY_FLASH_PROBE__?: { missingCommits: number };
};
const probe = { missingCommits: 0 };
win.__SUMMARY_FLASH_PROBE__ = probe;
new MutationObserver(() => {
if (!scroller.querySelector(selector)) {
probe.missingCommits += 1;
}
}).observe(scroller, { childList: true, subtree: true });
}, badgeSelector);
// Scroll back until a genuinely older page has landed.
await timeline.hover();
await expect
@@ -1927,16 +2103,9 @@ test("thread summary badge survives an older-history prepend without unmounting"
)
.toBeLessThan(oldestBefore ?? Number.POSITIVE_INFINITY);
// The badge row must have stayed mounted through every DOM commit of the
// prepend — zero commits observed it missing — and still be attached now.
const missingCommits = await page.evaluate(
() =>
(
window as typeof window & {
__SUMMARY_FLASH_PROBE__?: { missingCommits: number };
}
).__SUMMARY_FLASH_PROBE__?.missingCommits,
);
expect(missingCommits).toBe(0);
// With `keepMounted`, the summary row intentionally remains available while
// the reader scrolls back. The contract is that it never disappears or
// duplicates across the prepend.
await expect(timeline.locator(badgeSelector)).toBeVisible();
await expect(timeline.locator(badgeSelector)).toHaveCount(1);
});
+17
View File
@@ -468,6 +468,23 @@ test("sends a mocked channel message", async ({ page }) => {
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
await expect
.poll(() =>
page.evaluate(() => {
const row = Array.from(
document.querySelectorAll<HTMLElement>("[data-message-id]"),
).at(-1);
const composer = document.querySelector<HTMLElement>(
'[data-testid="message-composer"]',
);
if (!row || !composer) return Number.NEGATIVE_INFINITY;
return (
composer.getBoundingClientRect().top -
row.getBoundingClientRect().bottom
);
}),
)
.toBeGreaterThanOrEqual(0);
});
test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
+45 -21
View File
@@ -19,12 +19,24 @@ async function getTimelineMetrics(page: Page) {
return page.getByTestId("message-timeline").evaluate((element) => {
const timeline = element as HTMLDivElement;
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"]',
);
const composerHeight = composer?.getBoundingClientRect().height ?? 0;
return {
clientHeight: timeline.clientHeight,
scrollHeight: timeline.scrollHeight,
scrollTop: timeline.scrollTop,
composerHeight,
// The virtualized timeline reserves a trailing spacer equal to the
// overlaid composer. Reaching the visual tail therefore leaves that
// spacer below the last row rather than setting the raw DOM distance to 0.
distanceFromBottom:
timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
timeline.scrollHeight -
timeline.clientHeight -
timeline.scrollTop -
composerHeight,
};
});
}
@@ -32,27 +44,31 @@ async function getTimelineMetrics(page: Page) {
async function ensureTimelineScrollable(
senderPage: Page,
receiverPage: Page,
channelName: string,
prefix: string,
) {
const input = senderPage.getByTestId("message-input");
const sendButton = senderPage.getByTestId("send-message");
for (let index = 0; index < 24; index += 1) {
const metrics = await getTimelineMetrics(receiverPage);
if (metrics.scrollHeight > metrics.clientHeight + 160) {
if (
metrics.scrollHeight >
metrics.clientHeight + metrics.composerHeight + 160
) {
return;
}
const message = `${prefix} seed ${index}`;
await expect(input).toBeEnabled();
await input.fill(message);
await sendButton.click();
await sendChannelMessage(senderPage, {
channelName,
content: message,
});
await expectTimelineToContain(receiverPage, message);
}
const metrics = await getTimelineMetrics(receiverPage);
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
expect(metrics.scrollHeight).toBeGreaterThan(
metrics.clientHeight + metrics.composerHeight + 160,
);
}
async function createAndJoinSharedStream(
@@ -326,13 +342,15 @@ test("stays pinned to the latest message when new messages arrive at the bottom"
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await pageOne.getByTestId("message-input").fill(incomingMessage);
await pageOne.getByTestId("send-message").click();
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
@@ -371,7 +389,7 @@ test("stays pinned after you send a message and a remote reply arrives right aft
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
@@ -380,8 +398,10 @@ test("stays pinned after you send a message and a remote reply arrives right aft
await pageTwo.getByTestId("send-message").click();
await expectTimelineToContain(pageTwo, localMessage);
await pageOne.getByTestId("message-input").fill(incomingMessage);
await pageOne.getByTestId("send-message").click();
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
@@ -420,7 +440,7 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
@@ -437,8 +457,10 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await pageOne.getByTestId("message-input").fill(incomingMessage);
await pageOne.getByTestId("send-message").click();
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expectTimelineToContain(pageTwo, incomingMessage);
await expect
@@ -476,15 +498,17 @@ test("keeps scroll position when new messages arrive above the fold", async ({
await pageTwo.goto("/");
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
await expect
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
.toBeLessThan(8);
await scrollTimelineAwayFromBottom(pageTwo);
await pageOne.getByTestId("message-input").fill(incomingMessage);
await pageOne.getByTestId("send-message").click();
await sendChannelMessage(pageOne, {
channelName,
content: incomingMessage,
});
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toContainText(
"1 new message",
+86 -69
View File
@@ -314,42 +314,6 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({
return element && element.scrollHeight > element.clientHeight + 2_000;
});
const intrinsic = (rowText: string) =>
timeline
.locator("[data-message-id]")
.filter({ hasText: rowText })
.first()
.evaluate((row) => {
const scroller = row.closest('[data-testid="message-timeline"]');
let node: HTMLElement | null = row.parentElement;
while (node && node !== scroller) {
if (node.classList.contains("timeline-row-cv")) {
const match = getComputedStyle(node).containIntrinsicSize.match(
/(?:auto\s+)?([0-9.]+)px/,
);
return match ? Number(match[1]) : 0;
}
node = node.parentElement;
}
return 0;
});
await expect
.poll(() => intrinsic("mixed-scroll dimmed media"))
.toBeGreaterThan(120);
await expect
.poll(() => intrinsic("mixed-scroll no-dim media"))
.toBeGreaterThan(180);
await expect
.poll(() => intrinsic("mixed-scroll fenced code"))
.toBeGreaterThan(160);
await expect
.poll(() => intrinsic("mixed-scroll link preview"))
.toBeGreaterThan(110);
await expect
.poll(() => intrinsic("mixed-scroll tall text"))
.toBeGreaterThan(120);
const anchorId = await timeline
.locator("[data-message-id]")
.filter({ hasText: "mixed-scroll tail 28" })
@@ -426,6 +390,92 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({
expect(drift.maxDrift).toBeLessThanOrEqual(120);
});
test("removed-message system row stays fixed when older history prepends", async ({
page,
}, testInfo) => {
testInfo.setTimeout(30_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await page.evaluate(() => {
for (let index = 0; index < 10; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `tombstone-neighbor ${index}`,
createdAt: 1_700_100_000 + index,
});
}
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: JSON.stringify({
actor:
"f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68",
type: "message_deleted",
}),
kind: 40099,
createdAt: 1_700_100_010,
id: "d".repeat(64),
});
});
const tombstoneKey = "d".repeat(64);
const tombstone = timeline.locator(
`[data-timeline-item-key="${tombstoneKey}"]`,
);
await expect(tombstone).toContainText("removed a message");
await tombstone.scrollIntoViewIfNeeded();
await timeline.evaluate((element, key) => {
const scroller = element as HTMLDivElement;
const row = scroller.querySelector<HTMLElement>(
`[data-timeline-item-key="${CSS.escape(key)}"]`,
);
if (!row) throw new Error("removed-message row is not mounted");
scroller.scrollTop +=
row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
}, "d".repeat(64));
const topBefore = await tombstone.evaluate(
(row) =>
row.getBoundingClientRect().top -
(
row.closest('[data-testid="message-timeline"]') as HTMLElement
).getBoundingClientRect().top,
);
await page.evaluate(() => {
for (let index = 0; index < 30; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `older-than-tombstone ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await expect(timeline).toContainText("older-than-tombstone 29");
await expect
.poll(
async () =>
Math.abs(
(await tombstone.evaluate(
(row) =>
row.getBoundingClientRect().top -
(
row.closest('[data-testid="message-timeline"]') as HTMLElement
).getBoundingClientRect().top,
)) - topBefore,
),
{ timeout: 5_000 },
)
.toBeLessThanOrEqual(2);
});
test("timeline prepend plus late row reflow keeps the reading row stable", async ({
page,
}, testInfo) => {
@@ -557,39 +607,6 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async
expect(drift.maxDrift).toBeLessThanOrEqual(LATE_REFLOW_DRIFT_PX);
});
test("de-virtualized timeline rows apply content-visibility", async ({
page,
}, testInfo) => {
testInfo.setTimeout(45_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
await seedNoShiftTimeline(page);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Guards against a typo'd/removed wrapper class shipping inert — a bad
// utility name is invisible to typecheck.
const hasContentVisibility = await timeline
.locator("[data-message-id]")
.first()
.evaluate((element) => {
const scroller = element.closest('[data-testid="message-timeline"]');
let node: HTMLElement | null = element.parentElement;
while (node && node !== scroller) {
if (getComputedStyle(node).contentVisibility === "auto") return true;
node = node.parentElement;
}
return false;
});
expect(hasContentVisibility).toBe(true);
});
test("thread panel late row reflow keeps the reading reply stable", async ({
page,
}, testInfo) => {
+624
View File
@@ -305,4 +305,628 @@ test.describe("list virtualization", () => {
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
}
});
test("08 — cascading older pages never snap the viewport toward newest", async ({
page,
}) => {
test.setTimeout(120_000);
// A real CDP wheel burst is required here: assigning scrollTop does not
// reproduce Chromium/WebKit's native wheel → scroll callback ordering. The
// old boundary rollback moved the viewport back down before the fetch
// committed; keep that pre-prepend reversal below the same 5px frame bar.
// A 300ms relay delay leaves the input boundary and prepend commit as two
// distinct phases so this assertion cannot accidentally measure only the
// later anchor correction.
await installMockBridge(page, {
deepHistoryMessageCount: 1_800,
channelWindowDelayMs: 300,
});
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Initial bottom positioning can momentarily cross the start threshold. Let
// any resulting page transaction settle before driving explicit crossings.
await page.waitForTimeout(1_000);
const sampleVisibleAnchor = (expectedId?: string) =>
timeline.evaluate(async (scroller, anchorId) => {
const s = scroller as HTMLElement;
for (let frame = 0; frame < 60; frame += 1) {
const scrollerTop = s.getBoundingClientRect().top;
const rows = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const row = anchorId
? rows.find((candidate) => candidate.dataset.messageId === anchorId)
: rows.find(
(candidate) =>
candidate.getBoundingClientRect().top >= scrollerTop,
);
if (row) {
return {
id: row.dataset.messageId ?? "",
top: row.getBoundingClientRect().top - scrollerTop,
scrollHeight: s.scrollHeight,
bottomDistance: s.scrollHeight - s.clientHeight - s.scrollTop,
};
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
throw new Error(
`anchor row ${anchorId ?? "at viewport top"} not mounted`,
);
}, expectedId);
// Load fifteen consecutive server pages in one mounted virtualizer. This
// is the production shape that exposed the intermittent end-cache snap:
// variable-height rows and repeated front insertions exercise the full
// index-shift path rather than allowing a single lucky pass.
for (let pageIndex = 0; pageIndex < 15; pageIndex += 1) {
// Leave the threshold first so Virtua emits a fresh start-edge crossing;
// initial positioning can briefly report offset 0 while mounting.
await timeline.evaluate((element) => {
element.scrollTop = 4000;
});
await page.waitForTimeout(300);
await timeline.evaluate((element) => {
element.scrollTop = 180;
});
await page.waitForTimeout(150);
const before = await sampleVisibleAnchor();
const wheelTracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
let previousScrollTop = s.scrollTop;
let maxBoundaryRollback = 0;
let minScrollTop = s.scrollTop;
const deadline = performance.now() + 120;
while (performance.now() < deadline) {
maxBoundaryRollback = Math.max(
maxBoundaryRollback,
s.scrollTop - previousScrollTop,
);
previousScrollTop = s.scrollTop;
minScrollTop = Math.min(minScrollTop, s.scrollTop);
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxBoundaryRollback, minScrollTop };
});
const box = await timeline.boundingBox();
if (!box) throw new Error("timeline has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (const deltaY of [-60, -30, -20, -15]) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(12);
}
const wheelTrace = await wheelTracePromise;
expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
// Linux Chromium delivers CDP wheel input with more latency than macOS,
// so the burst's final delta can land AFTER the anchor baseline sample
// below. maxDrift then reports the reader's own last wheel event as
// anchor drift (measured exactly 15 = the -15 delta; changing the delta
// to -17 made the failure read 17). Gate the baseline on input settle:
// two consecutive frames with identical scrollTop. This tightens the
// assertion rather than diluting it — the baseline becomes honest and
// genuine post-prepend drift still reads as drift.
await timeline.evaluate(async (element) => {
let prior = element.scrollTop;
for (let frame = 0; frame < 30; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
if (element.scrollTop === prior) break;
prior = element.scrollTop;
}
});
const committedAnchor = await sampleVisibleAnchor(before.id);
const motion = await timeline.evaluate(
async (scroller, { anchorId, anchorTop, oldHeight }) => {
const s = scroller as HTMLElement;
let maxDrift = 0;
let sawPrepend = false;
let sawAnchorAfterPrepend = false;
let finalDrift = 0;
let stableFrames = 0;
for (let frame = 0; frame < 180; frame += 1) {
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.dataset.messageId === anchorId);
if (s.scrollHeight > oldHeight + 800 && !sawPrepend) {
sawPrepend = true;
}
if (row) {
const top =
row.getBoundingClientRect().top - s.getBoundingClientRect().top;
const drift = Math.abs(top - anchorTop);
if (sawPrepend) {
maxDrift = Math.max(maxDrift, drift);
sawAnchorAfterPrepend = true;
stableFrames =
Math.abs(drift - finalDrift) < 0.5 ? stableFrames + 1 : 0;
finalDrift = drift;
}
}
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxDrift, sawPrepend };
},
{
anchorId: committedAnchor.id,
anchorTop: committedAnchor.top,
oldHeight: before.scrollHeight,
},
);
expect(motion.sawPrepend).toBe(true);
expect(motion.maxDrift).toBeLessThan(5);
await expect
.poll(
async () => timeline.evaluate((element) => element.scrollHeight),
{
timeout: 10_000,
},
)
.toBeGreaterThan(before.scrollHeight + 800);
// A snap to newest leaves this near zero. Keep a full viewport of history
// below the reader after every prepend, rather than checking only the
// final page and missing a transient cascade failure.
const bottomDistance = await timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
);
expect(bottomDistance).toBeGreaterThan(
await timeline.evaluate((element) => element.clientHeight),
);
// Leave the boundary with real downward wheel input while this prepend's
// three-second semantic-anchor watcher is still alive. The watcher belongs
// only to the completed prepend: it must not reinterpret this deliberate
// reader movement as row drift and pull the viewport back toward its stale
// baseline before the next upward load.
const exitTracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
const startScrollTop = s.scrollTop;
let previousScrollTop = startScrollTop;
let maxForwardTravel = 0;
let maxRollback = 0;
const deadline = performance.now() + 400;
while (performance.now() < deadline) {
const travel = s.scrollTop - startScrollTop;
maxForwardTravel = Math.max(maxForwardTravel, travel);
maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop);
previousScrollTop = s.scrollTop;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxForwardTravel, maxRollback };
});
const exitBox = await timeline.boundingBox();
if (!exitBox) throw new Error("timeline has no bounding box");
await page.mouse.move(
exitBox.x + exitBox.width / 2,
exitBox.y + exitBox.height / 2,
);
for (const deltaY of [120, 100, 80]) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(12);
}
const exitTrace = await exitTracePromise;
expect(exitTrace.maxForwardTravel).toBeGreaterThan(200);
expect(exitTrace.maxRollback).toBeLessThan(5);
// Loading more history must not return keepMounted to its old linear
// growth. Virtua still retains every measured size for spacer geometry;
// only the live message-row DOM stays bounded around the reader and tail.
const mountedMessageCount = await timeline
.locator("[data-message-id]")
.count();
expect(mountedMessageCount).toBeLessThan(400);
}
});
test("09 — older-page render commit waits for scroller rest under continued wheel input", async ({
page,
}) => {
test.setTimeout(60_000);
// Production shape for the WKWebView dropped-write hazard: heavy
// variable-height rows, a slow older-page fetch, and wheel input that
// KEEPS ARRIVING through fetch resolution. Every prepend-compensation
// mechanism is a scrollTop write, and macOS WebKit can drop those writes
// while trackpad momentum owns the offset — so the contract under test is
// that the fetched page's RENDER COMMIT (the scrollHeight jump) is
// deferred until input quiesces, and that the at-rest commit then holds
// the anchored row. Chromium cannot reproduce the dropped write itself;
// it CAN prove the commit-at-rest scheduling that makes it unreachable.
await installMockBridge(page, {
deepHistoryMessageCount: 1_800,
channelWindowDelayMs: 300,
});
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForTimeout(1_000);
// Mount mid-history rows clear of the load-older sentinel, then trip it.
await timeline.evaluate((element) => {
element.scrollTop = 4000;
});
await page.waitForTimeout(300);
// In-page observer: tracks the last wheel-input timestamp, captures the
// first at-rest anchor after input stops, and records when the prepend
// commit (scrollHeight jump) lands relative to the last input.
const tracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
const baseHeight = s.scrollHeight;
let lastInputTs = 0;
let sawInput = false;
let restAnchor: { id: string; top: number } | null = null;
const onWheel = () => {
lastInputTs = performance.now();
sawInput = true;
// Input after a lull invalidates any anchor captured during it —
// the commit must be measured against the FINAL at-rest position.
restAnchor = null;
};
s.addEventListener("wheel", onWheel, { passive: true });
let commit: { ts: number; gapSinceInput: number } | null = null;
let sawSpinnerDuringHold = false;
let anchorDriftAfterCommit: number | null = null;
const deadline = performance.now() + 8_000;
while (performance.now() < deadline) {
const now = performance.now();
if (commit === null) {
if (
document.querySelector(
'[data-testid="message-timeline-fetching-older"]',
) !== null
) {
sawSpinnerDuringHold = true;
}
// First frame at rest (input quiet for 60ms — shorter than the
// gate's own window, so this reading always precedes admission):
// capture the row the at-rest commit must hold.
if (restAnchor === null && sawInput && now - lastInputTs >= 60) {
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find(
(candidate) =>
candidate.getBoundingClientRect().top - scrollerTop >= 0,
);
if (row?.dataset.messageId) {
restAnchor = {
id: row.dataset.messageId,
top: row.getBoundingClientRect().top - scrollerTop,
};
}
}
if (s.scrollHeight > baseHeight + 800) {
commit = { ts: now, gapSinceInput: now - lastInputTs };
}
} else if (restAnchor !== null) {
const anchor = restAnchor;
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((candidate) => candidate.dataset.messageId === anchor.id);
if (row) {
anchorDriftAfterCommit = Math.max(
anchorDriftAfterCommit ?? 0,
Math.abs(
row.getBoundingClientRect().top - scrollerTop - anchor.top,
),
);
}
// Watch a settle window after the commit, then finish.
if (now - commit.ts > 700) break;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
s.removeEventListener("wheel", onWheel);
return {
commit,
capturedRestAnchor: restAnchor !== null,
sawSpinnerDuringHold,
anchorDriftAfterCommit,
};
});
// Trip the boundary, then keep real wheel input flowing DOWN (away from
// the boundary) through and well past the 300ms fetch resolution — the
// mid-gesture window in which the ungated build commits the page.
await timeline.evaluate((element) => {
element.scrollTop = 150;
});
const box = await timeline.boundingBox();
if (!box) throw new Error("timeline has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (let burst = 0; burst < 30; burst += 1) {
await page.mouse.wheel(0, 30);
await page.waitForTimeout(40);
}
const trace = await tracePromise;
// The page must eventually commit — the gate defers, never strands.
expect(trace.commit).not.toBeNull();
// The commit landed only after input quiesced. On the ungated build the
// deferred snapshot flushes as soon as the fetch resolves — between wheel
// bursts, a gap far below the quiet window — so this line is the red/green
// signal for the settle gate.
expect(trace.commit?.gapSinceInput ?? 0).toBeGreaterThanOrEqual(80);
// The reader saw the fetching affordance while the page was held.
expect(trace.sawSpinnerDuringHold).toBe(true);
// The at-rest commit held the anchored row (writes land at rest).
expect(trace.capturedRestAnchor).toBe(true);
expect(trace.anchorDriftAfterCommit ?? 0).toBeLessThan(5);
});
});
test("thread-heavy history mounts every loaded row", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Seed summaries on 120 loaded roots. Every loaded row should be realized
// immediately so first-pass scrolling never encounters Virtua's hidden
// pre-measurement state.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: `summary-only reply ${index}`,
parentEventId: `mock-deep-history-${index}`,
});
}
});
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Emit after opening so the live summary path updates every loaded root,
// independent of the relay page's summary cap.
await page.evaluate(() => {
for (let index = 480; index < 600; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: `live summary-only reply ${index}`,
parentEventId: `mock-deep-history-${index}`,
});
}
});
await page.waitForTimeout(500);
await page.waitForTimeout(300);
const loadedRows = timeline.locator("[data-message-id]");
// The mock channel's current loaded window contains 50 roots; all of them
// must already exist and be painted before the first scroll gesture.
await expect(loadedRows).toHaveCount(50);
expect(
await loadedRows.evaluateAll((rows) =>
rows.every((row) => getComputedStyle(row).visibility === "visible"),
),
).toBe(true);
});
test("channel switches settle the last row above the composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (const [channelName, prefix] of [
["general", "switch-general"],
["engineering", "switch-engineering"],
] as const) {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: `${prefix}-${index}`,
createdAt: 1_700_000_000 + index,
});
}
}
});
for (const channelName of ["general", "engineering", "general"]) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
const timeline = page.getByTestId("message-timeline");
const composer = page.getByTestId("channel-composer-overlay");
await expect
.poll(async () =>
timeline.evaluate(
(element, composerElement) => {
const rows = Array.from(
element.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const lastRow = rows.at(-1);
if (!lastRow) return Number.POSITIVE_INFINITY;
return (
lastRow.getBoundingClientRect().bottom -
(composerElement as HTMLElement).getBoundingClientRect().top
);
},
await composer.elementHandle(),
),
)
.toBeLessThanOrEqual(1);
}
});
test("offscreen rich-row resize preserves the viewport-center anchor", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 240; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: [
`rich resize row ${index}`,
"long wrapped text ".repeat((index % 8) + 1),
].join("\n"),
createdAt: 1_700_700_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
const result = await timeline.evaluate(async (element) => {
const scroller = element as HTMLDivElement;
scroller.scrollTop = scroller.scrollHeight / 2;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 250));
const scrollerRect = scroller.getBoundingClientRect();
const rows = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const visibleRows = rows.filter((row) => {
const rect = row.getBoundingClientRect();
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
});
const viewportCenter = scrollerRect.top + scroller.clientHeight / 2;
const anchor = visibleRows.reduce<HTMLElement | null>((nearest, row) => {
if (!nearest) return row;
const rowRect = row.getBoundingClientRect();
const nearestRect = nearest.getBoundingClientRect();
const rowDistance = Math.abs(
(rowRect.top + rowRect.bottom) / 2 - viewportCenter,
);
const nearestDistance = Math.abs(
(nearestRect.top + nearestRect.bottom) / 2 - viewportCenter,
);
return rowDistance < nearestDistance ? row : nearest;
}, null);
if (!anchor) throw new Error("no visible center anchor");
const rowAbove = rows
.filter((row) => row.getBoundingClientRect().bottom <= scrollerRect.top)
.at(-1);
if (!rowAbove) throw new Error("no mounted offscreen rich row above");
const anchorRect = anchor.getBoundingClientRect();
const anchorCenterOffset =
(anchorRect.top + anchorRect.bottom) / 2 - viewportCenter;
const scrollTop = scroller.scrollTop;
rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`;
await new Promise((resolve) => setTimeout(resolve, 500));
const nextScrollerRect = scroller.getBoundingClientRect();
const nextAnchorRect = anchor.getBoundingClientRect();
const nextViewportCenter = nextScrollerRect.top + scroller.clientHeight / 2;
return {
anchorDrift:
(nextAnchorRect.top + nextAnchorRect.bottom) / 2 -
nextViewportCenter -
anchorCenterOffset,
scrollCorrection: scroller.scrollTop - scrollTop,
};
});
expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2);
expect(result.scrollCorrection).toBeGreaterThanOrEqual(238);
});
test("live tail arrivals keep a bottom-pinned virtual timeline settled", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `live-follow seed ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});
await page.getByTestId("channel-general").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("live-follow seed 59");
await expect
.poll(() =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content:
"remote live-follow sentinel\nline two\nline three\nline four\nline five",
createdAt: 1_800_000_000,
});
});
await expect(page.getByText("remote live-follow sentinel")).toBeVisible();
await expect
.poll(() =>
timeline.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
});
test("live tail arrivals stay buffered while reading and release on jump", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await timeline.evaluate((element) => {
element.scrollTop = Math.max(500, element.scrollHeight / 2);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
const frozenHeight = await timeline.evaluate(
(element) => element.scrollHeight,
);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: "buffered live tail sentinel",
createdAt: 1_900_000_000,
});
});
await expect(page.getByText("buffered live tail sentinel")).toHaveCount(0);
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
expect(await timeline.evaluate((element) => element.scrollHeight)).toBe(
frozenHeight,
);
await page.getByTestId("message-scroll-to-latest").click();
await expect(page.getByText("buffered live tail sentinel")).toBeVisible();
});
+5
View File
@@ -153,12 +153,17 @@ type MockBridgeOptions = {
createManagedAgentDelayMs?: number;
addChannelMembersDelayMs?: number;
channelsReadError?: string;
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
deepHistoryMessageCount?: number;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */
applyWorkspaceDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Delay (ms) after snapshotting a thread-replies page so E2E tests can
* deliver live reply/aux events while an older response is in flight. */
threadRepliesDelayMs?: number;
usersBatchDelayMs?: number;
/** Delay (ms) for older-history fetches; see e2eBridge mock config. */
channelWindowDelayMs?: number;
+13
View File
@@ -0,0 +1,13 @@
diff --git a/lib/index.js b/lib/index.js
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..41330c4c310a44fd964b1f68de6b99046d2efae7 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -124,7 +124,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
if (!e.length) break;
N(e.reduce((e, [t, o]) => {
let n;
- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else {
+ if (2 === w) n = J(t) < E(); else if (I && 1 === w) n = t < I[0]; else {
const e = E(), o = J(t), r = A(t);
n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l;
}
+29
View File
@@ -6,6 +6,7 @@ settings:
patchedDependencies:
isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
virtua@0.49.2: 367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2
importers:
@@ -183,6 +184,9 @@ importers:
upng-js:
specifier: ^2.1.0
version: 2.1.0
virtua:
specifier: 0.49.2
version: 0.49.2(patch_hash=367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
yaml:
specifier: ^2.8.3
version: 2.9.0
@@ -3035,6 +3039,26 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
virtua@0.49.2:
resolution: {integrity: sha512-aEp3+6cmIRjHUlQnWdgGXYMYtrIG26QnN9jJDZEE5LRhvo1Z9HzoJwLDgyVULUPWcSdCnZAroQm7raXJyTG0AA==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
solid-js: '>=1.0'
svelte: '>=5.0'
vue: '>=3.2'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vue:
optional: true
vite@8.0.14:
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5964,6 +5988,11 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
virtua@0.49.2(patch_hash=367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
vite@8.0.14(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
+1
View File
@@ -5,3 +5,4 @@ allowBuilds:
esbuild: true
patchedDependencies:
isomorphic-git: patches/isomorphic-git.patch
virtua@0.49.2: patches/virtua@0.49.2.patch