Skip to content

Commit 8915db3

Browse files
committed
feat: respect staleMaxAge
1 parent 5a71511 commit 8915db3

File tree

2 files changed

+130
-2
lines changed

2 files changed

+130
-2
lines changed

src/cache.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,32 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = any[]>(
7171
entry.expires = Date.now() + ttl;
7272
}
7373

74+
const staleTtl =
75+
opts.swr && opts.staleMaxAge != null && opts.staleMaxAge >= 0
76+
? opts.staleMaxAge * 1000
77+
: undefined;
78+
79+
// When staleMaxAge is set, an entry is completely dead after maxAge + staleMaxAge
80+
const isFullyExpired =
81+
staleTtl !== undefined &&
82+
ttl > 0 &&
83+
Date.now() - (entry.mtime || 0) > ttl + staleTtl;
84+
7485
const expired =
7586
shouldInvalidateCache ||
7687
entry.integrity !== integrity ||
7788
opts.maxAge === 0 ||
7889
(ttl > 0 && Date.now() - (entry.mtime || 0) > ttl) ||
7990
validate(entry) === false;
8091

92+
// If fully expired beyond staleMaxAge, clear the stale value so SWR won't serve it
93+
if (isFullyExpired) {
94+
entry.value = undefined;
95+
entry.integrity = undefined;
96+
entry.mtime = undefined;
97+
entry.expires = undefined;
98+
}
99+
81100
const _resolve = async () => {
82101
const isPending = pending[key];
83102
if (!isPending) {
@@ -109,8 +128,16 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = any[]>(
109128
delete pending[key];
110129
if (validate(entry) !== false) {
111130
let setOpts: { ttl?: number } | undefined;
112-
if (opts.maxAge != null && opts.maxAge > 0 && !opts.swr /* TODO: respect staleMaxAge */) {
113-
setOpts = { ttl: opts.maxAge };
131+
if (opts.maxAge != null && opts.maxAge > 0) {
132+
if (opts.swr) {
133+
// With SWR, storage TTL must cover maxAge + staleMaxAge window
134+
if (opts.staleMaxAge != null && opts.staleMaxAge >= 0) {
135+
setOpts = { ttl: opts.maxAge + opts.staleMaxAge };
136+
}
137+
// If staleMaxAge is not set, no storage TTL (entry lives until manually evicted)
138+
} else {
139+
setOpts = { ttl: opts.maxAge };
140+
}
114141
}
115142
const promise = Promise.resolve(useStorage().set(cacheKey, entry, setOpts)).catch(
116143
(error) => {

test/index.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,107 @@ describe("cachedFunction", () => {
313313
errorSpy.mockRestore();
314314
});
315315

316+
it("SWR with staleMaxAge serves stale within window then expires", async () => {
317+
let callCount = 0;
318+
const fn = defineCachedFunction(
319+
async () => {
320+
callCount++;
321+
await new Promise((r) => setTimeout(r, 5));
322+
return `v${callCount}`;
323+
},
324+
{ maxAge: 0.01, swr: true, staleMaxAge: 0.02 },
325+
);
326+
327+
// Initial call
328+
expect(await fn()).toBe("v1");
329+
expect(callCount).toBe(1);
330+
331+
// Wait for maxAge to expire but within staleMaxAge window
332+
await new Promise((r) => setTimeout(r, 15));
333+
// SWR should return stale value while revalidating in background
334+
const r2 = await fn();
335+
expect(r2).toBe("v1"); // stale value served
336+
expect(callCount).toBe(2); // resolver triggered in background
337+
338+
// Wait for background resolve to finish
339+
await new Promise((r) => setTimeout(r, 10));
340+
341+
// Wait for both maxAge + staleMaxAge to fully expire
342+
await new Promise((r) => setTimeout(r, 40));
343+
// Now entry is fully expired — SWR should NOT serve stale, must await fresh value
344+
const r3 = await fn();
345+
expect(r3).toBe("v3");
346+
expect(callCount).toBe(3);
347+
});
348+
349+
it("SWR without staleMaxAge serves stale indefinitely", async () => {
350+
let callCount = 0;
351+
const fn = defineCachedFunction(
352+
async () => {
353+
callCount++;
354+
await new Promise((r) => setTimeout(r, 5));
355+
return `v${callCount}`;
356+
},
357+
{ maxAge: 0.01, swr: true },
358+
);
359+
360+
expect(await fn()).toBe("v1");
361+
await new Promise((r) => setTimeout(r, 50));
362+
// Even after long time, SWR without staleMaxAge should still serve stale
363+
const r2 = await fn();
364+
expect(r2).toBe("v1"); // stale value
365+
expect(callCount).toBe(2); // revalidating in background
366+
});
367+
368+
it("sets storage TTL to maxAge + staleMaxAge when SWR with staleMaxAge", async () => {
369+
const setSpy = vi.fn();
370+
setStorage({
371+
get: () => null,
372+
set: setSpy,
373+
});
374+
375+
const fn = defineCachedFunction(() => "value", {
376+
maxAge: 60,
377+
swr: true,
378+
staleMaxAge: 120,
379+
});
380+
await fn();
381+
expect(setSpy).toHaveBeenCalledWith(expect.any(String), expect.any(Object), { ttl: 180 });
382+
});
383+
384+
it("does not set storage TTL when SWR without staleMaxAge", async () => {
385+
const setSpy = vi.fn();
386+
setStorage({
387+
get: () => null,
388+
set: setSpy,
389+
});
390+
391+
const fn = defineCachedFunction(() => "value", {
392+
maxAge: 60,
393+
swr: true,
394+
});
395+
await fn();
396+
expect(setSpy).toHaveBeenCalledWith(expect.any(String), expect.any(Object), undefined);
397+
});
398+
399+
it("SWR with staleMaxAge: 0 never serves stale", async () => {
400+
let callCount = 0;
401+
const fn = defineCachedFunction(
402+
async () => {
403+
callCount++;
404+
return `v${callCount}`;
405+
},
406+
{ maxAge: 0.01, swr: true, staleMaxAge: 0 },
407+
);
408+
409+
expect(await fn()).toBe("v1");
410+
await new Promise((r) => setTimeout(r, 20));
411+
// staleMaxAge: 0 means the stale window is zero — entry is fully expired
412+
const r2 = await fn();
413+
expect(r2).toBe("v2");
414+
expect(callCount).toBe(2);
415+
});
416+
316417
it("waitUntil is used for SWR background revalidation", async () => {
317418
const waitUntilFn = vi.fn();
318419
let callCount = 0;

0 commit comments

Comments
 (0)