diff --git a/src/api.test.ts b/src/api.test.ts index fbac350..9df985a 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -51,6 +51,47 @@ describe("api helpers", () => { await expect(getJSON("/api/fail")).rejects.toThrow("boom"); }); + it("cleans expired JSON cache entries", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-31T00:00:00Z")); + const { getJSON, readJSONCache } = await loadApi(); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ items: [1] })); + vi.stubGlobal("fetch", fetchMock); + + await getJSON("/api/posts"); + expect(readJSONCache("/api/posts")).toEqual({ items: [1] }); + + vi.setSystemTime(new Date("2026-05-31T00:05:01Z")); + expect(readJSONCache("/api/posts")).toBeNull(); + expect(window.localStorage.length).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("prunes old JSON cache entries beyond the entry limit", async () => { + vi.useFakeTimers(); + try { + const { getJSON, readJSONCache } = await loadApi(); + const fetchMock = vi.fn((url: string) => + Promise.resolve(jsonResponse({ url })), + ); + vi.stubGlobal("fetch", fetchMock); + + for (let index = 0; index < 81; index += 1) { + vi.setSystemTime(new Date(2026, 4, 31, 0, 0, index)); + await getJSON(`/api/cache-${index}`); + } + + expect(readJSONCache("/api/cache-0")).toBeNull(); + expect(readJSONCache("/api/cache-80")).toEqual({ url: "/api/cache-80" }); + expect(window.localStorage.length).toBe(80); + } finally { + vi.useRealTimers(); + } + }); + it("posts JSON with optional bearer token", async () => { const { postJSON } = await loadApi(); const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); diff --git a/src/api.ts b/src/api.ts index 28dee0a..50a900c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -19,44 +19,139 @@ type JsonCacheEntry = { const jsonMemoryCache = new Map>(); const jsonStoragePrefix = "ark-json-cache:v1:"; +const jsonCacheTtlMs = 5 * 60 * 1000; +const jsonCacheMaxEntries = 80; function jsonCacheKey(path: string): string { return `${apiBase}${path}`; } -export function readJSONCache(path: string): T | null { - const key = jsonCacheKey(path); - const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry | undefined; - if (memoryEntry) return memoryEntry.value; +function jsonStorageKey(key: string): string { + return `${jsonStoragePrefix}${key}`; +} +function isFreshJSONCacheEntry(entry: JsonCacheEntry): boolean { + return Date.now() - entry.cachedAt <= jsonCacheTtlMs; +} + +function removeStoredJSONCache(key: string): void { + jsonMemoryCache.delete(key); + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(jsonStorageKey(key)); + } catch { + // Ignore privacy-mode storage failures. + } +} + +function readStoredJSONCacheEntry(key: string): JsonCacheEntry | null { if (typeof window === "undefined") return null; try { - const raw = window.localStorage.getItem(`${jsonStoragePrefix}${key}`); + const raw = window.localStorage.getItem(jsonStorageKey(key)); if (!raw) return null; const entry = JSON.parse(raw) as JsonCacheEntry; - jsonMemoryCache.set(key, entry as JsonCacheEntry); - return entry.value; + if (typeof entry.cachedAt !== "number") { + removeStoredJSONCache(key); + return null; + } + return entry; } catch { + removeStoredJSONCache(key); return null; } } +function cleanupExpiredJSONCache(): void { + for (const [key, entry] of jsonMemoryCache) { + if (!isFreshJSONCacheEntry(entry)) jsonMemoryCache.delete(key); + } + + if (typeof window === "undefined") return; + + try { + const keys: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(jsonStoragePrefix)) keys.push(key); + } + + keys.forEach((storageKey) => { + const key = storageKey.slice(jsonStoragePrefix.length); + const entry = readStoredJSONCacheEntry(key); + if (!entry || !isFreshJSONCacheEntry(entry)) removeStoredJSONCache(key); + }); + } catch { + // Ignore storage access failures; memory cleanup above still happened. + } +} + +function pruneJSONCache(): void { + if (jsonMemoryCache.size > jsonCacheMaxEntries) { + [...jsonMemoryCache.entries()] + .sort(([, a], [, b]) => a.cachedAt - b.cachedAt) + .slice(0, jsonMemoryCache.size - jsonCacheMaxEntries) + .forEach(([key]) => jsonMemoryCache.delete(key)); + } + + if (typeof window === "undefined") return; + + try { + const entries: Array<{ key: string; cachedAt: number }> = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const storageKey = window.localStorage.key(index); + if (!storageKey?.startsWith(jsonStoragePrefix)) continue; + const key = storageKey.slice(jsonStoragePrefix.length); + const entry = readStoredJSONCacheEntry(key); + if (entry) entries.push({ key, cachedAt: entry.cachedAt }); + } + + entries + .sort((a, b) => a.cachedAt - b.cachedAt) + .slice(0, Math.max(0, entries.length - jsonCacheMaxEntries)) + .forEach(({ key }) => removeStoredJSONCache(key)); + } catch { + // Ignore storage access failures. + } +} + +export function readJSONCache(path: string): T | null { + cleanupExpiredJSONCache(); + + const key = jsonCacheKey(path); + const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry | undefined; + if (memoryEntry) { + if (isFreshJSONCacheEntry(memoryEntry)) return memoryEntry.value; + removeStoredJSONCache(key); + return null; + } + + const entry = readStoredJSONCacheEntry(key); + if (!entry) return null; + if (!isFreshJSONCacheEntry(entry)) { + removeStoredJSONCache(key); + return null; + } + + jsonMemoryCache.set(key, entry as JsonCacheEntry); + return entry.value; +} + function writeJSONCache(path: string, value: T): void { const key = jsonCacheKey(path); const entry: JsonCacheEntry = { cachedAt: Date.now(), value }; jsonMemoryCache.set(key, entry as JsonCacheEntry); - 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. + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(jsonStorageKey(key), JSON.stringify(entry)); + } catch { + // Ignore storage quota / privacy-mode failures; memory cache still works. + } } + + cleanupExpiredJSONCache(); + pruneJSONCache(); } export async function getJSON(path: string): Promise { diff --git a/src/components/messageStream/hooks/usePostStream.test.tsx b/src/components/messageStream/hooks/usePostStream.test.tsx new file mode 100644 index 0000000..16f6305 --- /dev/null +++ b/src/components/messageStream/hooks/usePostStream.test.tsx @@ -0,0 +1,67 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Post } from "../../../types/post"; +import { usePostStream } from "./usePostStream"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function post(id: string): Post { + return { + id, + categoryId: 1, + categorySlug: "general", + language: "zh-CN", + attachments: [], + isRecommended: false, + publishedAt: "2026-05-31T00:00:00.000Z", + }; +} + +const params = { + scope: { kind: "all" as const }, + type: "all", + q: "", + lang: "zh-CN" as const, +}; + +describe("usePostStream", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("expires the in-memory stream cache", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1000); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ items: [post("fresh")] })) + .mockResolvedValueOnce(jsonResponse({ items: [post("refetched")] })); + vi.stubGlobal("fetch", fetchMock); + + const first = renderHook(() => usePostStream(params)); + await waitFor(() => + expect(first.result.current.items[0]?.id).toBe("fresh"), + ); + first.unmount(); + + now.mockReturnValue(1000 + 60 * 1000); + const cached = renderHook(() => usePostStream(params)); + expect(cached.result.current.items[0]?.id).toBe("fresh"); + expect(cached.result.current.isLoading).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + cached.unmount(); + + now.mockReturnValue(1000 + 5 * 60 * 1000 + 1); + const expired = renderHook(() => usePostStream(params)); + await waitFor(() => + expect(expired.result.current.items[0]?.id).toBe("refetched"), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + expired.unmount(); + }); +}); diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index 491e963..e78f4c3 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -98,6 +98,7 @@ type CachedStream = { items: Post[]; cursor: string | undefined; hasMore: boolean; + cachedAt: number; }; /** @@ -107,16 +108,56 @@ type CachedStream = { * In-memory only: a full page reload starts fresh. */ const streamCache = new Map(); +const streamCacheTtlMs = 5 * 60 * 1000; +const streamCacheMaxEntries = 8; function streamKey(params: PostStreamParams): string { return buildRealUrl(params); } +function isFreshStreamCache(entry: CachedStream): boolean { + return Date.now() - entry.cachedAt <= streamCacheTtlMs; +} + +function pruneStreamCache(): void { + for (const [key, entry] of streamCache) { + if (!isFreshStreamCache(entry)) streamCache.delete(key); + } + + while (streamCache.size > streamCacheMaxEntries) { + const oldestKey = streamCache.keys().next().value as string | undefined; + if (!oldestKey) break; + streamCache.delete(oldestKey); + } +} + +function readStreamCache(key: string): CachedStream | undefined { + const entry = streamCache.get(key); + if (!entry) return undefined; + if (!isFreshStreamCache(entry)) { + streamCache.delete(key); + return undefined; + } + + streamCache.delete(key); + streamCache.set(key, entry); + return entry; +} + +function writeStreamCache( + key: string, + entry: Omit, +): void { + streamCache.delete(key); + streamCache.set(key, { ...entry, cachedAt: Date.now() }); + pruneStreamCache(); +} + function cacheFirstPage( params: PostStreamParams, page: PostListResponse, ): void { - streamCache.set(streamKey(params), { + writeStreamCache(streamKey(params), { items: itemsOrEmpty(page.items), cursor: page.nextCursor, hasMore: !!page.nextCursor, @@ -131,7 +172,7 @@ function cacheFirstPage( export function prefetchPostStream(params: PostStreamParams): void { if (USE_MOCK) return; const key = streamKey(params); - if (streamCache.has(key)) return; + if (readStreamCache(key)) return; const url = buildRealUrl(params); const cachedPage = readJSONCache(url); @@ -146,7 +187,7 @@ export function prefetchPostStream(params: PostStreamParams): void { } export function usePostStream(params: PostStreamParams): PostStreamResult { - const initialCached = streamCache.get(streamKey(params)); + const initialCached = readStreamCache(streamKey(params)); const [items, setItems] = useState(() => initialCached?.items ?? []); const [hasMore, setHasMore] = useState(() => initialCached?.hasMore ?? true); const [isLoading, setIsLoading] = useState(() => !initialCached); @@ -156,6 +197,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { const cursorRef = useRef(initialCached?.cursor); const hasMoreRef = useRef(initialCached?.hasMore ?? true); const loadingRef = useRef(false); + const restoredFromCacheRef = useRef(!!initialCached); const fetchPage = useCallback( async (resetting: boolean) => { @@ -225,7 +267,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { useEffect(() => { // Restore a previously-loaded view instantly (no reset, no refetch). - const cached = streamCache.get(streamKey(params)); + const cached = readStreamCache(streamKey(params)); if (cached) { setItems(cached.items); cursorRef.current = cached.cursor; @@ -233,8 +275,10 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { hasMoreRef.current = cached.hasMore; setIsLoading(false); setError(null); + restoredFromCacheRef.current = true; return; } + restoredFromCacheRef.current = false; setItems([]); cursorRef.current = undefined; setHasMore(true); @@ -254,7 +298,11 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { // Persist loaded state so returning to this view restores it from cache. useEffect(() => { if (items.length === 0) return; - streamCache.set(streamKey(params), { + if (restoredFromCacheRef.current) { + restoredFromCacheRef.current = false; + return; + } + writeStreamCache(streamKey(params), { items, cursor: cursorRef.current, hasMore,