9.0 KiB
Caching & the reactive query result
Deep reference for the WebApp resource cache and the two refresh tools. The SKILL.md Freshness & caching section is the summary; this is the full behavior.
Surface caveat up front: everything about caching below is the WebApp surface. On uncached surfaces (Mosaic, OpenAI) there is no cache — see Uncached surfaces at the end. The
query/mutatenamespace shape is identical on every surface, so call sites stay portable.
1. Caching is ON by default (WebApp) — do NOT reinvent it
Every sdk.graphql!.query() on WebApp is cached automatically. There is no opt-in flag, no
createCachedClient factory, and no /cache import subpath — if you have heard of those,
they do not exist in @salesforce/platform-sdk. There is also no need for React Query, SWR,
localStorage, or any hand-rolled memoization. If you find yourself writing a cache, stop
— it already exists.
Default policy: max-age with a 300-second TTL (DEFAULT_MAX_AGE_SECONDS = 300).
| Situation | Behavior |
|---|---|
| Cache hit (entry < 300s old) | Return cached data immediately — no network call |
| Stale (entry > 300s old) | Treated as a miss → fetch from network → write back (300s TTL) |
| Miss (no entry) | Fetch → write to cache (300s TTL) → return |
mutate() is never cached — it is a pass-through to the network.
What gets cached
Only successful responses with a non-empty data object are written:
dataisnull, missing, or{}→ NOT cached (an emptydatausually means a transient server condition; caching it would poison the cache for the full TTL).- Response carries a non-empty
errorsarray → surfaced as an error and NOT cached.
Cache key
The key is stableJSONStringify({ query, variables, operationName }).
cacheControldoes NOT affect the key. The same query + variables share one cache entry no matter what policy each call passes. A"no-cache"call and a default call read/write the same slot.- The query is keyed by its raw string — two semantically identical queries with different
whitespace produce different entries. Reuse the same
gql-tagged constant; do not re-template the same query inline per call. - Variables are deep-cloned at call time, so mutating your variables object afterward does not desync the key from the request body. Variables must be JSON-serializable (circular refs / BigInt throw a typed error).
Shared across SDK instances by baseUrl
Cache bundles are deduped in a module-level registry keyed by the resolved baseUrl. A query
run through one createDataSDK() instance is a cache hit on another instance targeting the same
host. Independently-built features that each create their own SDK do not issue redundant
network requests for the same data.
The per-instance fetch pipeline (CSRF, onStatus) stays isolated — a cache miss routes
through the calling SDK's own fetch, so per-instance request behavior is preserved.
Practical implication: you do not need to hoist createDataSDK() into a singleton purely
to share cache. Calling it per-feature is fine; the cache is shared by host underneath. (A
singleton is still reasonable for other reasons.)
2. The two refresh tools (keep them distinct)
There are two unrelated mechanisms for getting fresh data. They have different shapes and
different mental models. Do not conflate result.refresh() (a method on a live handle) with
cacheControl: "no-cache" (a per-call option).
| Reactive refresh | Call-site cache control | |
|---|---|---|
| API | result.subscribe(cb) + result.refresh() |
cacheControl on the query options bag |
| Lifetime | Long-lived handle; subscription persists until you unsubscribe | One-shot, fire-and-forget per call |
| Pushes updates? | Yes — subscribe fires on every subsequent snapshot |
No — you read the returned value once |
| PR | #502 | #537 (W-22514759) |
| Use when | A mounted component should react to cache updates or re-fetch on demand | "This specific read must bypass / only-use / re-TTL the cache" |
2a. Reactive refresh — subscribe + refresh
query() resolves a QueryResult<T> — a snapshot (data/errors) plus subscribe(cb)
and refresh(). The contract (type shape, the independent-subscription and fire-on-subsequent
semantics) lives in sdk-api.md; this
section is about when and how to use it.
The lifecycle is: read the initial snapshot, register a subscriber for later snapshots, and always unsubscribe when the consumer goes away (component unmount, effect re-run, view teardown) so the subscription doesn't leak:
const result = await sdk.graphql!.query<GetAccountsQuery>({ query: GET_ACCOUNTS, variables });
render(result.data, result.errors); // initial snapshot
const unsub = result.subscribe(({ data, errors }) => render(data, errors)); // live updates
await result.refresh(); // force re-fetch → pushes to subscribers
// later, when the consumer tears down:
unsub();
Managing the subscription in a framework. Whatever reactive/lifecycle primitive your UI
layer uses — a React effect, a Vue/Svelte lifecycle hook, a web-component
connected/disconnectedCallback, a store teardown — the same three obligations hold:
- Kick off
query()on mount/setup and store the resolvedresultso you can callrefresh()on it later (e.g. behind a "Refresh" button). - Push each
subscribesnapshot into your reactive state so the view re-renders. - Run
unsub()in the teardown path, and guard against a late-resolvingquery()writing state after teardown (track acancelledflag). The subscription does not fire on registration, so set initial state from the awaited snapshot, not from the subscriber.
Refresh after a mutation — mutations have no subscribe/refresh. To make a list reflect a
write, hold the query result and call result.refresh() after mutate() resolves:
await sdk.graphql!.mutate({ mutation: CREATE_ACCOUNT, variables: { input } });
await accountsResult.refresh(); // re-fetches, bypasses cache, pushes to subscribers
2b. Call-site cache control — the cacheControl option
cacheControl is a one-shot policy override on the query options bag. The type and the precise
per-value behavior (including how an only-if-cached miss surfaces as DataNotFoundError) are
the SDK contract — see
sdk-api.md. In short:
"no-cache"— skip the cache read, always hit the network, still write back."only-if-cached"— cache-only; a miss surfaces aDataNotFoundErroronresult.errors(no network, no throw). Handle it by rendering an empty state — do not fall back to the network.{ type: "max-age", maxAge: <seconds> }— custom TTL instead of 300s.
It does not affect the cache key — the same query+variables share one slot regardless of the policy each call passes. Which policy to reach for is the strategy table below.
3. Choosing a strategy
| Goal | Reach for |
|---|---|
| Default reads, freshness within ~5 min is fine | Nothing — default 300s cache |
| Component stays mounted, should reflect cache updates / on-demand re-fetch | subscribe + refresh (2a) |
| Re-fetch a held query after a mutation | result.refresh() (2a) |
| One-off "this read must be fresh" (button, post-mutation one-shot) | cacheControl: "no-cache" (2b) |
| Offline-first — render only cached data, tolerate a miss as an empty state (no network fallback) | cacheControl: "only-if-cached" (2b) |
| Data changes faster than 5 min | cacheControl: { type: "max-age", maxAge: N } (2b) |
no-cache (2b) and refresh() (2a) both bypass the cache and write back; the difference is
refresh() pushes to existing subscribers and is a method on a live handle, while
no-cache is a fresh one-shot call with no subscribers.
Uncached surfaces (Mosaic, OpenAI)
- No cache exists. Every
query()is a network request. cacheControlis silently ignored —"no-cache","only-if-cached", andmax-agehave no effect (notably,only-if-cachedwill not raiseDataNotFoundErrorbecause there is no cache layer to miss).subscribeis real but only emits in response torefresh()— there is no background cache to push updates, sorefresh()is the sole source of new snapshots, and eachrefresh()costs one network request.- PR #502 flags the uncached
subscribe/refreshedge semantics (re-emit-to-all, error fan-out, ordering vs concurrent refresh) as a known soft spot / follow-up. Document conservatively; do not over-promise behavior there.
The namespace shape (query/mutate, options bag, QueryResult) is identical across surfaces,
so the same call site runs on WebApp and uncached surfaces alike.