From 33d10c7510a34b644f59aad8d070e091924abd7c Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 12:20:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20eagerTimelineLayout=20preview?= =?UTF-8?q?=20=E2=80=94=20lay=20out=20every=20fetched=20row=20immediately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetched pages already mount fully into the DOM (no virtualizer since #1338; the markdown parse is paid eagerly at the deferred commit from #1022). But each row wears content-visibility:auto with a heuristic contain-intrinsic-size reserve, so offscreen rows skip layout until they scroll near the viewport — and since the estimate never exactly matches the true height, every realization during a fling changes scrollHeight and forces useAnchoredScroll to compensate mid-momentum. That estimate→true re-realization is the root the reserve heuristic only approximates away. This flag removes the root: with eagerTimelineLayout on, timeline rows opt out of content-visibility skipping entirely. The full fetched window lays out once, at exact heights, at commit time; scrolling through already-loaded history triggers zero height re-realizations and zero anchored-scroll corrections. Cost is a one-time layout of the loaded window (~50 rows/page) instead of pay-as-you-scroll. Mechanics: - preview-features.json: new eagerTimelineLayout entry (desktop), toggled in Settings → Experiments like the other preview lanes. - TimelineMessageList: rows add timeline-row-cv-eager when the flag is on. The reserve style stays on the wrapper — inert without size containment — so flag-off behavior is byte-identical to main. - utilities.css: .timeline-row-cv.timeline-row-cv-eager flips content-visibility to visible. - timeline-no-shift e2e: the wiring guard now asserts the eager path (the e2e bridge opts every preview feature in), and a new sibling spec with seedPreviewFeatures:false pins the default auto path. Portable classifier (sami/portable-classifier 5e5bd4a5, advisory per arc law — Tyler's trackpad judges): baseline at this tip scores 19 post-momentum bites @60px max (chromium) / 22 @60px (webkit); flag-ON scores 0 bites / 0.0px max on both engines, stable across repeat runs, with healthy momentum-frame populations (~226-238). Verified: typecheck, biome, 2217 unit tests, timeline-no-shift 6/6, scroll-history + overscroll-boundary + channel-window-mock-paging + live-broadcast-reply-timeline 20/20, messaging 31/31. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../messages/ui/TimelineMessageList.tsx | 12 +++++- .../src/shared/styles/globals/utilities.css | 9 ++++ desktop/tests/e2e/timeline-no-shift.spec.ts | 43 ++++++++++++++++--- preview-features.json | 8 ++++ 4 files changed, 64 insertions(+), 8 deletions(-) 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" + ] } ] }