perf(banner): smoother deep-link from banner to /browse post
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 27s

- MessageStream: drop the mount-time scroll lock and the 80ms-delayed
  custom rAF; engage the lock only while the smooth animation runs and
  use native scrollTo({behavior:'smooth'}) so the page never feels frozen
  during pagination and the easing is buttery.
- PublicLayout: fire the default /browse prefetch immediately on mount
  (banner / Home tile destination) so a fast tap hits a warm cache;
  popular / latest stay deferred to idle.
- FigmaBanner: prefetch the all-scope stream on mount and on pointerdown
  as safety nets, and ignore empty / '#' / javascript: link URLs so a
  contentless banner renders as a non-interactive image.
- usePostStream: dedupe in-flight prefetches by key so concurrent
  callers (layout + banner) collapse into a single network request.
This commit is contained in:
TerryM
2026-06-02 11:30:47 +08:00
parent fbb9d21f24
commit 7ed9f8c8bf
4 changed files with 75 additions and 53 deletions

View File

@@ -79,9 +79,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
? hash.slice("#post-".length)
: "";
const targetPostId = queryTargetPostId || hashTargetPostId;
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
Boolean(queryTargetPostId),
);
// Lock only engages while we are actively running the smooth-scroll animation
// — not during the wait/pagination phase — so the page never feels frozen
// before the bubble exists.
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(false);
const handledTargetRef = useRef<string>("");
const targetScrollTimersRef = useRef<number[]>([]);
const targetScrollFrameRef = useRef<number | null>(null);
@@ -100,7 +101,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
useEffect(() => {
handledTargetRef.current = "";
clearTargetScrollTimers();
setIsAligningQueryTarget(Boolean(queryTargetPostId));
setIsAligningQueryTarget(false);
}, [queryTargetPostId, targetPostId]);
useEffect(() => clearTargetScrollTimers, []);
@@ -168,52 +169,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
window.scrollTo({ top, left: 0, behavior });
};
const boundedSmoothScrollToTarget = () => {
const firstTarget = targetScrollTop();
if (firstTarget === null) return;
const start = window.scrollY;
const startedAt = performance.now();
const duration = 520;
const direction = firstTarget >= start ? 1 : -1;
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3);
const tick = (now: number) => {
const latestTarget = targetScrollTop();
if (latestTarget === null) return;
const progress = Math.min(1, (now - startedAt) / duration);
const ideal = start + (latestTarget - start) * easeOutCubic(progress);
const next =
direction >= 0
? Math.min(ideal, latestTarget)
: Math.max(ideal, latestTarget);
window.scrollTo({ top: next, left: 0, behavior: "auto" });
if (progress < 1) {
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
return;
}
scrollToTarget("auto");
setIsAligningQueryTarget(false);
targetScrollFrameRef.current = null;
};
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
};
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (queryTargetPostId) {
// Query deep-links (`?post=<id>`) usually come from Home cards/list
// rows. Keep the premium motion, but drive it ourselves so scrolling is
// bounded to the current target position and cannot visibly pass the
// target before snapping back. User-driven scroll is temporarily locked
// by the effect above; programmatic scroll remains allowed.
targetScrollTimersRef.current = [80].map((ms) =>
window.setTimeout(boundedSmoothScrollToTarget, ms),
// Query deep-links (`?post=<id>`) come from banner / Home card clicks.
// Use the browser-native smooth scroll (runs on the compositor, much
// smoother than a hand-rolled rAF) and lock user scroll only for the
// 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.
setIsAligningQueryTarget(true);
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
);
targetScrollTimersRef.current = [
window.setTimeout(() => scrollToTarget("auto"), 450),
window.setTimeout(() => scrollToTarget("auto"), 750),
window.setTimeout(() => {
scrollToTarget("auto");
setIsAligningQueryTarget(false);
}, 950),
];
} else {
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),

View File

@@ -164,15 +164,22 @@ function cacheFirstPage(
});
}
// In-flight prefetch keys so concurrent callers (PublicLayout + FigmaBanner +
// hover prefetch, etc.) collapse into a single network request instead of
// firing one per call site before the first response lands.
const inFlightPrefetches = new Set<string>();
/**
* Warm the cache for a stream view before the user navigates to it, so opening
* the page shows content immediately instead of starting to load on arrival.
* No-op for the mock backend or when the first page is already cached.
* No-op for the mock backend, when the first page is already cached, or when
* an identical prefetch is already in flight.
*/
export function prefetchPostStream(params: PostStreamParams): void {
if (USE_MOCK) return;
const key = streamKey(params);
if (readStreamCache(key)) return;
if (inFlightPrefetches.has(key)) return;
const url = buildRealUrl(params);
const cachedPage = readJSONCache<PostListResponse>(url);
@@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void {
return;
}
inFlightPrefetches.add(key);
getJSON<PostListResponse>(url)
.then((page) => cacheFirstPage(params, page))
.catch(() => {});
.catch(() => {})
.finally(() => inFlightPrefetches.delete(key));
}
export function usePostStream(params: PostStreamParams): PostStreamResult {