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

@@ -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,