mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
The prior fallback fired `scrollTo(currentScrollTop + aboveShift)` whenever
the baseline-relative position diff read 0 and `|aboveShift| > 0.5`. On WebKit
that condition is not specific to a real reflow: the per-rAF baseline is
captured post-realization, so `baseline` and `current` straddle the SAME side
of the reflow (dTop=dScroll=0) on EVERY frame, and `aboveShift` — summed from
the RO entries — can be nonzero on a frame where the anchor never moved,
because a row straddling the anchor boundary is miscounted. The fallback then
fabricated a downward shove: the "Shape B" slow-trackpad lurch
(`e=0, rowMove=+35..41px, dScroll=0`) — firing the correction WAS the visible
jump.
Add a second, baseline-timing-independent signal and fire only when the two
instruments agree. Carry the anchor snapshot from the PREVIOUS rAF tick
(`prevReadingAnchorRef`), which — unlike the same-frame baseline — was captured
before this frame's realization and so spans the reflow. Its document-position
shift `observedShift = (scrollTop+topOffset) − prev(scrollTop+topOffset)` is
scroll-invariant (user scroll moves scrollTop and topOffset equal-and-opposite),
so it isolates the reflow alone (Eva's probe `e`, from one coherent snapshot;
Quinn's scroll-isolated `R_obs`). Fire only when `prev.id === baseline.id` AND
`|aboveShift − observedShift| ≤ 0.5`, and correct by `observedShift` (never raw
screen motion — that would refold the user's scroll into the pin).
Agreement is the classifier boundary. Two-population split of fallback frames
on the WebKit slow-trackpad probe:
population | aboveShift vs observedShift | old writer | gated writer
-------------------|-----------------------------|--------------|-------------
real reflow | agree (Δ ≤ 0.5px) | corrects OK | corrects OK
straddler miscount | disagree (Δ = row height) | fabricates | suppressed
| | +35..41 shove| (no write)
Suppressing the miscount branch removes the fabricated shoves without touching
the frames where the RO sum is genuine.
Validated (each run x2, stable):
- Shape-B fabricated `e=0` shoves: eliminated (was the dominant felt defect)
- Chromium slow-trackpad: 0 escapes, sync 0.00, felt 0.00 (no regression)
- WebKit felt/wheel gate: 0.91px peak (<< 2px threshold), was 0.00 pre-gate —
the 0.91 is the frame-late CV-realization flash the miscount was masking
- WebKit slow-trackpad escapes: 67 -> 54
- 2167/2167 unit tests, tsc, biome clean
Residual (54 escapes) is a different phenomenon with a different owner: ~15
self-correcting frame-late flashes + ~12 multi-frame-blind under-corrections,
both rooted in WebKit delivering the CV-realization RO callback one frame after
the realization paints (documented in
RESEARCH/WEBKIT_RO_REALIZATION_FRAME_LATE_W1.md; overturns W4a doc lines 96-98,
which assumed the RO write is same-frame-visible on WebKit). That is a W4a
architecture item (Quinn owns direction; Max's W2 pre-realization band is the
A/B candidate), not a W1 branch-logic defect.
Co-authored-by: Dawn <dawn@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
889 lines
41 KiB
TypeScript
889 lines
41 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
|
|
|
|
/**
|
|
* Distance (in CSS pixels) below which we consider the scroll position
|
|
* "at the bottom" of the message list. Tight enough that the user has to
|
|
* actually scroll down to re-pin; permissive enough to tolerate sub-pixel
|
|
* rounding from the layout engine.
|
|
*/
|
|
const AT_BOTTOM_THRESHOLD_PX = 32;
|
|
// Tests and user-visible "pinned" affordances need the view at the physical
|
|
// floor, not merely within the looser UI at-bottom threshold. The loose
|
|
// threshold decides whether the user is close enough to count as reading the
|
|
// latest message; this strict threshold decides when a programmatic bottom pin
|
|
// has actually finished settling.
|
|
const TRUE_BOTTOM_THRESHOLD_PX = 1;
|
|
// Realization compensation only runs when the reading anchor's captured scroll
|
|
// position is consistent with the frame the ResizeObserver fires in. Under
|
|
// WebKit async ("coordinated") scrolling the rAF baseline read and the RO
|
|
// post-layout read can straddle a compositor commit, so a fragment of the
|
|
// user's own momentum can leak into the measured shift. If the scroll moved
|
|
// more than this bound since the baseline was captured, momentum is clearly in
|
|
// flight and the two reads are not trustworthy together — we SKIP the
|
|
// correction rather than risk folding the wheel delta into the pin. Under-
|
|
// correcting a single realization is invisible (the next quiet frame catches
|
|
// it); fighting the wheel is the visible lurch. Chosen at roughly one frame of
|
|
// aggressive trackpad momentum; tune against the gate.
|
|
const COMPENSATION_SCROLL_SKIP_PX = 120;
|
|
// Distance below the scroller top at which the reading anchor is chosen. We
|
|
// pick the first row whose top sits at least this far below the viewport top
|
|
// rather than the first row past the top edge, so the anchor stays OUT of the
|
|
// freshly-exposed realization band hanging just under the fold during an
|
|
// upscroll. A row inside that band can re-measure to a garbage position when
|
|
// the ResizeObserver re-queries it mid-realization; a row a notch below the
|
|
// churn moves only by the net height change above it. Matches the probe's
|
|
// SAFE_MARGIN so the gate measures the same anchor the writer pins.
|
|
const READING_ANCHOR_SAFE_MARGIN_PX = 60;
|
|
|
|
type AnchorState =
|
|
| { kind: "at-bottom" }
|
|
| { kind: "message"; messageId: string; topOffset: number };
|
|
|
|
type BottomSettleContainer = Pick<
|
|
HTMLDivElement,
|
|
"scrollHeight" | "clientHeight" | "scrollTop" | "scrollTo"
|
|
>;
|
|
|
|
export function settleProgrammaticBottomPin(
|
|
container: BottomSettleContainer,
|
|
): boolean {
|
|
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
|
return isAtTrueBottom(container);
|
|
}
|
|
|
|
type UseAnchoredScrollOptions = {
|
|
/** Scroll container. Owned by the parent so external refs still compose. */
|
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
/** Inner content element — must wrap every renderable row, including the
|
|
* sentinel and bottom anchor. Used to schedule layout work on resize. */
|
|
contentRef: React.RefObject<HTMLDivElement | null>;
|
|
/** Resets when changed; lets us drop anchor + scroll state across channels. */
|
|
channelId?: string | null;
|
|
/** Suppresses initial scroll-to-bottom while a skeleton is showing. */
|
|
isLoading: boolean;
|
|
/** Source of truth for the rendered list. Used to detect new-at-bottom
|
|
* arrivals and to seed/refresh the anchor pre-render. */
|
|
messages: Array<{ id: string }>;
|
|
|
|
/** When set, scroll to and highlight this message on mount and on change. */
|
|
targetMessageId?: string | null;
|
|
onTargetReached?: (messageId: string) => void;
|
|
};
|
|
|
|
type UseAnchoredScrollResult = {
|
|
/** Pass through to the scroll container's `onScroll`. */
|
|
onScroll: () => void;
|
|
/** True when the user is within `AT_BOTTOM_THRESHOLD_PX` of the bottom. */
|
|
isAtBottom: boolean;
|
|
/** Number of new messages that have arrived while the user is not at the
|
|
* bottom. Cleared when the user returns to the bottom. */
|
|
newMessageCount: number;
|
|
/** Message id that should pulse a highlight (target/active-search). */
|
|
highlightedMessageId: string | null;
|
|
/** Imperative: scroll to bottom. */
|
|
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
/** Arm a one-shot scroll-to-bottom that fires on the next appended message
|
|
* (used by the composer's send flow). */
|
|
scrollToBottomOnNextUpdate: () => void;
|
|
/** Imperative: scroll a specific message into view; optionally pulse it.
|
|
* Returns true if the row was found and scrolled, false otherwise. */
|
|
scrollToMessage: (
|
|
messageId: string,
|
|
options?: { highlight?: boolean; behavior?: ScrollBehavior },
|
|
) => boolean;
|
|
};
|
|
|
|
function isAtBottomNow(
|
|
container: Pick<
|
|
HTMLDivElement,
|
|
"scrollHeight" | "clientHeight" | "scrollTop"
|
|
>,
|
|
) {
|
|
return (
|
|
container.scrollHeight - container.clientHeight - container.scrollTop <=
|
|
AT_BOTTOM_THRESHOLD_PX
|
|
);
|
|
}
|
|
|
|
function isAtTrueBottom(
|
|
container: Pick<
|
|
HTMLDivElement,
|
|
"scrollHeight" | "clientHeight" | "scrollTop"
|
|
>,
|
|
) {
|
|
return (
|
|
container.scrollHeight - container.clientHeight - container.scrollTop <=
|
|
TRUE_BOTTOM_THRESHOLD_PX
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pick an anchor for the current scroll position.
|
|
*
|
|
* Top-crossing walk: chronological children, top-down. The first
|
|
* `data-message-id` row whose bottom edge has crossed below the container
|
|
* top is the anchor — that's the row the reader's eye is on when they've
|
|
* scrolled up through history. `topOffset` is the row's top relative to
|
|
* the container's top and may be negative when the row straddles the edge.
|
|
*
|
|
* If no such row exists (e.g. nothing scrolled past the top, list shorter
|
|
* than the viewport, etc.) the anchor is `at-bottom`.
|
|
*
|
|
* Algorithm credit: Sami's [13] in the buzz-bugs scroll-redesign thread,
|
|
* supersedes the Matrix-style bottom-up walk in [7]. The top-crossing
|
|
* choice is what keeps the row the reader is *reading* fixed under
|
|
* in-viewport reflow (image-load, embed expansion).
|
|
*/
|
|
function computeAnchor(container: HTMLDivElement): AnchorState {
|
|
if (isAtBottomNow(container)) {
|
|
return { kind: "at-bottom" };
|
|
}
|
|
|
|
const containerTop = container.getBoundingClientRect().top;
|
|
const rows = container.querySelectorAll<HTMLElement>("[data-message-id]");
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i];
|
|
const rect = row.getBoundingClientRect();
|
|
if (rect.bottom > containerTop) {
|
|
const messageId = row.dataset.messageId;
|
|
if (messageId) {
|
|
return {
|
|
kind: "message",
|
|
messageId,
|
|
topOffset: rect.top - containerTop,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { kind: "at-bottom" };
|
|
}
|
|
|
|
/**
|
|
* Snapshot the reader's position for realization compensation: the first row
|
|
* FULLY inside the viewport (its top at/below the scroller top) and that row's
|
|
* top relative to the scroller top.
|
|
*
|
|
* Why *fully* visible and not the top-crossing straddler `computeAnchor` picks:
|
|
* the CSS scroll-anchoring spec descends past partially-visible candidates and
|
|
* anchors on the first fully-visible element for exactly the case we hit —
|
|
* during an upscroll the realizing band is the freshly exposed content hanging
|
|
* above the fold, so a straddler anchor sits *inside* that churning band and
|
|
* can't measure the shift below it. The first fully-visible row sits one notch
|
|
* below the churn, so re-pinning it to its saved offset cancels the net height
|
|
* change of everything above it (the layout engine sums those deltas for us —
|
|
* a row resizing below the anchor doesn't move the anchor's top, so it's
|
|
* excluded for free). We require the row's top to sit at least
|
|
* `READING_ANCHOR_SAFE_MARGIN_PX` below the scroller top so the anchor never
|
|
* sits inside the realization band itself. Returns null when no such row
|
|
* exists.
|
|
*
|
|
* We also capture `scrollTop` alongside the viewport-relative `topOffset` so
|
|
* compensation can be computed scroll-invariantly: the row's document position
|
|
* `scrollTop + topOffset` changes ONLY when content above it reflows — a user
|
|
* scroll moves `scrollTop` and `topOffset` by equal-and-opposite amounts and
|
|
* leaves the sum fixed. That decoupling is what makes the correction correct on
|
|
* WebKit even when the baseline is one frame stale relative to the user's live
|
|
* momentum: we compensate the reflow, never the wheel.
|
|
*/
|
|
function snapshotReadingAnchor(
|
|
container: HTMLDivElement,
|
|
): { id: string; topOffset: number; scrollTop: number } | null {
|
|
const containerTop = container.getBoundingClientRect().top;
|
|
const safeTop = containerTop + READING_ANCHOR_SAFE_MARGIN_PX;
|
|
const rows = container.querySelectorAll<HTMLElement>("[data-message-id]");
|
|
for (const row of rows) {
|
|
const rect = row.getBoundingClientRect();
|
|
if (rect.top >= safeTop) {
|
|
const id = row.dataset.messageId;
|
|
if (id)
|
|
return {
|
|
id,
|
|
topOffset: rect.top - containerTop,
|
|
scrollTop: container.scrollTop,
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The height the browser is CURRENTLY using to lay out `row`. For a
|
|
* `content-visibility: auto` row that has never painted this is its
|
|
* `contain-intrinsic-size` reserve (the estimate) rather than its realized
|
|
* height; for a row the browser has already realized-and-remembered (the `auto`
|
|
* keyword) it is that remembered size. We read the computed
|
|
* `contain-intrinsic-block-size` — Blink returns it as `"<n>px"` or
|
|
* `"auto <n>px"` — and fall back to the live box height if the property is
|
|
* empty/unsupported (e.g. WKWebView returning an empty string). Seeding the
|
|
* resize map with this value is what turns a realization into a MEASURABLE
|
|
* `realized - reserve` delta instead of an unmeasurable first sighting.
|
|
*/
|
|
function reservedRowHeight(row: HTMLElement): number {
|
|
const raw = getComputedStyle(row).containIntrinsicBlockSize;
|
|
const match = raw.match(/(-?\d+(?:\.\d+)?)px/);
|
|
if (match) return Number.parseFloat(match[1]);
|
|
return row.getBoundingClientRect().height;
|
|
}
|
|
|
|
/**
|
|
* Layout-shift compensation for the reading anchor, computed scroll-invariantly.
|
|
*
|
|
* Given the anchor row's document position (`scrollTop + topOffset`) at baseline
|
|
* and now, returns the absolute `scrollTop` the container should be written to
|
|
* so the row stays visually fixed across a reflow above it — WITHOUT folding in
|
|
* the user's own scroll motion since the baseline.
|
|
*
|
|
* The row's document position moves ONLY when content above it changes height:
|
|
* a user scroll changes `scrollTop` and `topOffset` by equal-and-opposite
|
|
* amounts and leaves the sum fixed. So `shift` (the reflow above the row) is the
|
|
* change in document position, and the corrected target is `currentScrollTop +
|
|
* shift`. When the user has purely scrolled (no reflow) the shift is 0 and the
|
|
* target equals the current position — the correction ignores the wheel.
|
|
*
|
|
* Returns `null` when the shift is within `epsilonPx` (nothing to correct).
|
|
*/
|
|
export function computeAnchorCorrection(
|
|
baseline: { topOffset: number; scrollTop: number },
|
|
current: { topOffset: number; scrollTop: number },
|
|
epsilonPx = 0.5,
|
|
): number | null {
|
|
const shift =
|
|
current.scrollTop +
|
|
current.topOffset -
|
|
(baseline.scrollTop + baseline.topOffset);
|
|
if (Math.abs(shift) <= epsilonPx) return null;
|
|
return current.scrollTop + shift;
|
|
}
|
|
|
|
export function useAnchoredScroll({
|
|
scrollContainerRef,
|
|
contentRef,
|
|
channelId,
|
|
isLoading,
|
|
messages,
|
|
|
|
targetMessageId = null,
|
|
onTargetReached,
|
|
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
|
|
// Anchor lives in a ref because it must survive renders and is updated
|
|
// both on scroll (commit-time read) and in the layout effect (post-render
|
|
// restoration). useState would force re-renders we don't want.
|
|
const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
|
|
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
|
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
|
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
|
string | null
|
|
>(null);
|
|
|
|
const hasInitializedRef = React.useRef(false);
|
|
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
|
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
|
|
const prevMessageCountRef = React.useRef(0);
|
|
const prevMessagesRef = React.useRef<Array<{ id: string }>>([]);
|
|
const handledTargetIdRef = React.useRef<string | null>(null);
|
|
const highlightTimeoutRef = React.useRef<number | null>(null);
|
|
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
|
|
// on channel switch (the channelId reset effect clears it).
|
|
const mountPinRafIdRef = React.useRef<number | null>(null);
|
|
// One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before
|
|
// it sends a message (see ChannelPane). When the user's own message then
|
|
// appends, we snap to bottom even if they had scrolled up to read history.
|
|
// Consumed (and cleared) by the next append in the restoration effect.
|
|
const forceBottomOnNextAppendRef = React.useRef(false);
|
|
// True from a programmatic bottom pin until the list's row measurement settles
|
|
// and the view reaches a true physical bottom. During this window `onScroll`
|
|
// ignores transient gaps and keeps chasing the floor. A `ref`, not state — the
|
|
// guard runs on a native scroll event, outside React's render cycle.
|
|
const settlingRef = React.useRef(false);
|
|
// Baseline for realization/reflow compensation: the first row fully inside
|
|
// the viewport (top at/below the scroller top), its top offset, and the
|
|
// scrollTop at capture. Sampled every rAF by a running loop while mid-history
|
|
// (see the rAF baseline effect) — NOT per-scroll-event, because scroll events
|
|
// dispatch async off WebKit's scrolling thread and would hand the RO a stale
|
|
// snapshot. rAF callbacks run in the frame's rendering steps before layout/RO
|
|
// delivery on every engine, so this baseline is the freshest pre-shift read
|
|
// cross-engine. The ResizeObserver re-pins THIS row using the scroll-invariant
|
|
// document-position delta (see the RO callback).
|
|
const readingAnchorRef = React.useRef<{
|
|
id: string;
|
|
topOffset: number;
|
|
scrollTop: number;
|
|
} | null>(null);
|
|
// The reading-anchor snapshot from the PREVIOUS rAF tick. On WebKit the
|
|
// current-frame snapshot is captured post-realization (blind to this frame's
|
|
// reflow), so the RO fallback diffs against this older, pre-realization read
|
|
// to recover the anchor's true document-position shift. Same shape as
|
|
// `readingAnchorRef`; the sampler shifts current->previous each frame.
|
|
const prevReadingAnchorRef = React.useRef<{
|
|
id: string;
|
|
topOffset: number;
|
|
scrollTop: number;
|
|
} | null>(null);
|
|
|
|
// Reset everything when the channel changes — the layout effect that runs
|
|
// immediately after this reset is responsible for either jumping to bottom
|
|
// or to the target message for the new channel.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we want this effect to fire exactly when the channel changes (and on mount).
|
|
React.useLayoutEffect(() => {
|
|
anchorRef.current = { kind: "at-bottom" };
|
|
setIsAtBottom(true);
|
|
setNewMessageCount(0);
|
|
setHighlightedMessageId(null);
|
|
hasInitializedRef.current = false;
|
|
prevLastMessageIdRef.current = undefined;
|
|
prevFirstMessageIdRef.current = undefined;
|
|
prevMessageCountRef.current = 0;
|
|
prevMessagesRef.current = [];
|
|
handledTargetIdRef.current = null;
|
|
forceBottomOnNextAppendRef.current = false;
|
|
settlingRef.current = false;
|
|
if (highlightTimeoutRef.current !== null) {
|
|
window.clearTimeout(highlightTimeoutRef.current);
|
|
highlightTimeoutRef.current = null;
|
|
}
|
|
if (mountPinRafIdRef.current !== null) {
|
|
cancelAnimationFrame(mountPinRafIdRef.current);
|
|
mountPinRafIdRef.current = null;
|
|
}
|
|
}, [channelId]);
|
|
|
|
const scrollToBottomImperative = React.useCallback(
|
|
(behavior: ScrollBehavior = "auto") => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
anchorRef.current = { kind: "at-bottom" };
|
|
// A programmatic jump-to-bottom is not atomic, even for `behavior: "auto"`:
|
|
// the browser can emit `scroll` while the list is still settling row
|
|
// measurements. During that window `computeAnchor` may read the transient
|
|
// gap as a deliberate scroll-up and latch a mid-history message anchor,
|
|
// which strands future appends above the floor. Arm the settle guard for
|
|
// every imperative bottom jump so `onScroll` holds the at-bottom anchor
|
|
// until it can snap to the true floor.
|
|
settlingRef.current = true;
|
|
container.scrollTo({ top: container.scrollHeight, behavior });
|
|
setIsAtBottom(true);
|
|
setNewMessageCount(0);
|
|
},
|
|
[scrollContainerRef],
|
|
);
|
|
|
|
// Arm a one-shot: the next append snaps to bottom regardless of where the
|
|
// user is. The consumer calls this right before sending so their own
|
|
// outbound message pulls the view down even if they'd scrolled up.
|
|
const scrollToBottomOnNextUpdate = React.useCallback(() => {
|
|
forceBottomOnNextAppendRef.current = true;
|
|
}, []);
|
|
|
|
const scrollToMessageImperative = React.useCallback(
|
|
(
|
|
messageId: string,
|
|
options: { highlight?: boolean; behavior?: ScrollBehavior } = {},
|
|
): boolean => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return false;
|
|
const el = container.querySelector<HTMLElement>(
|
|
`[data-message-id="${messageId}"]`,
|
|
);
|
|
if (!el) return false;
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const containerRect = container.getBoundingClientRect();
|
|
const currentTopOffset = rect.top - containerRect.top;
|
|
const centeredTopOffset = (container.clientHeight - rect.height) / 2;
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
container.scrollHeight - container.clientHeight,
|
|
);
|
|
const targetScrollTop = Math.min(
|
|
maxScrollTop,
|
|
Math.max(0, container.scrollTop + currentTopOffset - centeredTopOffset),
|
|
);
|
|
const targetTopOffset =
|
|
currentTopOffset - (targetScrollTop - container.scrollTop);
|
|
|
|
container.scrollTo({
|
|
top: targetScrollTop,
|
|
behavior: options.behavior ?? "auto",
|
|
});
|
|
|
|
// Smooth scrolling starts an async animation, so measuring after the call can still return the pre-animation position.
|
|
// Save the clamped destination offset instead; otherwise a concurrent
|
|
// render/ResizeObserver restore can fight the smooth scroll back toward
|
|
// where it started.
|
|
anchorRef.current = {
|
|
kind: "message",
|
|
messageId,
|
|
topOffset: targetTopOffset,
|
|
};
|
|
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
|
|
|
|
if (options.highlight) {
|
|
if (highlightTimeoutRef.current !== null) {
|
|
window.clearTimeout(highlightTimeoutRef.current);
|
|
}
|
|
setHighlightedMessageId(messageId);
|
|
highlightTimeoutRef.current = window.setTimeout(() => {
|
|
setHighlightedMessageId((current) =>
|
|
current === messageId ? null : current,
|
|
);
|
|
highlightTimeoutRef.current = null;
|
|
}, 2_000);
|
|
}
|
|
return true;
|
|
},
|
|
[scrollContainerRef],
|
|
);
|
|
|
|
// Scroll handler: recompute anchor + bottom state from the current
|
|
// scroll position. Cheap enough to run on every scroll event — a single
|
|
// `getBoundingClientRect` walk plus rect reads.
|
|
const onScroll = React.useCallback(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
// 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
|
|
// scroll-up and latch a message anchor, freezing the view short of bottom.
|
|
// While settling, keep the anchor at-bottom and chase the physical floor.
|
|
if (settlingRef.current) {
|
|
if (settleProgrammaticBottomPin(container)) {
|
|
settlingRef.current = false;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
anchorRef.current = computeAnchor(container);
|
|
const atBottom = anchorRef.current.kind === "at-bottom";
|
|
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
|
|
if (atBottom) {
|
|
setNewMessageCount(0);
|
|
}
|
|
}, [scrollContainerRef]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Anchor restoration: after every render, stick to the bottom if the user is
|
|
// there. The reading position across prepend / in-viewport reflow is held by
|
|
// the browser's native scroll anchoring (overflow-anchor) now that every
|
|
// loaded row stays in the DOM, so there is no JS message-anchor restore.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
React.useLayoutEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
// First render after a reset (channel switch or initial mount): jump
|
|
// to the requested target message, or to the bottom by default.
|
|
if (!hasInitializedRef.current) {
|
|
if (isLoading) return;
|
|
// Defer the scroll out of the layout effect so the current paint commits
|
|
// first; cancelled on channel switch via the reset effect's rAF guard.
|
|
const pinToBottomOnMount = () => {
|
|
anchorRef.current = { kind: "at-bottom" };
|
|
mountPinRafIdRef.current = requestAnimationFrame(() => {
|
|
mountPinRafIdRef.current = null;
|
|
scrollToBottomImperative("auto");
|
|
});
|
|
};
|
|
if (targetMessageId) {
|
|
// A cold deep-link target may not be in the DOM on this first
|
|
// commit — the route screen fetches it by id and splices it in a
|
|
// render or two later. If centering fails now, leave the timeline at
|
|
// its default position and let the post-mount target effect (keyed on
|
|
// `messages`) retry once the row lands, rather than marking it handled.
|
|
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
|
|
handledTargetIdRef.current = targetMessageId;
|
|
onTargetReached?.(targetMessageId);
|
|
} else {
|
|
pinToBottomOnMount();
|
|
}
|
|
} else {
|
|
pinToBottomOnMount();
|
|
}
|
|
hasInitializedRef.current = true;
|
|
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
|
|
prevFirstMessageIdRef.current = messages[0]?.id;
|
|
prevMessageCountRef.current = messages.length;
|
|
prevMessagesRef.current = messages;
|
|
return;
|
|
}
|
|
|
|
const anchor = anchorRef.current;
|
|
const lastMessage = messages[messages.length - 1];
|
|
const firstMessage = messages[0];
|
|
const prevLastId = prevLastMessageIdRef.current;
|
|
const prevCount = prevMessageCountRef.current;
|
|
const newLatestArrived =
|
|
lastMessage !== undefined && lastMessage.id !== prevLastId;
|
|
// Count growth, not tail-id change, is the reliable "messages arrived"
|
|
// signal. The relay can deliver a message that sorts ahead of an existing
|
|
// same-second row, so the list grows without the *last* id changing —
|
|
// `newLatestArrived` misses that case and the unread counter never bumps.
|
|
const prevMessages = prevMessagesRef.current;
|
|
const messagesArrived = messages.length - prevCount;
|
|
const isPrepend =
|
|
classifyTimelineMessageDelta({
|
|
current: messages,
|
|
previous: prevMessages,
|
|
}) === "prepend";
|
|
|
|
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
|
|
// resulting append lands, snap to bottom regardless of the current anchor,
|
|
// then clear the flag. Bail before the anchored branch so the user's own
|
|
// message pulls the view down.
|
|
if (newLatestArrived && forceBottomOnNextAppendRef.current) {
|
|
forceBottomOnNextAppendRef.current = false;
|
|
anchorRef.current = { kind: "at-bottom" };
|
|
settlingRef.current = true;
|
|
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
|
setIsAtBottom(true);
|
|
setNewMessageCount(0);
|
|
prevLastMessageIdRef.current = lastMessage?.id;
|
|
prevFirstMessageIdRef.current = firstMessage?.id;
|
|
prevMessageCountRef.current = messages.length;
|
|
prevMessagesRef.current = messages;
|
|
return;
|
|
}
|
|
|
|
if (anchor.kind === "at-bottom") {
|
|
// Stick to bottom across the append.
|
|
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
|
if (newLatestArrived) setNewMessageCount(0);
|
|
} else if (messagesArrived > 0) {
|
|
// Anchored mid-history. An older-history prepend grows the content above
|
|
// the reading row; the browser's native scroll anchoring does NOT correct
|
|
// this at the top edge (no anchor node above the viewport when scrollTop
|
|
// is ~0), so re-pin the anchored row to its saved offset by id. This is
|
|
// the single scroll writer for the prepend — the load-older observer only
|
|
// triggers the fetch. We run it in this post-commit layout effect (not the
|
|
// observer's promise callback) because the prepended rows commit on a
|
|
// deferred snapshot a few frames later, so the row's true position is only
|
|
// known here.
|
|
const row = container.querySelector<HTMLElement>(
|
|
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
|
|
);
|
|
if (row) {
|
|
const currentTopOffset =
|
|
row.getBoundingClientRect().top -
|
|
container.getBoundingClientRect().top;
|
|
const drift = currentTopOffset - anchor.topOffset;
|
|
if (Math.abs(drift) > 0.5) {
|
|
container.scrollBy(0, drift);
|
|
}
|
|
}
|
|
if (!isPrepend) {
|
|
setNewMessageCount((current) => current + messagesArrived);
|
|
}
|
|
}
|
|
|
|
prevLastMessageIdRef.current = lastMessage?.id;
|
|
prevFirstMessageIdRef.current = firstMessage?.id;
|
|
prevMessageCountRef.current = messages.length;
|
|
prevMessagesRef.current = messages;
|
|
}, [
|
|
isLoading,
|
|
messages,
|
|
onTargetReached,
|
|
scrollContainerRef,
|
|
scrollToBottomImperative,
|
|
scrollToMessageImperative,
|
|
targetMessageId,
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content resize: a height change React isn't driving — a bottom-pinned
|
|
// in-viewport reflow (image decode, embed expand, late font), OR an
|
|
// off-screen row above the reading position realizing to its true height as
|
|
// the user scrolls up into it (content-visibility skip -> visible). Both grow
|
|
// `scrollHeight` without a `messages` change, so the layout effect doesn't
|
|
// fire. The ResizeObserver callback runs in the rendering steps AFTER layout
|
|
// and BEFORE paint, which makes a `scrollBy` here same-frame invisible — this
|
|
// is the correct trigger for compensation, not the async-dispatched
|
|
// `contentvisibilityautostatechange` event (which may fire after the shifted
|
|
// frame has already painted).
|
|
//
|
|
// - at-bottom: re-pin to the new floor to stay glued.
|
|
// - mid-history: `overflow-anchor: none` is set on the scroller and the
|
|
// shipped WKWebView has no native anchoring anyway, so nothing holds the
|
|
// reading row across the reflow/realization — our writer must. This is
|
|
// the fix for the up-scroll jitter AND the latent reflow bug (both were
|
|
// previously left to a native anchoring that does not run here).
|
|
//
|
|
// Freshness (single-writer + no-fighting-the-wheel): `anchorRef.current` is
|
|
// re-baselined by `onScroll` every scroll event, and scroll events are
|
|
// dispatched earlier in the same frame's rendering steps than ResizeObserver
|
|
// delivery — so when a realization RO fires mid-gesture, the anchor's saved
|
|
// offset already reflects the user's current scroll position, and the drift
|
|
// we measure is purely the layout shift above the reading row, not the user's
|
|
// own wheel delta. We skip while settling a programmatic bottom pin so this
|
|
// never races the floor-chase in `onScroll`.
|
|
// ---------------------------------------------------------------------------
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional re-sync trigger — on each committed render we (re)observe any newly-mounted `.timeline-row-cv` rows so a row appended by a load-older page starts being watched. The callback reads only stable refs; `channelId` forces a full re-subscribe when the keyed scroll container remounts.
|
|
React.useEffect(() => {
|
|
const content = contentRef.current;
|
|
if (!content || typeof ResizeObserver === "undefined") return;
|
|
// Last-known laid-out height per observed row. The compensable delta of a
|
|
// resize is `newHeight - lastHeight`. We SEED each row at observe time with
|
|
// the height the browser is currently using for layout — for a
|
|
// `content-visibility: auto` row that has never painted, that is its
|
|
// `contain-intrinsic-size` reserve (the estimate), NOT its realized height
|
|
// (which the row does not report until it realizes). Seeding with the
|
|
// reserve is what makes the very first RO delivery — the realization itself
|
|
// — yield the true `realized - reserve` delta instead of being silently
|
|
// swallowed as an unmeasurable first sighting.
|
|
const lastHeights = new WeakMap<Element, number>();
|
|
const observer = new ResizeObserver((entries) => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
// A programmatic bottom pin is still settling; `onScroll` owns the
|
|
// floor-chase, so stay out of its way and don't double-write.
|
|
if (settlingRef.current) return;
|
|
// Bottom vs mid-history is decided by SYNCHRONOUS geometry, not
|
|
// `anchorRef.current.kind` (scroll-event-maintained → stale under WebKit
|
|
// momentum). `isAtBottomNow` reads live scroll metrics, and it matches the
|
|
// exact signal the rAF baseline sampler uses to decide whether a reading
|
|
// anchor exists — so the branch here and the baseline can't disagree.
|
|
if (isAtBottomNow(container)) {
|
|
// Bottom-glue: a row realizing/reflowing while pinned grows the content,
|
|
// so re-pin to the new floor to stay glued, and refresh the height map
|
|
// so we don't treat this growth as a mid-history delta later.
|
|
for (const entry of entries) {
|
|
lastHeights.set(
|
|
entry.target,
|
|
entry.target.getBoundingClientRect().height,
|
|
);
|
|
}
|
|
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
|
return;
|
|
}
|
|
// Mid-history: a batch of rows realized/reflowed this frame. The reading
|
|
// anchor — the first row a safe margin below the fold, snapshotted by the
|
|
// per-rAF baseline sampler — has shifted by the net height change of
|
|
// everything above it, and must be re-pinned to its saved offset so it
|
|
// stays visually fixed. This is the single scroll writer for realization;
|
|
// `overflow-anchor: none` (and WKWebView's absence of it) mean nothing
|
|
// else competes. We use the RO batch as the TRIGGER — a row's laid-out
|
|
// height actually changed (seeded from the reserve so a first-realization
|
|
// delivery counts, not swallowed as a first sighting) — and correct via
|
|
// one of two engine-order-independent signals computed below.
|
|
let changed = false;
|
|
let aboveShift = 0;
|
|
const anchorForShift = readingAnchorRef.current;
|
|
const anchorRowForShift = anchorForShift
|
|
? container.querySelector<HTMLElement>(
|
|
`[data-message-id="${CSS.escape(anchorForShift.id)}"]`,
|
|
)
|
|
: null;
|
|
const anchorTopForShift = anchorRowForShift
|
|
? anchorRowForShift.getBoundingClientRect().top
|
|
: Number.POSITIVE_INFINITY;
|
|
for (const entry of entries) {
|
|
const row = entry.target as HTMLElement;
|
|
const rect = row.getBoundingClientRect();
|
|
const height = rect.height;
|
|
const last = lastHeights.get(row);
|
|
lastHeights.set(row, height);
|
|
if (last === undefined) continue; // never seeded (defensive).
|
|
if (Math.abs(height - last) > 0.5) changed = true;
|
|
// A row that grew/shrank ABOVE the anchor shifts the anchor by its
|
|
// height delta. Rows at/below the anchor don't move it.
|
|
if (rect.top < anchorTopForShift) aboveShift += height - last;
|
|
}
|
|
if (!changed) return;
|
|
const baseline = readingAnchorRef.current;
|
|
if (!baseline) return; // no stable pre-batch snapshot: skip, don't drift.
|
|
const anchorRow = container.querySelector<HTMLElement>(
|
|
`[data-message-id="${CSS.escape(baseline.id)}"]`,
|
|
);
|
|
if (!anchorRow) return;
|
|
const containerTop = container.getBoundingClientRect().top;
|
|
const currentScrollTop = container.scrollTop;
|
|
// Staleness skip. The baseline was captured in this frame's rAF (pre-
|
|
// layout), but under async scrolling a compositor commit can land between
|
|
// that read and this post-layout read. A large scroll delta since
|
|
// baseline means momentum is in flight and the two reads may not describe
|
|
// one coherent state — skip rather than fold the wheel into the pin.
|
|
if (
|
|
Math.abs(currentScrollTop - baseline.scrollTop) >
|
|
COMPENSATION_SCROLL_SKIP_PX
|
|
) {
|
|
return;
|
|
}
|
|
const currentTopOffset =
|
|
anchorRow.getBoundingClientRect().top - containerTop;
|
|
// Scroll-invariant correction: isolates the reflow above the reading row
|
|
// from the user's own scroll motion since baseline (see
|
|
// `computeAnchorCorrection`). Reads are taken as late as possible, here in
|
|
// the post-layout RO callback, and the write is absolute — no compounding
|
|
// of a stale `scrollTop` read against a newer committed offset.
|
|
const target = computeAnchorCorrection(baseline, {
|
|
topOffset: currentTopOffset,
|
|
scrollTop: currentScrollTop,
|
|
});
|
|
if (target !== null) {
|
|
// The anchor's own document position moved (Chromium: the rAF baseline
|
|
// is captured pre-reflow, so this fires and measures the exact shift).
|
|
container.scrollTo({ top: target, behavior: "auto" });
|
|
} else if (Math.abs(aboveShift) > 0.5) {
|
|
// The baseline-relative position diff is blind. On WebKit the rAF
|
|
// baseline is captured post-realization, so `baseline` and `current`
|
|
// sit on the SAME side of the reflow and the diff reads 0 — but that is
|
|
// ALSO what a frame with no genuine displacement reads, and `aboveShift`
|
|
// (summed from the RO entries) is not by itself enough to tell them
|
|
// apart: a row straddling the anchor boundary is misclassified, so
|
|
// `aboveShift` can be nonzero on a frame where the anchor did not move
|
|
// (the "Shape B" slow-trackpad lurch — firing it IS the visible jump).
|
|
//
|
|
// Sufficient signal: the anchor's own document-position shift measured
|
|
// independently of the RO entries, over the PREVIOUS rAF tick (which,
|
|
// unlike the same-frame baseline, was captured before this frame's
|
|
// realization, so it spans the reflow and is not blind). Document
|
|
// position (`scrollTop + topOffset`) is scroll-invariant: the user's own
|
|
// scroll moves `scrollTop` and `topOffset` equal-and-opposite, so this
|
|
// observed shift is the reflow alone — Eva's probe `e = rowMove +
|
|
// dScroll`, computed from one coherent prev-tick snapshot.
|
|
//
|
|
// Fire only when the two instruments AGREE (`aboveShift ≈ observed`):
|
|
// agreement is the sufficiency condition — both the RO sum and the
|
|
// independent geometry see the same reflow, so it is real (not a
|
|
// straddler miscount). Correct by the observed shift, never by raw
|
|
// on-screen motion (that would refold the user's scroll into the pin).
|
|
const prev = prevReadingAnchorRef.current;
|
|
if (prev && prev.id === baseline.id) {
|
|
const observedShift =
|
|
currentScrollTop +
|
|
currentTopOffset -
|
|
(prev.scrollTop + prev.topOffset);
|
|
if (Math.abs(aboveShift - observedShift) <= 0.5) {
|
|
container.scrollTo({
|
|
top: currentScrollTop + observedShift,
|
|
behavior: "auto",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
// Observe every timeline row (not the content wrapper): a
|
|
// `content-visibility: auto` row realizing to its true height is a resize
|
|
// of THAT row's box but does not reliably fire a ResizeObserver on the
|
|
// wrapper (Blink does not surface CV realization as an ancestor resize).
|
|
// The RO callback runs after layout, before paint, so the compensating
|
|
// scroll write is same-frame invisible.
|
|
for (const row of content.querySelectorAll<HTMLElement>(
|
|
".timeline-row-cv",
|
|
)) {
|
|
lastHeights.set(row, reservedRowHeight(row));
|
|
observer.observe(row);
|
|
}
|
|
return () => observer.disconnect();
|
|
}, [channelId, contentRef, scrollContainerRef, messages]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-rAF reading-anchor baseline. This is the single writer of
|
|
// `readingAnchorRef`, and the crux of the cross-engine fix. The RO
|
|
// compensation needs a "before the reflow" snapshot of the reading row; the
|
|
// natural place to take it — the scroll event — is wrong on WebKit, which
|
|
// dispatches scroll asynchronously off its scrolling thread, so under momentum
|
|
// the snapshot the RO consumes lags the live offset and the correction folds
|
|
// the user's own wheel delta into the pin (the 204px lurch). rAF callbacks
|
|
// run in every engine's frame rendering steps BEFORE style/layout and RO
|
|
// delivery, so a baseline captured here is the freshest possible pre-reflow
|
|
// read on both engines, decoupled from scroll-event timing entirely.
|
|
//
|
|
// Mid-history is derived from SYNCHRONOUS geometry every frame
|
|
// (`isAtBottomNow`), NOT from `anchorRef.current.kind`. `anchorRef` is
|
|
// maintained by the scroll event — the very thing that goes stale on WebKit
|
|
// under momentum — so gating on it would clear the baseline during exactly
|
|
// the frames the sampler exists to protect. Reading live scroll geometry is
|
|
// immune to scroll-event staleness. When at-bottom we clear the baseline
|
|
// (bottom-glue owns that path); while a programmatic bottom pin is settling
|
|
// we hold off — `onScroll` owns that window.
|
|
//
|
|
// No `channelId` dep: the loop reads `scrollContainerRef.current` fresh every
|
|
// frame, so it re-binds to the new scroller on channel switch on its own.
|
|
React.useEffect(() => {
|
|
let rafId = requestAnimationFrame(function sample() {
|
|
const container = scrollContainerRef.current;
|
|
if (container && !settlingRef.current) {
|
|
// Shift the current snapshot into the previous slot BEFORE overwriting
|
|
// it, so the RO callback (which runs after this rAF in the same frame)
|
|
// can diff the anchor's document position against the PREVIOUS frame's
|
|
// pre-realization read — the only rAF snapshot old enough to span this
|
|
// frame's reflow on WebKit. The same-frame snapshot below is captured
|
|
// post-realization and is blind to it.
|
|
prevReadingAnchorRef.current = readingAnchorRef.current;
|
|
readingAnchorRef.current = isAtBottomNow(container)
|
|
? null
|
|
: snapshotReadingAnchor(container);
|
|
}
|
|
rafId = requestAnimationFrame(sample);
|
|
});
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [scrollContainerRef]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
|
|
// the initial-mount target above — this handles changes after the first
|
|
// render.
|
|
//
|
|
// A deep-link target may live in older history that isn't in the DOM when
|
|
// the route param first changes. The route screen fetches the target event
|
|
// by id and splices it into `messages` asynchronously, so its row appears a
|
|
// render or two later. We therefore key this effect on `messages` and bail
|
|
// *without* marking the target handled until its row actually exists — each
|
|
// subsequent message commit re-runs the effect and retries the centering.
|
|
// ---------------------------------------------------------------------------
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
|
|
React.useEffect(() => {
|
|
if (!targetMessageId) {
|
|
handledTargetIdRef.current = null;
|
|
return;
|
|
}
|
|
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
|
|
if (!hasInitializedRef.current) return; // initial-mount path will handle.
|
|
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
const el = container.querySelector<HTMLElement>(
|
|
`[data-message-id="${targetMessageId}"]`,
|
|
);
|
|
if (!el) {
|
|
// Row not in the DOM yet. A cold deep-link target is fetched by id and
|
|
// spliced into `messages` a render or two later; this effect re-runs on
|
|
// each `messages` commit and retries until the row exists.
|
|
return;
|
|
}
|
|
handledTargetIdRef.current = targetMessageId;
|
|
scrollToMessageImperative(targetMessageId, { highlight: true });
|
|
onTargetReached?.(targetMessageId);
|
|
}, [
|
|
isLoading,
|
|
messages,
|
|
onTargetReached,
|
|
scrollContainerRef,
|
|
scrollToMessageImperative,
|
|
targetMessageId,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (highlightTimeoutRef.current !== null) {
|
|
window.clearTimeout(highlightTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
onScroll,
|
|
isAtBottom,
|
|
newMessageCount,
|
|
highlightedMessageId,
|
|
scrollToBottom: scrollToBottomImperative,
|
|
scrollToBottomOnNextUpdate,
|
|
scrollToMessage: scrollToMessageImperative,
|
|
};
|
|
}
|