diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index fdc9679104..76ff1d692c 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -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";
@@ -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 ??
@@ -277,7 +284,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
)}
{group.items.map((item) => (
diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css
index 1dd7326e22..3a9bade842 100644
--- a/desktop/src/shared/styles/globals/utilities.css
+++ b/desktop/src/shared/styles/globals/utilities.css
@@ -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;
diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts
index c33d399d59..2cc2551034 100644
--- a/desktop/tests/e2e/timeline-no-shift.spec.ts
+++ b/desktop/tests/e2e/timeline-no-shift.spec.ts
@@ -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);
@@ -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 {
+ 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,
diff --git a/preview-features.json b/preview-features.json
index bc5f6d16c0..32f21398b9 100644
--- a/preview-features.json
+++ b/preview-features.json
@@ -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"
+ ]
}
]
}