feat(stream): friendlier pagination loading + error retry
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 29s

- Replace the bare '…' loading dots at the bottom of the post stream
  with a skeleton bubble that matches the initial-load placeholders, so
  pagination feels like content arriving instead of a frozen indicator.
- Localize the retry control via new 'retry' / 'loadMoreFailed' keys
  across all 7 locales and surface a user-friendly error string instead
  of the raw exception message.
- Retry button now picks reset() vs loadMore() based on item count so a
  pagination failure only refetches the next page, not the whole stream.
- When a banner deep-link can't find its target post and pagination
  errors, break out of the retry loop and release the scroll lock so the
  user sees the inline retry instead of an endless freeze.

Verified in the browser: zh-CN renders '加载更多资料失败,请检查网络后重试。'
with a '重试' button; banner clicks with empty / '#' / 'javascript:' /
null linkUrls render no anchor and do not navigate.
This commit is contained in:
TerryM
2026-06-02 11:39:17 +08:00
parent 7ed9f8c8bf
commit 387b25f1e3
8 changed files with 42 additions and 9 deletions

View File

@@ -31,7 +31,7 @@ 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 groups = useGroupedByDay(items, lang);
const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; const retryLabel = t("retry");
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const filterBarRef = useRef<HTMLDivElement>(null); const filterBarRef = useRef<HTMLDivElement>(null);
@@ -213,10 +213,17 @@ export function MessageStream({ scope }: MessageStreamProps) {
return; return;
} }
// Not loaded yet — keep paging until it appears or the stream is exhausted. // Not loaded yet — keep paging until it appears or the stream is
// exhausted. If the previous loadMore errored, stop the loop so the user
// sees the inline retry button instead of an endless retry cycle, and
// release the scroll lock so they can interact with the page.
if (error) {
setIsAligningQueryTarget(false);
return;
}
if (hasMore && !isLoading) loadMore(); if (hasMore && !isLoading) loadMore();
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
}, [targetPostId, items, hasMore, isLoading, loadMore]); }, [targetPostId, items, 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);
@@ -273,20 +280,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
) : null} ) : null}
{error ? ( {error ? (
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200"> <div
<span className="break-all">{error}</span> role="alert"
className="my-4 flex flex-col gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200 sm:flex-row sm:items-center sm:justify-between"
>
<span className="break-words">{t("loadMoreFailed")}</span>
<button <button
type="button" type="button"
onClick={() => reset()} onClick={() => (items.length === 0 ? reset() : loadMore())}
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500" 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}
</button> </button>
</div> </div>
) : null} ) : null}
{isLoading ? ( {isLoading && !error ? (
<div className="py-4 text-center text-xs text-neutral-500"></div> <div
aria-live="polite"
aria-label={t("loading")}
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
>
<Skeleton className="h-[80px] rounded-2xl" />
</div>
) : null} ) : null}
</> </>
)} )}

View File

@@ -126,6 +126,9 @@ export const enDict: Dict = {
tagsCommaLabel: "Tags (comma-separated)", tagsCommaLabel: "Tags (comma-separated)",
uploadFile: "Upload", uploadFile: "Upload",
loading: "Loading…", loading: "Loading…",
loadMoreFailed:
"Couldn't load more posts. Check your connection and try again.",
retry: "Retry",
paginationPrev: "Previous", paginationPrev: "Previous",
paginationNext: "Next", paginationNext: "Next",
listRange: "Showing {{from}}{{to}} of {{total}}", listRange: "Showing {{from}}{{to}} of {{total}}",

View File

@@ -126,6 +126,9 @@ export const idDict: Dict = {
tagsCommaLabel: "Tag (dipisahkan koma)", tagsCommaLabel: "Tag (dipisahkan koma)",
uploadFile: "Unggah", uploadFile: "Unggah",
loading: "Memuat…", loading: "Memuat…",
loadMoreFailed:
"Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.",
retry: "Coba lagi",
paginationPrev: "Sebelumnya", paginationPrev: "Sebelumnya",
paginationNext: "Berikutnya", paginationNext: "Berikutnya",
listRange: "Menampilkan {{from}}{{to}} dari {{total}}", listRange: "Menampilkan {{from}}{{to}} dari {{total}}",

View File

@@ -127,6 +127,9 @@ export const jaDict: Dict = {
tagsCommaLabel: "タグ(カンマ区切り)", tagsCommaLabel: "タグ(カンマ区切り)",
uploadFile: "アップロード", uploadFile: "アップロード",
loading: "読み込み中…", loading: "読み込み中…",
loadMoreFailed:
"追加の読み込みに失敗しました。接続を確認してやり直してください。",
retry: "再試行",
paginationPrev: "前へ", paginationPrev: "前へ",
paginationNext: "次へ", paginationNext: "次へ",
listRange: "{{from}}{{to}} / 全 {{total}} 件", listRange: "{{from}}{{to}} / 全 {{total}} 件",

View File

@@ -126,6 +126,8 @@ export const koDict: Dict = {
tagsCommaLabel: "태그 (쉼표로 구분)", tagsCommaLabel: "태그 (쉼표로 구분)",
uploadFile: "업로드", uploadFile: "업로드",
loading: "로딩 중…", loading: "로딩 중…",
loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.",
retry: "다시 시도",
paginationPrev: "이전", paginationPrev: "이전",
paginationNext: "다음", paginationNext: "다음",
listRange: "{{from}}{{to}} / 총 {{total}}건", listRange: "{{from}}{{to}} / 총 {{total}}건",

View File

@@ -126,6 +126,8 @@ export const msDict: Dict = {
tagsCommaLabel: "Tag (dipisahkan koma)", tagsCommaLabel: "Tag (dipisahkan koma)",
uploadFile: "Muat naik", uploadFile: "Muat naik",
loading: "Memuatkan…", loading: "Memuatkan…",
loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.",
retry: "Cuba lagi",
paginationPrev: "Sebelum", paginationPrev: "Sebelum",
paginationNext: "Seterusnya", paginationNext: "Seterusnya",
listRange: "Menunjukkan {{from}}{{to}} daripada {{total}}", listRange: "Menunjukkan {{from}}{{to}} daripada {{total}}",

View File

@@ -126,6 +126,8 @@ export const viDict: Dict = {
tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)", tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)",
uploadFile: "Tải lên", uploadFile: "Tải lên",
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.",
retry: "Thử lại",
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}}",

View File

@@ -124,6 +124,8 @@ export const zhDict: Dict = {
tagsCommaLabel: "标签(逗号分隔)", tagsCommaLabel: "标签(逗号分隔)",
uploadFile: "上传文件", uploadFile: "上传文件",
loading: "加载中…", loading: "加载中…",
loadMoreFailed: "加载更多资料失败,请检查网络后重试。",
retry: "重试",
paginationPrev: "上一页", paginationPrev: "上一页",
paginationNext: "下一页", paginationNext: "下一页",
listRange: "显示 {{from}}{{to}},共 {{total}} 条", listRange: "显示 {{from}}{{to}},共 {{total}} 条",