fix: expire frontend caches

This commit is contained in:
TerryM
2026-05-31 02:44:44 +08:00
parent 5b93e8dc77
commit 46b7ee861e
4 changed files with 272 additions and 21 deletions

View File

@@ -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 }));

View File

@@ -19,27 +19,122 @@ type JsonCacheEntry<T> = {
const jsonMemoryCache = new Map<string, JsonCacheEntry<unknown>>();
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<T>(path: string): T | null {
const key = jsonCacheKey(path);
const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry<T> | undefined;
if (memoryEntry) return memoryEntry.value;
function jsonStorageKey(key: string): string {
return `${jsonStoragePrefix}${key}`;
}
function isFreshJSONCacheEntry(entry: JsonCacheEntry<unknown>): 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<T>(key: string): JsonCacheEntry<T> | 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<T>;
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
return entry.value;
} catch {
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<T>(path: string): T | null {
cleanupExpiredJSONCache();
const key = jsonCacheKey(path);
const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry<T> | undefined;
if (memoryEntry) {
if (isFreshJSONCacheEntry(memoryEntry)) return memoryEntry.value;
removeStoredJSONCache(key);
return null;
}
const entry = readStoredJSONCacheEntry<T>(key);
if (!entry) return null;
if (!isFreshJSONCacheEntry(entry)) {
removeStoredJSONCache(key);
return null;
}
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
return entry.value;
}
function writeJSONCache<T>(path: string, value: T): void {
@@ -47,16 +142,16 @@ function writeJSONCache<T>(path: string, value: T): void {
const entry: JsonCacheEntry<T> = { cachedAt: Date.now(), value };
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
if (typeof window === "undefined") return;
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
`${jsonStoragePrefix}${key}`,
JSON.stringify(entry),
);
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<T>(path: string): Promise<T> {

View File

@@ -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();
});
});

View File

@@ -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<string, CachedStream>();
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<CachedStream, "cachedAt">,
): 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<PostListResponse>(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<Post[]>(() => 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<string | undefined>(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,