feat(stream): hold deep-link scroll until first content reveals
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 28s
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:
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user