mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
feat(desktop): wire settleGate preview flag + synchronous at-rest re-anchor
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>
This commit is contained in:
parent
041d72a66c
commit
c4176a6e9d
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user