fix: expire frontend caches
This commit is contained in:
67
src/components/messageStream/hooks/usePostStream.test.tsx
Normal file
67
src/components/messageStream/hooks/usePostStream.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user