From 985463b7da09a4fad2f21f099c4bf74d05da78b2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 01:40:21 +0800 Subject: [PATCH 1/5] fix(stream): resolve search deep-links without pagination stall Search results can link to older posts that are not present in the first /browse page. The previous deep-link flow kept paginating the all-assets stream until the target id appeared, leaving users stuck on the waiting indicator for very old posts. Fetch /api/posts/:id directly for ?post= arrivals and inject the resolved target post at the top of the stream when it is not already in loaded items. The normal paginated feed still loads below for context. Keep the explicit finding/not-found status messages as a fallback for slow or missing direct fetches. Verified with search result c5eeb17d-3bd0-4d32-9c92-5efa6e4a015c: target post rendered within 100ms instead of waiting for pagination. Checks: tsc, format:check, tests, build. --- .../messageStream/MessageStream.tsx | 131 ++++++++++++++++-- src/locales/en.ts | 3 + src/locales/id.ts | 4 + src/locales/ja.ts | 4 + src/locales/ko.ts | 4 + src/locales/ms.ts | 3 + src/locales/vi.ts | 3 + src/locales/zh-CN.ts | 2 + 8 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 8d38fef..d0f50da 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -1,8 +1,9 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; -import { postJSON } from "../../api"; -import { useI18n } from "../../i18n"; -import type { PostScope } from "../../types/post"; +import { LoaderCircle } from "lucide-react"; +import { getJSON, postJSON } from "../../api"; +import { langQuery, useI18n } from "../../i18n"; +import type { Post, PostScope } from "../../types/post"; import { Reveal } from "../../motion"; import { Skeleton } from "../Skeleton"; import { FilterChips } from "./FilterChips"; @@ -30,7 +31,6 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); - const groups = useGroupedByDay(items, lang); const retryLabel = t("retry"); const sentinelRef = useRef(null); @@ -79,6 +79,27 @@ export function MessageStream({ scope }: MessageStreamProps) { ? hash.slice("#post-".length) : ""; const targetPostId = queryTargetPostId || hashTargetPostId; + const [resolvedTargetPost, setResolvedTargetPost] = useState( + null, + ); + const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false); + const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false); + const targetAlreadyInBaseItems = useMemo( + () => + !!queryTargetPostId && + items.some((post) => post.id === queryTargetPostId), + [items, queryTargetPostId], + ); + const streamItems = useMemo(() => { + if ( + resolvedTargetPost && + !items.some((post) => post.id === resolvedTargetPost.id) + ) { + return [resolvedTargetPost, ...items]; + } + return items; + }, [items, resolvedTargetPost]); + const groups = useGroupedByDay(streamItems, lang); // 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. @@ -113,10 +134,14 @@ export function MessageStream({ scope }: MessageStreamProps) { // 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) { + if ( + streamItems.length > 0 && + !isLoading && + firstContentAtRef.current === null + ) { firstContentAtRef.current = performance.now(); } - }, [items.length, isLoading]); + }, [streamItems.length, isLoading]); // Banner / deep-link arrivals (`?post=`) should always begin the // smooth-scroll positioning from the top of the stream, so the user sees a @@ -128,6 +153,51 @@ export function MessageStream({ scope }: MessageStreamProps) { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [queryTargetPostId]); + // Search result clicks can target very old posts that are nowhere near the + // first paginated /browse page. Do not make the user wait while the stream + // loads page after page; fetch the target post directly and inject it at the + // top so it can render and be highlighted immediately. The normal stream + // still loads underneath for context / scrolling. + useEffect(() => { + if (!queryTargetPostId) { + setResolvedTargetPost(null); + setIsFetchingTargetPost(false); + setTargetPostFetchFailed(false); + return; + } + if (targetAlreadyInBaseItems) { + setResolvedTargetPost(null); + setIsFetchingTargetPost(false); + setTargetPostFetchFailed(false); + return; + } + + let cancelled = false; + setIsFetchingTargetPost(true); + setTargetPostFetchFailed(false); + getJSON( + `/api/posts/${encodeURIComponent(queryTargetPostId)}?lang=${encodeURIComponent( + langQuery(lang), + )}`, + ) + .then((post) => { + if (cancelled) return; + setResolvedTargetPost(post); + }) + .catch(() => { + if (cancelled) return; + setResolvedTargetPost(null); + setTargetPostFetchFailed(true); + }) + .finally(() => { + if (!cancelled) setIsFetchingTargetPost(false); + }); + + return () => { + cancelled = true; + }; + }, [lang, queryTargetPostId, targetAlreadyInBaseItems]); + useEffect(() => clearTargetScrollTimers, []); useEffect(() => { @@ -261,7 +331,7 @@ export function MessageStream({ scope }: MessageStreamProps) { } if (hasMore && !isLoading) loadMore(); else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); - }, [targetPostId, items, hasMore, isLoading, error, loadMore]); + }, [targetPostId, streamItems, hasMore, isLoading, error, loadMore]); const updateParam = (key: string, value: string) => { const n = new URLSearchParams(sp); @@ -270,7 +340,28 @@ export function MessageStream({ scope }: MessageStreamProps) { setSp(n, { replace: true }); }; - const isInitialLoad = isLoading && items.length === 0; + const isInitialLoad = isLoading && streamItems.length === 0; + + // When the user arrives via /browse?post= (typically from search or a + // banner) and the target post lives deep in the stream, pagination has to + // keep loading older pages until it surfaces. Show an explicit "finding + // your post" indicator so the user knows we're actively searching for + // their specific post, not just lazily loading the feed. + const targetInLoadedItems = + !!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId); + const isSearchingDeepTarget = + !!queryTargetPostId && + !targetInLoadedItems && + !error && + (isFetchingTargetPost || hasMore || isLoading); + const targetNotFoundInStream = + !!queryTargetPostId && + !targetInLoadedItems && + !error && + targetPostFetchFailed && + !hasMore && + !isLoading && + streamItems.length > 0; return (
@@ -284,6 +375,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
+ {isSearchingDeepTarget ? ( +
+ + {t("searchingForPost")} +
+ ) : null} + {targetNotFoundInStream ? ( +
+ {t("postNotFound")} +
+ ) : null} {isInitialLoad ? ( <> {Array.from({ length: 10 }).map((_, i) => ( @@ -311,7 +420,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
))} - {!isLoading && !error && items.length === 0 ? ( + {!isLoading && !error && streamItems.length === 0 ? (

{t("noResults")}

@@ -325,7 +434,9 @@ export function MessageStream({ scope }: MessageStreamProps) { {t("loadMoreFailed")}