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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManag
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useFeatureEnabled } from "@/shared/features";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
Expand Down Expand Up @@ -115,6 +116,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
threadUnreadCounts,
unfollowThreadById,
}: TimelineMessageListProps) {
// eagerTimelineLayout preview: opt every row out of content-visibility
// skipping so the full fetched window lays out immediately at exact heights
// (no estimate→true re-realization, and none of the anchored-scroll
// corrections it forces, during scrolling).
const eagerTimelineLayout = useFeatureEnabled("eagerTimelineLayout");

const entries = React.useMemo(
() =>
mainEntries ??
Expand Down Expand Up @@ -277,7 +284,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
)}
{group.items.map((item) => (
<div
className="timeline-row-cv"
className={cn(
"timeline-row-cv",
eagerTimelineLayout && "timeline-row-cv-eager",
)}
key={getTimelineItemKey(item)}
style={timelineRowReserveStyle(item)}
>
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/shared/styles/globals/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@
content-visibility: visible;
}

/* eagerTimelineLayout preview: realize every fetched row's layout up front.
Heights are exact from first paint, so scrolling never triggers the
estimate→true height re-realization (and the anchored-scroll position
corrections it forces). The reserve style stays on the wrapper but is
inert without size containment. */
.timeline-row-cv.timeline-row-cv-eager {
content-visibility: visible;
}

.content-visibility-auto-interactive {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
Expand Down
43 changes: 36 additions & 7 deletions desktop/tests/e2e/timeline-no-shift.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,11 @@ test("de-virtualized timeline rows apply content-visibility", async ({
}, testInfo) => {
testInfo.setTimeout(45_000);

// The e2e bridge opts every preview feature in, so `eagerTimelineLayout`
// is ON here: rows must compute `visible` (eagerly laid out). The
// flag-off spec below covers the default `auto` path. Both guard against
// a typo'd/removed wrapper class shipping inert — a bad utility name is
// invisible to typecheck.
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
Expand All @@ -573,22 +578,46 @@ test("de-virtualized timeline rows apply content-visibility", async ({
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
expect(await firstRowContentVisibility(timeline)).toBe("visible");
});

test("timeline rows skip offscreen layout when eager layout is off", async ({
page,
}, testInfo) => {
testInfo.setTimeout(45_000);

await installMockBridge(page, undefined, { seedPreviewFeatures: false });
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();

expect(await firstRowContentVisibility(timeline)).toBe("auto");
});

/** Computed `content-visibility` of the first message row's `timeline-row-cv`
* wrapper, or "missing" when no wrapper is found between row and scroller. */
function firstRowContentVisibility(timeline: Locator): Promise<string> {
return 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;
if (node.classList.contains("timeline-row-cv")) {
return getComputedStyle(node).contentVisibility;
}
node = node.parentElement;
}
return false;
return "missing";
});
expect(hasContentVisibility).toBe(true);
});
}

test("thread panel late row reflow keeps the reading reply stable", async ({
page,
Expand Down
8 changes: 8 additions & 0 deletions preview-features.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
"platforms": [
"desktop"
]
},
{
"id": "eagerTimelineLayout",
"name": "Eager timeline layout",
"description": "Lay out every fetched message row immediately instead of skipping offscreen rows (content-visibility). Heights are exact from the start, so scrolling never triggers height re-realizations or position corrections mid-fling — at the cost of a one-time layout of the full loaded window.",
"platforms": [
"desktop"
]
}
]
}
Loading