diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 448bf62ce..5b2ce5bb9 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -26,7 +26,9 @@ With `validateRedirects`, it follows redirects by hand, sends the credential only to origins the host trusts, and records the final origin for `getValidatedResponseSource`. With a cache, a GET is looked up first (a stale entry is revalidated with its `ETag`), a successful GET is stored, and a -successful write invalidates what it made stale. +successful write invalidates what it made stale. While the host's `fresh` +hook returns true, such as during a tool call that asked for fresh data, GETs +skip the lookup and their responses replace the cached entry. The host passes in a `CredentialProvider` (the current credential, an optional refresh ahead of expiry, and the 401 refresh), header rules that may reject a diff --git a/packages/toolkit-core/src/api-transport.test.ts b/packages/toolkit-core/src/api-transport.test.ts index 40ce0b8b3..1492ff57d 100644 --- a/packages/toolkit-core/src/api-transport.test.ts +++ b/packages/toolkit-core/src/api-transport.test.ts @@ -536,6 +536,37 @@ describe("cache", () => { expect(sent).toHaveLength(1); }); + it("skips the cache in a fresh scope and replaces the entry", async () => { + const cache = memoryCache(); + const { fetch, sent } = scriptedFetch([ + Response.json({ v: 1 }), + Response.json({ v: 2 }), + ]); + let fresh = false; + const api = transport(fetch, { cache, fresh: () => fresh }); + const stored = async (v: number) => + await vi.waitFor(async () => { + const lookup = await cache.lookup({ + url: ISSUES_URL, + requestHeaders: { authorization: "Bearer token-a" }, + identity: "id-a", + }); + expect( + lookup?.kind === "hit" ? await lookup.response.json() : undefined, + ).toEqual({ v }); + }); + + await api.request(ISSUES_URL); + await stored(1); + fresh = true; + expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 }); + await stored(2); + fresh = false; + + expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 }); + expect(sent).toHaveLength(2); + }); + it("revalidates a stale entry with its ETag", async () => { const response = await transport( scriptedFetch([new Response(null, { status: 304 })]).fetch, diff --git a/packages/toolkit-core/src/api-transport.ts b/packages/toolkit-core/src/api-transport.ts index 5201097c7..5c0ac1388 100644 --- a/packages/toolkit-core/src/api-transport.ts +++ b/packages/toolkit-core/src/api-transport.ts @@ -106,6 +106,12 @@ export type ApiTransportOptions = { ): Promise; }; cache?: TransportCache; + /** + * Whether the current scope skips cache reads, such as a tool call with + * `fresh: true`. Hosts back it with request-scoped state like + * `AsyncLocalStorage`. The response is still stored, replacing the entry. + */ + fresh?(): boolean; logger?: TransportLogger; /** Wrap each request, including cache lookups, in a host span. */ span?( @@ -663,7 +669,10 @@ export function createApiTransport( // A cached response has no validated origin, so discovery always // goes to the network. const lookup = - cache && method === "GET" && !options.validateRedirects + cache && + method === "GET" && + !options.validateRedirects && + !config.fresh?.() ? await cache.lookup({ url, // Before a refresh picks the token, so validate nothing yet.