fix(stream): resolve search deep-links without pagination stall
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s

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.
This commit is contained in:
TerryM
2026-06-03 01:40:21 +08:00
parent 0326cb2998
commit 985463b7da
8 changed files with 144 additions and 10 deletions

View File

@@ -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<HTMLDivElement>(null);
@@ -79,6 +79,27 @@ export function MessageStream({ scope }: MessageStreamProps) {
? hash.slice("#post-".length)
: "";
const targetPostId = queryTargetPostId || hashTargetPostId;
const [resolvedTargetPost, setResolvedTargetPost] = useState<Post | null>(
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=<id>`) 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<Post>(
`/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=<id> (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 (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
@@ -284,6 +375,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div>
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{isSearchingDeepTarget ? (
<div
role="status"
aria-live="polite"
className="mx-auto flex w-fit max-w-full items-center gap-2 rounded-full border border-ark-gold/40 bg-ark-gold/10 px-4 py-2 text-sm text-ark-gold shadow-sm"
>
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
<span>{t("searchingForPost")}</span>
</div>
) : null}
{targetNotFoundInStream ? (
<div
role="status"
className="mx-auto w-fit max-w-full rounded-full border border-yellow-700/40 bg-yellow-950/30 px-4 py-2 text-center text-sm text-yellow-200"
>
{t("postNotFound")}
</div>
) : null}
{isInitialLoad ? (
<>
{Array.from({ length: 10 }).map((_, i) => (
@@ -311,7 +420,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div>
))}
{!isLoading && !error && items.length === 0 ? (
{!isLoading && !error && streamItems.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
@@ -325,7 +434,9 @@ export function MessageStream({ scope }: MessageStreamProps) {
<span className="break-words">{t("loadMoreFailed")}</span>
<button
type="button"
onClick={() => (items.length === 0 ? reset() : loadMore())}
onClick={() =>
streamItems.length === 0 ? reset() : loadMore()
}
className="shrink-0 self-start rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500 sm:self-auto"
>
{retryLabel}