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" + ] } ] }