From c4176a6e9d244523fda57ebc16924ee6c786d464 Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 11:08:54 -0400 Subject: [PATCH] feat(desktop): wire settleGate preview flag + synchronous at-rest re-anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the `settleGate` preview feature (preview-features.json → Settings → Experiments) and read it in MessageTimeline via `useFeatureEnabled`, passing `settleGate: { enabled }` to `useAnchoredScroll`. Default-off: the flag-off runtime path is unchanged from main — the mid-history prepend re-anchor runs immediately on the prepend commit, exactly as it always has. Gate-on, the re-anchor is now routed on live-motion at re-anchor time rather than deferred unconditionally: - At rest (no scroll event within SETTLE_MOTION_WINDOW_MS): `applyReanchor()` runs synchronously in the layout effect, main-identical — no frame ever paints at the displaced position. - Under a live fling: defer behind `observeScrollSettle` (k consecutive still frames), re-measuring drift at settle time. The still-vs-moving decision reads `lastScrollTsRef`, stamped synchronously in `onScroll`, so it costs zero deferred frames — a rAF probe would leak one painted frame at full prepend height at rest. SETTLE_MOTION_WINDOW_MS (~6 frames) sits above WebKit's ~2-frame coalesced freeze, consistent with the k>=3 clamp, so a coalesced gap can't misread a live fling as still. A newer prepend, channel switch, or unmount cancels any pending correction. This clears the gate-on behavior-contract tier symmetrically: the earlier defer-always version red-lined timeline-no-shift.spec.ts:429 at maxDrift 3400px (one painted frame at rest); the synchronous at-rest path takes it to 18px with no test change. Full timeline-no-shift contract file 5/5 green with the flag forced on by the e2e bridge. Unit tier green (7 observeScrollSettle cases incl. freeze-survival and k-clamp); tsc + biome clean. Ruling: Dawn on :429 (at rest, "never paints a displaced frame" is the behavior; synchronous-commit is main's means, not the assertion); ratified by Eva. The momentum-only deferral is real behavior but unexercised by any current top-tier test — a future mid-fling contract test gets the settle-sampled shape from birth rather than rewriting :429. Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- .../features/messages/ui/MessageTimeline.tsx | 9 +++ .../features/messages/ui/useAnchoredScroll.ts | 80 ++++++++++++++----- preview-features.json | 8 ++ 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e007e3f915..3ac17031cd 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -14,6 +14,7 @@ import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channel 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 { channelChrome } from "@/shared/layout/chromeLayout"; import { Spinner } from "@/shared/ui/spinner"; import { TooltipProvider } from "@/shared/ui/tooltip"; @@ -237,6 +238,13 @@ const MessageTimelineBase = React.forwardRef< }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; + // Preview feature (Settings → Experiments, default-off): when enabled, the + // mid-history prepend re-anchor is deferred behind the polled settle gate. + // Off ⇒ the re-anchor runs immediately on the prepend commit, exactly as on + // main. Read here (not inside the hook) so the switch stays a single, visible + // wiring point tied to the manifest id. + const settleGateEnabled = useFeatureEnabled("settleGate"); + const { highlightedMessageId, isAtBottom, @@ -253,6 +261,7 @@ const MessageTimelineBase = React.forwardRef< onTargetReached, scrollContainerRef, targetMessageId, + settleGate: { enabled: settleGateEnabled }, }); const timelineIntroSurface = selectTimelineIntroSurface({ diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 1bf77e3046..fb7f09c031 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -95,6 +95,20 @@ export type SettleGateOptions = { */ export const DEFAULT_SETTLE_QUIET_FRAMES = 3; +/** + * How recently a `scroll` event must have fired for the gate to treat the + * scroll as *in live motion* at layout-effect time. Live momentum emits a + * `scroll` event every frame (~16ms) continuously; a discrete one-shot + * `scrollTop` write emits a single event and then goes silent. A window a few + * frames wide cleanly separates "still fling" from "settled after a discrete + * jump" without a deferred sample — which is the requirement: the still-path + * must correct synchronously in the layout effect, paying zero painted frames + * of displacement, exactly as main does (Dawn's ruling on + * `timeline-no-shift.spec.ts:429`). A deferred rAF probe leaks one painted + * frame at full prepend height; a timestamp read does not. + */ +export const SETTLE_MOTION_WINDOW_MS = 100; + type UseAnchoredScrollResult = { /** Pass through to the scroll container's `onScroll`. */ onScroll: () => void; @@ -291,6 +305,13 @@ export function useAnchoredScroll({ // before its `onSettle` fires — otherwise a stale re-anchor could snap the // view after the user has already flung somewhere else. const settleObserverCancelRef = React.useRef<(() => void) | null>(null); + // Timestamp (ms) of the most recent `scroll` event, written in `onScroll`. + // The prepend re-anchor reads it synchronously at layout-effect time to + // decide still-vs-moving without a deferred sample (see + // `SETTLE_MOTION_WINDOW_MS`): a still scroll gets a synchronous, main-identical + // re-anchor (no displaced frame paints); only a live fling arms the async + // settle path. + const lastScrollTsRef = React.useRef(0); // Keep the latest settle-gate config in a ref so the layout effect can read // it without adding it to the dependency array (it must not re-run the // restoration logic just because the caller passed a new options object). @@ -421,6 +442,10 @@ export function useAnchoredScroll({ const onScroll = React.useCallback(() => { const container = scrollContainerRef.current; if (!container) return; + // Record when this scroll event fired so the prepend re-anchor can read + // live-motion state synchronously at layout-effect time (settle gate). + lastScrollTsRef.current = + typeof performance !== "undefined" ? performance.now() : Date.now(); // 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 @@ -562,28 +587,47 @@ export function useAnchoredScroll({ if (gate?.enabled) { // Settle gate on: the prepend re-anchor is the one mid-fling scroll // writer, and firing it against live user momentum is what produces the - // walk-blind jump. Defer it behind the polled quiet-window — apply the - // correction only once the scroll has been still for k consecutive - // frames (k clears WebKit's coalesced-freeze window; see - // `observeScrollSettle`). Supersede any correction still pending from a - // prior prepend so only the newest anchor is ever re-pinned. + // walk-blind jump. But deferral is only worth its cost under live + // momentum — when the reader is at rest there is no jerk to avoid, and + // deferring the correction (even by a single rAF) lets one frame paint + // at full prepend height, a visible jump the sampler catches (Dawn's + // ruling on `timeline-no-shift.spec.ts:429`). So decide still-vs-moving + // from a signal already known *here*, synchronously: the freshness of + // the last `scroll` event. Live momentum keeps `scroll` firing every + // frame; a discrete jump or a settled reader leaves the stamp stale. + // The window is a few frames wide so WebKit's ~2-frame coalesced freeze + // (the same physics the k≥3 clamp respects) cannot misread a live fling + // as still and fire the synchronous correction into real momentum. // - // A3 trade (stated in the PR body): this removes the mid-fling jerk by - // *deferring* the re-anchor to settle, which means the reading row can - // drift during the fling and snap back once at rest. The artifact the - // user feels moves from "jump mid-scroll" to "small settle-time - // adjustment"; it is a trade, not a free correction. + // Still ⇒ `applyReanchor()` inline, identical to main's synchronous + // commit — no displaced frame ever paints. Moving ⇒ defer behind the + // polled quiet window, superseding any correction still pending from a + // prior prepend so only the newest anchor is re-pinned. + // + // A3 trade (stated in the PR body): at rest the correction is + // synchronous and behaves exactly as main. Only under a live fling is + // the reading row held at full prepend height until the fling ends, + // then snapped back once at settle — confined to true flings, the only + // window the gate was ever meant to touch. + const now = + typeof performance !== "undefined" ? performance.now() : Date.now(); + const moving = now - lastScrollTsRef.current < SETTLE_MOTION_WINDOW_MS; if (settleObserverCancelRef.current !== null) { settleObserverCancelRef.current(); + settleObserverCancelRef.current = null; + } + if (moving) { + settleObserverCancelRef.current = observeScrollSettle( + container, + gate.quietFrames ?? DEFAULT_SETTLE_QUIET_FRAMES, + () => { + settleObserverCancelRef.current = null; + applyReanchor(); + }, + ); + } else { + applyReanchor(); } - settleObserverCancelRef.current = observeScrollSettle( - container, - gate.quietFrames ?? DEFAULT_SETTLE_QUIET_FRAMES, - () => { - settleObserverCancelRef.current = null; - applyReanchor(); - }, - ); } else { applyReanchor(); } diff --git a/preview-features.json b/preview-features.json index bc5f6d16c0..5840d7ef08 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,6 +40,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "settleGate", + "name": "Scroll settle gate", + "description": "Defer the mid-history re-anchor until scrolling settles (k consecutive still frames) instead of correcting mid-fling — smoother momentum scrolling on WebKit. At rest, identical to today; only under a live fling is the reading row held until the fling ends, then snapped once at settle.", + "platforms": [ + "desktop" + ] } ] }