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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { postJSON } from "../../api";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import { useI18n } from "../../i18n";
|
import { getJSON, postJSON } from "../../api";
|
||||||
import type { PostScope } from "../../types/post";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import type { Post, PostScope } from "../../types/post";
|
||||||
import { Reveal } from "../../motion";
|
import { Reveal } from "../../motion";
|
||||||
import { Skeleton } from "../Skeleton";
|
import { Skeleton } from "../Skeleton";
|
||||||
import { FilterChips } from "./FilterChips";
|
import { FilterChips } from "./FilterChips";
|
||||||
@@ -30,7 +31,6 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||||
usePostStream(params);
|
usePostStream(params);
|
||||||
const groups = useGroupedByDay(items, lang);
|
|
||||||
const retryLabel = t("retry");
|
const retryLabel = t("retry");
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -79,6 +79,27 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
? hash.slice("#post-".length)
|
? hash.slice("#post-".length)
|
||||||
: "";
|
: "";
|
||||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
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
|
// Lock only engages while we are actively running the smooth-scroll animation
|
||||||
// — not during the wait/pagination phase — so the page never feels frozen
|
// — not during the wait/pagination phase — so the page never feels frozen
|
||||||
// before the bubble exists.
|
// before the bubble exists.
|
||||||
@@ -113,10 +134,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
// Mark when first real content becomes visible (skeletons gone, items in).
|
// Mark when first real content becomes visible (skeletons gone, items in).
|
||||||
// Captured per-target via the reset above so a later navigation re-measures.
|
// Captured per-target via the reset above so a later navigation re-measures.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (items.length > 0 && !isLoading && firstContentAtRef.current === null) {
|
if (
|
||||||
|
streamItems.length > 0 &&
|
||||||
|
!isLoading &&
|
||||||
|
firstContentAtRef.current === null
|
||||||
|
) {
|
||||||
firstContentAtRef.current = performance.now();
|
firstContentAtRef.current = performance.now();
|
||||||
}
|
}
|
||||||
}, [items.length, isLoading]);
|
}, [streamItems.length, isLoading]);
|
||||||
|
|
||||||
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
|
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
|
||||||
// smooth-scroll positioning from the top of the stream, so the user sees a
|
// 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" });
|
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||||
}, [queryTargetPostId]);
|
}, [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(() => clearTargetScrollTimers, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -261,7 +331,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
}
|
}
|
||||||
if (hasMore && !isLoading) loadMore();
|
if (hasMore && !isLoading) loadMore();
|
||||||
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
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 updateParam = (key: string, value: string) => {
|
||||||
const n = new URLSearchParams(sp);
|
const n = new URLSearchParams(sp);
|
||||||
@@ -270,7 +340,28 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
setSp(n, { replace: true });
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
<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 ? (
|
{isInitialLoad ? (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
@@ -311,7 +420,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!isLoading && !error && items.length === 0 ? (
|
{!isLoading && !error && streamItems.length === 0 ? (
|
||||||
<p className="py-10 text-center text-sm text-neutral-400">
|
<p className="py-10 text-center text-sm text-neutral-400">
|
||||||
{t("noResults")}
|
{t("noResults")}
|
||||||
</p>
|
</p>
|
||||||
@@ -325,7 +434,9 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
<span className="break-words">{t("loadMoreFailed")}</span>
|
<span className="break-words">{t("loadMoreFailed")}</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
{retryLabel}
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ export const enDict: Dict = {
|
|||||||
loadMoreFailed:
|
loadMoreFailed:
|
||||||
"Couldn't load more posts. Check your connection and try again.",
|
"Couldn't load more posts. Check your connection and try again.",
|
||||||
retry: "Retry",
|
retry: "Retry",
|
||||||
|
searchingForPost: "Finding your post… loading older entries, please wait.",
|
||||||
|
postNotFound:
|
||||||
|
"Couldn’t find this post in the current view. It may have been removed.",
|
||||||
paginationPrev: "Previous",
|
paginationPrev: "Previous",
|
||||||
paginationNext: "Next",
|
paginationNext: "Next",
|
||||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ export const idDict: Dict = {
|
|||||||
loadMoreFailed:
|
loadMoreFailed:
|
||||||
"Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.",
|
"Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.",
|
||||||
retry: "Coba lagi",
|
retry: "Coba lagi",
|
||||||
|
searchingForPost:
|
||||||
|
"Mencari postingan Anda… memuat postingan lama, mohon tunggu.",
|
||||||
|
postNotFound:
|
||||||
|
"Postingan ini tidak ditemukan di daftar saat ini. Mungkin sudah dihapus.",
|
||||||
paginationPrev: "Sebelumnya",
|
paginationPrev: "Sebelumnya",
|
||||||
paginationNext: "Berikutnya",
|
paginationNext: "Berikutnya",
|
||||||
listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
|
listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ export const jaDict: Dict = {
|
|||||||
loadMoreFailed:
|
loadMoreFailed:
|
||||||
"追加の読み込みに失敗しました。接続を確認してやり直してください。",
|
"追加の読み込みに失敗しました。接続を確認してやり直してください。",
|
||||||
retry: "再試行",
|
retry: "再試行",
|
||||||
|
searchingForPost:
|
||||||
|
"投稿を検索中…古い投稿を読み込んでいます。しばらくお待ちください。",
|
||||||
|
postNotFound:
|
||||||
|
"現在のリストでこの投稿が見つかりません。削除された可能性があります。",
|
||||||
paginationPrev: "前へ",
|
paginationPrev: "前へ",
|
||||||
paginationNext: "次へ",
|
paginationNext: "次へ",
|
||||||
listRange: "{{from}}–{{to}} / 全 {{total}} 件",
|
listRange: "{{from}}–{{to}} / 全 {{total}} 件",
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ export const koDict: Dict = {
|
|||||||
loading: "로딩 중…",
|
loading: "로딩 중…",
|
||||||
loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
||||||
retry: "다시 시도",
|
retry: "다시 시도",
|
||||||
|
searchingForPost:
|
||||||
|
"게시물을 찾는 중… 이전 게시물을 불러오고 있습니다. 잠시만 기다려주세요.",
|
||||||
|
postNotFound:
|
||||||
|
"현재 목록에서 이 게시물을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
|
||||||
paginationPrev: "이전",
|
paginationPrev: "이전",
|
||||||
paginationNext: "다음",
|
paginationNext: "다음",
|
||||||
listRange: "{{from}}–{{to}} / 총 {{total}}건",
|
listRange: "{{from}}–{{to}} / 총 {{total}}건",
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export const msDict: Dict = {
|
|||||||
loading: "Memuatkan…",
|
loading: "Memuatkan…",
|
||||||
loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.",
|
loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.",
|
||||||
retry: "Cuba lagi",
|
retry: "Cuba lagi",
|
||||||
|
searchingForPost: "Mencari pos anda… memuat pos lama, sila tunggu.",
|
||||||
|
postNotFound:
|
||||||
|
"Pos ini tidak ditemui dalam senarai semasa. Mungkin telah dipadam.",
|
||||||
paginationPrev: "Sebelum",
|
paginationPrev: "Sebelum",
|
||||||
paginationNext: "Seterusnya",
|
paginationNext: "Seterusnya",
|
||||||
listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
|
listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export const viDict: Dict = {
|
|||||||
loading: "Đang tải…",
|
loading: "Đang tải…",
|
||||||
loadMoreFailed: "Không thể tải thêm bài. Hãy kiểm tra kết nối và thử lại.",
|
loadMoreFailed: "Không thể tải thêm bài. Hãy kiểm tra kết nối và thử lại.",
|
||||||
retry: "Thử lại",
|
retry: "Thử lại",
|
||||||
|
searchingForPost: "Đang tìm bài viết… tải thêm bài cũ, vui lòng đợi.",
|
||||||
|
postNotFound:
|
||||||
|
"Không tìm thấy bài này trong danh sách hiện tại. Bài có thể đã bị xóa.",
|
||||||
paginationPrev: "Trước",
|
paginationPrev: "Trước",
|
||||||
paginationNext: "Sau",
|
paginationNext: "Sau",
|
||||||
listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
|
listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const zhDict: Dict = {
|
|||||||
loading: "加载中…",
|
loading: "加载中…",
|
||||||
loadMoreFailed: "加载更多资料失败,请检查网络后重试。",
|
loadMoreFailed: "加载更多资料失败,请检查网络后重试。",
|
||||||
retry: "重试",
|
retry: "重试",
|
||||||
|
searchingForPost: "正在查找您的帖子,请稍等…",
|
||||||
|
postNotFound: "在当前列表中找不到这个帖子,可能已被移除。",
|
||||||
paginationPrev: "上一页",
|
paginationPrev: "上一页",
|
||||||
paginationNext: "下一页",
|
paginationNext: "下一页",
|
||||||
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
||||||
|
|||||||
Reference in New Issue
Block a user