Add stale cache for public data

This commit is contained in:
TerryM
2026-05-28 23:09:18 +08:00
parent 5ae9647465
commit 320739f91b
8 changed files with 263 additions and 83 deletions

View File

@@ -12,10 +12,59 @@ export function assetUrl(path: string | undefined | null) {
return `${apiBase}${path}`; return `${apiBase}${path}`;
} }
type JsonCacheEntry<T> = {
cachedAt: number;
value: T;
};
const jsonMemoryCache = new Map<string, JsonCacheEntry<unknown>>();
const jsonStoragePrefix = "ark-json-cache:v1:";
function jsonCacheKey(path: string): string {
return `${apiBase}${path}`;
}
export function readJSONCache<T>(path: string): T | null {
const key = jsonCacheKey(path);
const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry<T> | undefined;
if (memoryEntry) return memoryEntry.value;
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(`${jsonStoragePrefix}${key}`);
if (!raw) return null;
const entry = JSON.parse(raw) as JsonCacheEntry<T>;
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
return entry.value;
} catch {
return null;
}
}
function writeJSONCache<T>(path: string, value: T): void {
const key = jsonCacheKey(path);
const entry: JsonCacheEntry<T> = { cachedAt: Date.now(), value };
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
`${jsonStoragePrefix}${key}`,
JSON.stringify(entry),
);
} catch {
// Ignore storage quota / privacy-mode failures; memory cache still works.
}
}
export async function getJSON<T>(path: string): Promise<T> { export async function getJSON<T>(path: string): Promise<T> {
const res = await fetch(`${apiBase}${path}`); const res = await fetch(`${apiBase}${path}`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<T>; const data = (await res.json()) as T;
writeJSONCache(path, data);
return data;
} }
export async function getJSONAuth<T>(path: string, token: string): Promise<T> { export async function getJSONAuth<T>(path: string, token: string): Promise<T> {

View File

@@ -5,7 +5,7 @@ import {
useState, useState,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { assetUrl, getJSON, itemsOrEmpty } from "../api"; import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { langQuery, useI18n, type Lang } from "../i18n"; import { langQuery, useI18n, type Lang } from "../i18n";
const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
@@ -75,14 +75,19 @@ export function FigmaBanner() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const bannersUrl = `/api/banners?lang=${bannerLangParam(lang)}`;
setActiveIndex(0); setActiveIndex(0);
getJSON<BannerApiResponse>(`/api/banners?lang=${bannerLangParam(lang)}`)
const cachedBanners = readJSONCache<BannerApiResponse>(bannersUrl);
if (cachedBanners) setSlides(toSlides(itemsOrEmpty(cachedBanners.items)));
getJSON<BannerApiResponse>(bannersUrl)
.then((res) => { .then((res) => {
if (cancelled) return; if (cancelled) return;
setSlides(toSlides(itemsOrEmpty(res.items))); setSlides(toSlides(itemsOrEmpty(res.items)));
}) })
.catch(() => { .catch(() => {
if (!cancelled) setSlides([]); if (!cancelled && !cachedBanners) setSlides([]);
}); });
return () => { return () => {
cancelled = true; cancelled = true;

View File

@@ -1,6 +1,6 @@
import { Info, Search as SearchIcon, X } from "lucide-react"; import { Info, Search as SearchIcon, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { getJSON, itemsOrEmpty } from "../api"; import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { langQuery, type Lang } from "../i18n"; import { langQuery, type Lang } from "../i18n";
import type { Post, PostListResponse } from "../types/post"; import type { Post, PostListResponse } from "../types/post";
import { MessageBubble } from "./messageStream/MessageBubble"; import { MessageBubble } from "./messageStream/MessageBubble";
@@ -75,20 +75,22 @@ export function SearchPanel({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setIsTagLoading(true); const tagsUrl = buildPostsUrl({
getJSON<PostListResponse>(
buildPostsUrl({
lang: langParam, lang: langParam,
sort: "latest", sort: "latest",
limit: TAG_SOURCE_LIMIT, limit: TAG_SOURCE_LIMIT,
}), });
) const cachedTags = readJSONCache<PostListResponse>(tagsUrl);
if (cachedTags) setTags(extractTags(itemsOrEmpty(cachedTags.items)));
setIsTagLoading(true);
getJSON<PostListResponse>(tagsUrl)
.then((res) => { .then((res) => {
if (cancelled) return; if (cancelled) return;
setTags(extractTags(itemsOrEmpty(res.items))); setTags(extractTags(itemsOrEmpty(res.items)));
}) })
.catch(() => { .catch(() => {
if (!cancelled) setTags([]); if (!cancelled && !cachedTags) setTags([]);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setIsTagLoading(false); if (!cancelled) setIsTagLoading(false);
@@ -102,17 +104,31 @@ export function SearchPanel({
const showTagPosts = (tag: string) => { const showTagPosts = (tag: string) => {
setSelectedTag(tag); setSelectedTag(tag);
onQueryChange(tag); onQueryChange(tag);
const searchUrl = buildSearchUrl({
lang: langParam,
q: tag,
limit: TAG_RESULT_LIMIT,
});
const cachedPosts = readJSONCache<PostListResponse>(searchUrl);
if (cachedPosts) {
setTagPosts(
itemsOrEmpty(cachedPosts.items).filter((post) =>
post.tags?.some((postTag) => postTag.trim() === tag),
),
);
}
setIsPostLoading(true); setIsPostLoading(true);
getJSON<PostListResponse>( getJSON<PostListResponse>(searchUrl)
buildSearchUrl({ lang: langParam, q: tag, limit: TAG_RESULT_LIMIT }),
)
.then((res) => { .then((res) => {
const exactMatches = itemsOrEmpty(res.items).filter((post) => const exactMatches = itemsOrEmpty(res.items).filter((post) =>
post.tags?.some((postTag) => postTag.trim() === tag), post.tags?.some((postTag) => postTag.trim() === tag),
); );
setTagPosts(exactMatches); setTagPosts(exactMatches);
}) })
.catch(() => setTagPosts([])) .catch(() => {
if (!cachedPosts) setTagPosts([]);
})
.finally(() => setIsPostLoading(false)); .finally(() => setIsPostLoading(false));
}; };

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getJSON } from "../../../api"; import { getJSON, itemsOrEmpty, readJSONCache } from "../../../api";
import { langQuery, type Lang } from "../../../i18n"; import { langQuery, type Lang } from "../../../i18n";
import { sourceLanguageQuery } from "../../../i18nLanguages"; import { sourceLanguageQuery } from "../../../i18nLanguages";
import { MOCK_POSTS } from "../../../mocks/mockPosts"; import { MOCK_POSTS } from "../../../mocks/mockPosts";
@@ -114,6 +114,8 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
setError(null); setError(null);
const myReq = ++reqIdRef.current; const myReq = ++reqIdRef.current;
let showedCached = false;
try { try {
if (USE_MOCK) { if (USE_MOCK) {
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS)); await new Promise((r) => setTimeout(r, MOCK_DELAY_MS));
@@ -133,9 +135,26 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
params, params,
resetting ? undefined : cursorRef.current, resetting ? undefined : cursorRef.current,
); );
if (resetting) {
const cachedPage = readJSONCache<PostListResponse>(url);
if (cachedPage && myReq === reqIdRef.current) {
showedCached = true;
const cachedItems = itemsOrEmpty(cachedPage.items);
setItems(cachedItems);
cursorRef.current = cachedPage.nextCursor;
const cachedMore = !!cachedPage.nextCursor;
setHasMore(cachedMore);
hasMoreRef.current = cachedMore;
}
}
const res = await getJSON<PostListResponse>(url); const res = await getJSON<PostListResponse>(url);
if (myReq !== reqIdRef.current) return; if (myReq !== reqIdRef.current) return;
setItems((prev) => (resetting ? res.items : [...prev, ...res.items])); const freshItems = itemsOrEmpty(res.items);
setItems((prev) =>
resetting ? freshItems : [...prev, ...freshItems],
);
cursorRef.current = res.nextCursor; cursorRef.current = res.nextCursor;
const more = !!res.nextCursor; const more = !!res.nextCursor;
setHasMore(more); setHasMore(more);
@@ -143,7 +162,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
} }
} catch (e) { } catch (e) {
if (myReq !== reqIdRef.current) return; if (myReq !== reqIdRef.current) return;
setError(String(e)); if (!showedCached) setError(String(e));
} finally { } finally {
if (myReq === reqIdRef.current) setIsLoading(false); if (myReq === reqIdRef.current) setIsLoading(false);
loadingRef.current = false; loadingRef.current = false;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { getJSON, itemsOrEmpty, type Category } from "../../api"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { CategoryIcon } from "../../components/CategoryIcon"; import { CategoryIcon } from "../../components/CategoryIcon";
import { SectionHeader } from "../../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
@@ -34,17 +34,32 @@ export function CategoriesPage() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
getJSON<Category[]>( let cancelled = false;
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, const categoriesUrl = `/api/categories?lang=${encodeURIComponent(
) langQuery(lang),
.then((items) => )}`;
const applyCategories = (items: Category[]) =>
setCats( setCats(
itemsOrEmpty(items).sort( itemsOrEmpty(items).sort(
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b), (a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
), ),
), );
)
.catch((e) => setErr(String(e))); setErr(null);
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
if (cachedCategories) applyCategories(cachedCategories);
getJSON<Category[]>(categoriesUrl)
.then((items) => {
if (!cancelled) applyCategories(items);
})
.catch((e) => {
if (!cancelled && !cachedCategories) setErr(String(e));
});
return () => {
cancelled = true;
};
}, [lang]); }, [lang]);
if (err) { if (err) {

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, type Category } from "../../api"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { AssetStreamPage } from "../../components/AssetStreamPage"; import { AssetStreamPage } from "../../components/AssetStreamPage";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
@@ -13,18 +13,32 @@ export function CategoryPage() {
useEffect(() => { useEffect(() => {
if (!slug) return; if (!slug) return;
setTitle(slug); let cancelled = false;
getJSON<Category[]>( const categoriesUrl = `/api/categories?lang=${encodeURIComponent(
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, langQuery(lang),
) )}`;
.then((cats) => const applyTitle = (cats: Category[]) =>
setTitle( setTitle(
cleanCategoryDisplayName( cleanCategoryDisplayName(
itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug, itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug,
), ),
), );
)
.catch(() => setTitle(slug)); setTitle(slug);
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
if (cachedCategories) applyTitle(cachedCategories);
getJSON<Category[]>(categoriesUrl)
.then((cats) => {
if (!cancelled) applyTitle(cats);
})
.catch(() => {
if (!cancelled && !cachedCategories) setTitle(slug);
});
return () => {
cancelled = true;
};
}, [slug, lang]); }, [slug, lang]);
return <AssetStreamPage title={title || slug} scope={scope} />; return <AssetStreamPage title={title || slug} scope={scope} />;

View File

@@ -1,7 +1,7 @@
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getJSON, itemsOrEmpty, type Category } from "../../api"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { CategoryIcon } from "../../components/CategoryIcon"; import { CategoryIcon } from "../../components/CategoryIcon";
import { FigmaBanner } from "../../components/FigmaBanner"; import { FigmaBanner } from "../../components/FigmaBanner";
import { import {
@@ -61,41 +61,80 @@ export function Home() {
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 }); const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
useEffect(() => { useEffect(() => {
let cancelled = false;
const langParam = encodeURIComponent(langQuery(lang)); const langParam = encodeURIComponent(langQuery(lang));
const languageParam = encodeURIComponent(sourceLanguageQuery(lang)); const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
const catQ = `?lang=${langParam}`; const catQ = `?lang=${langParam}`;
const postQ = `?lang=${langParam}&language=${languageParam}`; const postQ = `?lang=${langParam}&language=${languageParam}`;
Promise.all([ const categoriesUrl = `/api/categories${catQ}`;
getJSON<Category[]>(`/api/categories${catQ}`), const recommendedUrl = `/api/posts/recommended${postQ}&limit=12`;
getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`), const latestUrl = `/api/posts${postQ}&sort=latest&limit=12`;
getJSON<{ items: Post[] }>(`/api/posts${postQ}&sort=latest&limit=12`), const popularUrl = `/api/posts${postQ}&sort=popular&limit=5`;
getJSON<{ items: Post[] }>(
`/api/posts${postQ}&sort=popular&limit=5`, const applyHomeData = (
).catch((): { items: Post[] } => ({ items: [] })), c: Category[],
]) r: { items: Post[] },
.then(([c, r, l, p]) => { l: { items: Post[] },
setCats(itemsOrEmpty(c)); p: { items: Post[] },
) => {
const categoryItems = itemsOrEmpty(c);
setCats(categoryItems);
setRec( setRec(
itemsOrEmpty(r.items).map((post) => itemsOrEmpty(r.items).map((post) =>
postToResource(post, lang, itemsOrEmpty(c)), postToResource(post, lang, categoryItems),
), ),
); );
const latestItems = itemsOrEmpty(l.items); const latestItems = itemsOrEmpty(l.items);
setLatestPosts(latestItems); setLatestPosts(latestItems);
setLatest( setLatest(
latestItems.map((post) => latestItems.map((post) => postToResource(post, lang, categoryItems)),
postToResource(post, lang, itemsOrEmpty(c)),
),
); );
const popularItems = itemsOrEmpty<Post>(p.items); const popularItems = itemsOrEmpty<Post>(p.items);
setPopularPosts(popularItems); setPopularPosts(popularItems);
setPopular( setPopular(
popularItems.map((post) => popularItems.map((post) => postToResource(post, lang, categoryItems)),
postToResource(post, lang, itemsOrEmpty(c)),
),
); );
};
setErr(null);
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
const cachedRecommended = readJSONCache<{ items: Post[] }>(recommendedUrl);
const cachedLatest = readJSONCache<{ items: Post[] }>(latestUrl);
const cachedPopular = readJSONCache<{ items: Post[] }>(popularUrl);
const showedCached = !!(
cachedCategories &&
cachedRecommended &&
cachedLatest
);
if (showedCached) {
applyHomeData(
cachedCategories,
cachedRecommended,
cachedLatest,
cachedPopular ?? { items: [] },
);
}
Promise.all([
getJSON<Category[]>(categoriesUrl),
getJSON<{ items: Post[] }>(recommendedUrl),
getJSON<{ items: Post[] }>(latestUrl),
getJSON<{ items: Post[] }>(popularUrl).catch(
(): { items: Post[] } => cachedPopular ?? { items: [] },
),
])
.then(([c, r, l, p]) => {
if (cancelled) return;
applyHomeData(c, r, l, p);
}) })
.catch((e) => setErr(String(e))); .catch((e) => {
if (!cancelled && !showedCached) setErr(String(e));
});
return () => {
cancelled = true;
};
}, [lang]); }, [lang]);
const iconKeyForResource = (r: PostBackedResource) => const iconKeyForResource = (r: PostBackedResource) =>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getJSON, itemsOrEmpty, type Category } from "../../api"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { RecommendedCard } from "../../components/RecommendedCard"; import { RecommendedCard } from "../../components/RecommendedCard";
import { SectionHeader } from "../../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
@@ -16,23 +16,46 @@ export function OfficialRecommendationsPage() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
const langParam = encodeURIComponent(langQuery(lang)); const langParam = encodeURIComponent(langQuery(lang));
const languageParam = encodeURIComponent(sourceLanguageQuery(lang)); const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
Promise.all([ const categoriesUrl = `/api/categories?lang=${langParam}`;
getJSON<Category[]>(`/api/categories?lang=${langParam}`), const recommendedUrl = `/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`;
getJSON<{ items: Post[] }>(
`/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`, const applyItems = (
), categories: Category[],
]) recommended: { items: Post[] },
.then(([categories, recommended]) => { ) => {
const cats = itemsOrEmpty(categories); const cats = itemsOrEmpty(categories);
setItems( setItems(
itemsOrEmpty(recommended.items).map((post) => itemsOrEmpty(recommended.items).map((post) =>
postToResource(post, lang, cats), postToResource(post, lang, cats),
), ),
); );
};
setErr(null);
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
const cachedRecommended = readJSONCache<{ items: Post[] }>(recommendedUrl);
const showedCached = !!(cachedCategories && cachedRecommended);
if (showedCached) applyItems(cachedCategories, cachedRecommended);
Promise.all([
getJSON<Category[]>(categoriesUrl),
getJSON<{ items: Post[] }>(recommendedUrl),
])
.then(([categories, recommended]) => {
if (cancelled) return;
applyItems(categories, recommended);
}) })
.catch((e) => setErr(String(e))); .catch((e) => {
if (!cancelled && !showedCached) setErr(String(e));
});
return () => {
cancelled = true;
};
}, [lang]); }, [lang]);
if (err) { if (err) {