diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index e769f3c..19ebbbc 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -12,6 +12,7 @@ import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { EASE_OUT } from "../motion"; import { langQuery, useI18n, type Lang } from "../i18n"; import { localizePath, stripLangPrefix } from "../languageRoutes"; +import { prefetchPostStream } from "./messageStream/hooks/usePostStream"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; @@ -87,12 +88,17 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] { .slice(0, MAX_BANNERS) .map((item) => { 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 { id: String(item.id), mobile: imageUrl, desktop: imageUrl, alt: "", - linkUrl: item.linkUrl || undefined, + linkUrl, }; }); } @@ -138,6 +144,32 @@ export function FigmaBanner() { }; }, [lang]); + // Banner clicks land on /browse?post=. 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 scroller = scrollerRef.current; if (!scroller) return; @@ -217,6 +249,7 @@ export function FigmaBanner() { }, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]); const handlePointerDown = (event: ReactPointerEvent) => { + prefetchAllStream(); if (event.pointerType !== "mouse") return; const scroller = scrollerRef.current; if (!scroller) return; diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index c81bb6b..7cca259 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -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(""); const targetScrollTimersRef = useRef([]); const targetScrollFrameRef = useRef(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=`) 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=`) 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"), diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index e78f4c3..646de1f 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -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(); + /** * 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(url); @@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void { return; } + inFlightPrefetches.add(key); getJSON(url) .then((page) => cacheFirstPage(params, page)) - .catch(() => {}); + .catch(() => {}) + .finally(() => inFlightPrefetches.delete(key)); } export function usePostStream(params: PostStreamParams): PostStreamResult { diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 297b46c..180d51e 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -333,14 +333,16 @@ export function PublicLayout() { // Current page name shown in the header brand slot (falls back to the brand). const pageTitle = usePageTitle(); - // Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so - // tapping them shows content immediately. Run one at a time, spaced out and - // only while idle, so prefetching never competes with the current page or - // janks low-end phones. Prefetch is JSON-only (no images). + // Warm the common stream views (全部资料 / 热门资料 / 最新) so tapping them + // shows content immediately. The default "all" stream is the most common + // destination (banners, Home cards) and fires right on mount so a fast tap + // 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(() => { const base = { scope: { kind: "all" as const }, type: "all", q: "", lang }; + prefetchPostStream({ ...base, sort: "" }); + const jobs = [ - () => prefetchPostStream({ ...base, sort: "" }), () => prefetchPostStream({ ...base, sort: "popular" }), () => prefetchPostStream({ ...base, sort: "latest" }), ]; @@ -362,7 +364,7 @@ export function PublicLayout() { else stepTimer = window.setTimeout(runNext, 200); }; - const startTimer = window.setTimeout(schedule, 600); + const startTimer = window.setTimeout(schedule, 300); return () => { window.clearTimeout(startTimer); window.clearTimeout(stepTimer);