fix(stream): resolve search deep-links without pagination stall
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user