From 2709634f8647e64fe0421e02c777691365ccae0a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:27:24 +0000 Subject: [PATCH] feat(toolkit-core): Let a request scope bypass cached reads Add a `fresh` hook to the API transport. While it returns true, GETs skip the cache lookup and go to the network, and the response still replaces the cached entry, so later reads see it. Hosts back the hook with request-scoped state, such as AsyncLocalStorage around a tool call. Implements #1669 task 5.3. Co-Authored-By: BYK --- packages/toolkit-core/README.md | 4 ++- .../toolkit-core/src/api-transport.test.ts | 31 +++++++++++++++++++ packages/toolkit-core/src/api-transport.ts | 11 ++++++- 3 files changed, 44 insertions(+), 2 deletions(-) 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.