perf(banner): smoother deep-link from banner to /browse post
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 27s
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:
@@ -12,6 +12,7 @@ import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
|||||||
import { EASE_OUT } from "../motion";
|
import { EASE_OUT } from "../motion";
|
||||||
import { langQuery, useI18n, type Lang } from "../i18n";
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
||||||
import { localizePath, stripLangPrefix } from "../languageRoutes";
|
import { localizePath, stripLangPrefix } from "../languageRoutes";
|
||||||
|
import { prefetchPostStream } from "./messageStream/hooks/usePostStream";
|
||||||
|
|
||||||
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
||||||
|
|
||||||
@@ -87,12 +88,17 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] {
|
|||||||
.slice(0, MAX_BANNERS)
|
.slice(0, MAX_BANNERS)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const imageUrl = assetUrl(item.imageUrl);
|
const imageUrl = assetUrl(item.imageUrl);
|
||||||
|
const raw = (item.linkUrl ?? "").trim();
|
||||||
|
// Treat empty, placeholder, or javascript: URLs as "no link" so the
|
||||||
|
// slide renders as a non-interactive image rather than a dead anchor.
|
||||||
|
const linkUrl =
|
||||||
|
raw && raw !== "#" && !/^javascript:/i.test(raw) ? raw : undefined;
|
||||||
return {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
mobile: imageUrl,
|
mobile: imageUrl,
|
||||||
desktop: imageUrl,
|
desktop: imageUrl,
|
||||||
alt: "",
|
alt: "",
|
||||||
linkUrl: item.linkUrl || undefined,
|
linkUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -138,6 +144,32 @@ export function FigmaBanner() {
|
|||||||
};
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
|
// Banner clicks land on /browse?post=<id>. Warm the all-scope stream cache
|
||||||
|
// the moment the banner is on screen so the destination renders instantly
|
||||||
|
// instead of showing a skeleton / loading state before scrolling to the post.
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchPostStream({
|
||||||
|
scope: { kind: "all" },
|
||||||
|
type: "all",
|
||||||
|
q: "",
|
||||||
|
sort: "",
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
// Safety net: if the user taps quickly before mount-prefetch completes,
|
||||||
|
// pointerdown re-issues the prefetch. Same-key requests are deduped by the
|
||||||
|
// stream cache, so this is free when the warm-up already ran.
|
||||||
|
const prefetchAllStream = useCallback(() => {
|
||||||
|
prefetchPostStream({
|
||||||
|
scope: { kind: "all" },
|
||||||
|
type: "all",
|
||||||
|
q: "",
|
||||||
|
sort: "",
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
@@ -217,6 +249,7 @@ export function FigmaBanner() {
|
|||||||
}, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]);
|
}, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]);
|
||||||
|
|
||||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
prefetchAllStream();
|
||||||
if (event.pointerType !== "mouse") return;
|
if (event.pointerType !== "mouse") return;
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
? hash.slice("#post-".length)
|
? hash.slice("#post-".length)
|
||||||
: "";
|
: "";
|
||||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||||
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
|
// Lock only engages while we are actively running the smooth-scroll animation
|
||||||
Boolean(queryTargetPostId),
|
// — 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 handledTargetRef = useRef<string>("");
|
||||||
const targetScrollTimersRef = useRef<number[]>([]);
|
const targetScrollTimersRef = useRef<number[]>([]);
|
||||||
const targetScrollFrameRef = useRef<number | null>(null);
|
const targetScrollFrameRef = useRef<number | null>(null);
|
||||||
@@ -100,7 +101,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handledTargetRef.current = "";
|
handledTargetRef.current = "";
|
||||||
clearTargetScrollTimers();
|
clearTargetScrollTimers();
|
||||||
setIsAligningQueryTarget(Boolean(queryTargetPostId));
|
setIsAligningQueryTarget(false);
|
||||||
}, [queryTargetPostId, targetPostId]);
|
}, [queryTargetPostId, targetPostId]);
|
||||||
|
|
||||||
useEffect(() => clearTargetScrollTimers, []);
|
useEffect(() => clearTargetScrollTimers, []);
|
||||||
@@ -168,52 +169,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
window.scrollTo({ top, left: 0, behavior });
|
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(
|
const prefersReducedMotion = window.matchMedia(
|
||||||
"(prefers-reduced-motion: reduce)",
|
"(prefers-reduced-motion: reduce)",
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
if (queryTargetPostId) {
|
if (queryTargetPostId) {
|
||||||
// Query deep-links (`?post=<id>`) usually come from Home cards/list
|
// Query deep-links (`?post=<id>`) come from banner / Home card clicks.
|
||||||
// rows. Keep the premium motion, but drive it ourselves so scrolling is
|
// Use the browser-native smooth scroll (runs on the compositor, much
|
||||||
// bounded to the current target position and cannot visibly pass the
|
// smoother than a hand-rolled rAF) and lock user scroll only for the
|
||||||
// target before snapping back. User-driven scroll is temporarily locked
|
// duration of the animation so the page never feels frozen. Quiet
|
||||||
// by the effect above; programmatic scroll remains allowed.
|
// re-alignments run inside the lock window to absorb late image-shift
|
||||||
targetScrollTimersRef.current = [80].map((ms) =>
|
// above the target; nothing nudges the user after the lock releases.
|
||||||
window.setTimeout(boundedSmoothScrollToTarget, ms),
|
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 {
|
} else {
|
||||||
window.requestAnimationFrame(() =>
|
window.requestAnimationFrame(() =>
|
||||||
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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 {
|
export function prefetchPostStream(params: PostStreamParams): void {
|
||||||
if (USE_MOCK) return;
|
if (USE_MOCK) return;
|
||||||
const key = streamKey(params);
|
const key = streamKey(params);
|
||||||
if (readStreamCache(key)) return;
|
if (readStreamCache(key)) return;
|
||||||
|
if (inFlightPrefetches.has(key)) return;
|
||||||
|
|
||||||
const url = buildRealUrl(params);
|
const url = buildRealUrl(params);
|
||||||
const cachedPage = readJSONCache<PostListResponse>(url);
|
const cachedPage = readJSONCache<PostListResponse>(url);
|
||||||
@@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inFlightPrefetches.add(key);
|
||||||
getJSON<PostListResponse>(url)
|
getJSON<PostListResponse>(url)
|
||||||
.then((page) => cacheFirstPage(params, page))
|
.then((page) => cacheFirstPage(params, page))
|
||||||
.catch(() => {});
|
.catch(() => {})
|
||||||
|
.finally(() => inFlightPrefetches.delete(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
||||||
|
|||||||
@@ -333,14 +333,16 @@ export function PublicLayout() {
|
|||||||
// Current page name shown in the header brand slot (falls back to the brand).
|
// Current page name shown in the header brand slot (falls back to the brand).
|
||||||
const pageTitle = usePageTitle();
|
const pageTitle = usePageTitle();
|
||||||
|
|
||||||
// Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so
|
// Warm the common stream views (全部资料 / 热门资料 / 最新) so tapping them
|
||||||
// tapping them shows content immediately. Run one at a time, spaced out and
|
// shows content immediately. The default "all" stream is the most common
|
||||||
// only while idle, so prefetching never competes with the current page or
|
// destination (banners, Home cards) and fires right on mount so a fast tap
|
||||||
// janks low-end phones. Prefetch is JSON-only (no images).
|
// still hits a warm cache. Popular / latest stay deferred to idle time so
|
||||||
|
// they don't compete with the current page on low-end phones.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const base = { scope: { kind: "all" as const }, type: "all", q: "", lang };
|
const base = { scope: { kind: "all" as const }, type: "all", q: "", lang };
|
||||||
|
prefetchPostStream({ ...base, sort: "" });
|
||||||
|
|
||||||
const jobs = [
|
const jobs = [
|
||||||
() => prefetchPostStream({ ...base, sort: "" }),
|
|
||||||
() => prefetchPostStream({ ...base, sort: "popular" }),
|
() => prefetchPostStream({ ...base, sort: "popular" }),
|
||||||
() => prefetchPostStream({ ...base, sort: "latest" }),
|
() => prefetchPostStream({ ...base, sort: "latest" }),
|
||||||
];
|
];
|
||||||
@@ -362,7 +364,7 @@ export function PublicLayout() {
|
|||||||
else stepTimer = window.setTimeout(runNext, 200);
|
else stepTimer = window.setTimeout(runNext, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTimer = window.setTimeout(schedule, 600);
|
const startTimer = window.setTimeout(schedule, 300);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(startTimer);
|
window.clearTimeout(startTimer);
|
||||||
window.clearTimeout(stepTimer);
|
window.clearTimeout(stepTimer);
|
||||||
|
|||||||
Reference in New Issue
Block a user