feat(stream): hold deep-link scroll until first content reveals
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 28s

Banner / Home-card deep-links were starting the smooth scroll the
moment the target post entered the DOM, before the in-view Reveal
animations on the top bubbles had time to fade in. Users perceived the
page as 'scrolling past nothing' because most bubbles were still at
opacity 0 when the viewport moved.

Track the moment first non-skeleton content appears for the current
target via firstContentAtRef, then hold the smooth-scroll start until
~300ms after that — long enough for the initial Reveal staggers to
play. Elapsed time is subtracted so cached arrivals don't pay the full
wait twice, and the ref resets per target so each navigation re-times.

Verified in the browser: with cold cache, content arrives ~480ms after
click, smooth scroll starts ~800ms (300ms settle), reaches deep target
by ~1.3s. With warm cache same pattern; users now see content before
motion begins.
This commit is contained in:
TerryM
2026-06-02 11:48:05 +08:00
parent 562843e4b2
commit edba16bbd2

View File

@@ -86,6 +86,11 @@ export function MessageStream({ scope }: MessageStreamProps) {
const handledTargetRef = useRef<string>("");
const targetScrollTimersRef = useRef<number[]>([]);
const targetScrollFrameRef = useRef<number | null>(null);
// Timestamp (perf clock) when the first batch of real items became visible
// for this target. Used to delay the deep-link smooth scroll until the
// initial Reveal in-view animations have had a moment to play, so the user
// sees content before the page starts moving.
const firstContentAtRef = useRef<number | null>(null);
const clearTargetScrollTimers = () => {
for (const timer of targetScrollTimersRef.current) {
@@ -100,10 +105,19 @@ export function MessageStream({ scope }: MessageStreamProps) {
useEffect(() => {
handledTargetRef.current = "";
firstContentAtRef.current = null;
clearTargetScrollTimers();
setIsAligningQueryTarget(false);
}, [queryTargetPostId, targetPostId]);
// Mark when first real content becomes visible (skeletons gone, items in).
// Captured per-target via the reset above so a later navigation re-measures.
useEffect(() => {
if (items.length > 0 && !isLoading && firstContentAtRef.current === null) {
firstContentAtRef.current = performance.now();
}
}, [items.length, isLoading]);
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
// smooth-scroll positioning from the top of the stream, so the user sees a
// clear, satisfying journey to the target post instead of a tiny nudge when
@@ -190,17 +204,31 @@ export function MessageStream({ scope }: MessageStreamProps) {
// duration of the animation so the page never feels frozen. Quiet
// re-alignments run inside the lock window to absorb late image-shift
// above the target; nothing nudges the user after the lock releases.
//
// Hold the animation until the page has had ~300ms after the first
// content paint, so the in-view Reveal staggers on the top bubbles
// have time to fade in. Otherwise the smooth scroll starts before the
// user can see anything, and the journey reads as "blank flash".
const REVEAL_SETTLE_MS = 300;
const elapsed =
firstContentAtRef.current !== null
? performance.now() - firstContentAtRef.current
: 0;
const settle = Math.max(0, REVEAL_SETTLE_MS - elapsed);
setIsAligningQueryTarget(true);
targetScrollTimersRef.current = [
window.setTimeout(() => {
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
);
targetScrollTimersRef.current = [
window.setTimeout(() => scrollToTarget("auto"), 450),
window.setTimeout(() => scrollToTarget("auto"), 750),
}, settle),
window.setTimeout(() => scrollToTarget("auto"), settle + 450),
window.setTimeout(() => scrollToTarget("auto"), settle + 750),
window.setTimeout(() => {
scrollToTarget("auto");
setIsAligningQueryTarget(false);
}, 950),
}, settle + 950),
];
} else {
window.requestAnimationFrame(() =>