fix: expire frontend caches
This commit is contained in:
@@ -51,6 +51,47 @@ describe("api helpers", () => {
|
|||||||
await expect(getJSON("/api/fail")).rejects.toThrow("boom");
|
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 () => {
|
it("posts JSON with optional bearer token", async () => {
|
||||||
const { postJSON } = await loadApi();
|
const { postJSON } = await loadApi();
|
||||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
|
||||||
|
|||||||
127
src/api.ts
127
src/api.ts
@@ -19,44 +19,139 @@ type JsonCacheEntry<T> = {
|
|||||||
|
|
||||||
const jsonMemoryCache = new Map<string, JsonCacheEntry<unknown>>();
|
const jsonMemoryCache = new Map<string, JsonCacheEntry<unknown>>();
|
||||||
const jsonStoragePrefix = "ark-json-cache:v1:";
|
const jsonStoragePrefix = "ark-json-cache:v1:";
|
||||||
|
const jsonCacheTtlMs = 5 * 60 * 1000;
|
||||||
|
const jsonCacheMaxEntries = 80;
|
||||||
|
|
||||||
function jsonCacheKey(path: string): string {
|
function jsonCacheKey(path: string): string {
|
||||||
return `${apiBase}${path}`;
|
return `${apiBase}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readJSONCache<T>(path: string): T | null {
|
function jsonStorageKey(key: string): string {
|
||||||
const key = jsonCacheKey(path);
|
return `${jsonStoragePrefix}${key}`;
|
||||||
const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry<T> | undefined;
|
}
|
||||||
if (memoryEntry) return memoryEntry.value;
|
|
||||||
|
|
||||||
|
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;
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(`${jsonStoragePrefix}${key}`);
|
const raw = window.localStorage.getItem(jsonStorageKey(key));
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const entry = JSON.parse(raw) as JsonCacheEntry<T>;
|
const entry = JSON.parse(raw) as JsonCacheEntry<T>;
|
||||||
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
|
if (typeof entry.cachedAt !== "number") {
|
||||||
return entry.value;
|
removeStoredJSONCache(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
} catch {
|
} catch {
|
||||||
|
removeStoredJSONCache(key);
|
||||||
return null;
|
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 {
|
function writeJSONCache<T>(path: string, value: T): void {
|
||||||
const key = jsonCacheKey(path);
|
const key = jsonCacheKey(path);
|
||||||
const entry: JsonCacheEntry<T> = { cachedAt: Date.now(), value };
|
const entry: JsonCacheEntry<T> = { cachedAt: Date.now(), value };
|
||||||
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
|
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
try {
|
window.localStorage.setItem(jsonStorageKey(key), JSON.stringify(entry));
|
||||||
window.localStorage.setItem(
|
} catch {
|
||||||
`${jsonStoragePrefix}${key}`,
|
// Ignore storage quota / privacy-mode failures; memory cache still works.
|
||||||
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> {
|
export async function getJSON<T>(path: string): Promise<T> {
|
||||||
|
|||||||
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[];
|
items: Post[];
|
||||||
cursor: string | undefined;
|
cursor: string | undefined;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
|
cachedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,16 +108,56 @@ type CachedStream = {
|
|||||||
* In-memory only: a full page reload starts fresh.
|
* In-memory only: a full page reload starts fresh.
|
||||||
*/
|
*/
|
||||||
const streamCache = new Map<string, CachedStream>();
|
const streamCache = new Map<string, CachedStream>();
|
||||||
|
const streamCacheTtlMs = 5 * 60 * 1000;
|
||||||
|
const streamCacheMaxEntries = 8;
|
||||||
|
|
||||||
function streamKey(params: PostStreamParams): string {
|
function streamKey(params: PostStreamParams): string {
|
||||||
return buildRealUrl(params);
|
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(
|
function cacheFirstPage(
|
||||||
params: PostStreamParams,
|
params: PostStreamParams,
|
||||||
page: PostListResponse,
|
page: PostListResponse,
|
||||||
): void {
|
): void {
|
||||||
streamCache.set(streamKey(params), {
|
writeStreamCache(streamKey(params), {
|
||||||
items: itemsOrEmpty(page.items),
|
items: itemsOrEmpty(page.items),
|
||||||
cursor: page.nextCursor,
|
cursor: page.nextCursor,
|
||||||
hasMore: !!page.nextCursor,
|
hasMore: !!page.nextCursor,
|
||||||
@@ -131,7 +172,7 @@ function cacheFirstPage(
|
|||||||
export function prefetchPostStream(params: PostStreamParams): void {
|
export function prefetchPostStream(params: PostStreamParams): void {
|
||||||
if (USE_MOCK) return;
|
if (USE_MOCK) return;
|
||||||
const key = streamKey(params);
|
const key = streamKey(params);
|
||||||
if (streamCache.has(key)) return;
|
if (readStreamCache(key)) return;
|
||||||
|
|
||||||
const url = buildRealUrl(params);
|
const url = buildRealUrl(params);
|
||||||
const cachedPage = readJSONCache<PostListResponse>(url);
|
const cachedPage = readJSONCache<PostListResponse>(url);
|
||||||
@@ -146,7 +187,7 @@ export function prefetchPostStream(params: PostStreamParams): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
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 [items, setItems] = useState<Post[]>(() => initialCached?.items ?? []);
|
||||||
const [hasMore, setHasMore] = useState(() => initialCached?.hasMore ?? true);
|
const [hasMore, setHasMore] = useState(() => initialCached?.hasMore ?? true);
|
||||||
const [isLoading, setIsLoading] = useState(() => !initialCached);
|
const [isLoading, setIsLoading] = useState(() => !initialCached);
|
||||||
@@ -156,6 +197,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
const cursorRef = useRef<string | undefined>(initialCached?.cursor);
|
const cursorRef = useRef<string | undefined>(initialCached?.cursor);
|
||||||
const hasMoreRef = useRef(initialCached?.hasMore ?? true);
|
const hasMoreRef = useRef(initialCached?.hasMore ?? true);
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
const restoredFromCacheRef = useRef(!!initialCached);
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
const fetchPage = useCallback(
|
||||||
async (resetting: boolean) => {
|
async (resetting: boolean) => {
|
||||||
@@ -225,7 +267,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore a previously-loaded view instantly (no reset, no refetch).
|
// Restore a previously-loaded view instantly (no reset, no refetch).
|
||||||
const cached = streamCache.get(streamKey(params));
|
const cached = readStreamCache(streamKey(params));
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setItems(cached.items);
|
setItems(cached.items);
|
||||||
cursorRef.current = cached.cursor;
|
cursorRef.current = cached.cursor;
|
||||||
@@ -233,8 +275,10 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
hasMoreRef.current = cached.hasMore;
|
hasMoreRef.current = cached.hasMore;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
restoredFromCacheRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
restoredFromCacheRef.current = false;
|
||||||
setItems([]);
|
setItems([]);
|
||||||
cursorRef.current = undefined;
|
cursorRef.current = undefined;
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
@@ -254,7 +298,11 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
// Persist loaded state so returning to this view restores it from cache.
|
// Persist loaded state so returning to this view restores it from cache.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
streamCache.set(streamKey(params), {
|
if (restoredFromCacheRef.current) {
|
||||||
|
restoredFromCacheRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeStreamCache(streamKey(params), {
|
||||||
items,
|
items,
|
||||||
cursor: cursorRef.current,
|
cursor: cursorRef.current,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
|||||||
Reference in New Issue
Block a user