0048: query() — stale-while-revalidate over resource()
Status: Proposed
Context
resource() (ADRs 0024 / 0026) already handles the per-component
"fetch this and react to deps" case well. What it doesn't do is the
cross-component shared-cache pattern: two components reading
/users/42 simultaneously trigger two requests; navigating away and
back triggers a third; coming back from bfcache triggers a fourth.
Every app rebuilds the same wrapper:
- A module-scoped Map keyed on a serialized cache key.
- Watches on
pageVisibilitySignal,onlineSignal, andbfcacheRestoreSignal(ADRs 0039 / 0041) that selectively refresh stale entries. - A
staleTimedebounce so a tab-flip doesn't slam every query at once. - An
invalidate(key)escape hatch for mutations.
This is the "SWR" / "React Query" pattern. Every signals-based framework eventually ships one, and the wrappers all converge on the same shape. This ADR ships that shape now that the substrate (lifecycle signals from ADR 0039, online signal from ADR 0041) is in place.
Decision
Add query() and invalidateQuery() to @purityjs/core:
export type QueryKey = string | readonly unknown[];
export interface QueryOptions<T> {
/** Cache key. Same key = shared in-flight + shared data. */
key: QueryKey;
/** Fetcher. Called with the key and an abort-aware info object. */
fetcher: (key: QueryKey, info: ResourceFetchInfo) => T | Promise<T>;
/** Initial value before the first fetch resolves. */
initialValue?: T;
/** ms; entries younger than this skip revalidation triggers. Default 0. */
staleTime?: number;
/** Revalidate when the page becomes visible. Default true. */
revalidateOnVisible?: boolean;
/** Revalidate when the browser reports back online. Default true. */
revalidateOnReconnect?: boolean;
/** Revalidate on bfcache restore. Default true. */
revalidateOnBfcacheRestore?: boolean;
}
export function query<T>(options: QueryOptions<T>): ResourceAccessor<T>;
export function invalidateQuery(key: QueryKey): void;
The returned accessor is structurally identical to ResourceAccessor —
no new API to learn. Components that read query({...})() /
.loading() / .error() / .refresh() / .mutate() are reading
the same shape as resource().
Module-level cache
query() keys an internal Map<string, QueryEntry> by
JSON.stringify-style serialization of the QueryKey. The first
call with a given key creates a resource() instance and the
shared entry; subsequent calls with the same key return the same
ResourceAccessor. This is the dedup mechanism — N components
reading query({ key: ['user', 42] }) share one in-flight
request, one cached value, one error state.
Revalidation triggers
The first query() call lazily wires three module-scoped watches
on the lifecycle signals shipped in ADR 0039 + 0041:
pageVisibilitySignal— when the value flips to'visible', iterate the cache andrefresh()every entry whosestaleTimehas elapsed sincelastFetchedAtand whoserevalidateOnVisibleistrue.onlineSignal— same shape, onfalse → truetransition, honourrevalidateOnReconnect.bfcacheRestoreSignal— same shape, on every increment, honourrevalidateOnBfcacheRestore.
Each trigger is opt-out per-entry. The defaults match the SWR convention: revalidate aggressively in the foreground, never in the background.
staleTime semantics
staleTime is a trigger debounce, not a TTL. An entry is
"fresh" for staleTime ms after the last successful fetch.
Within that window, the revalidation triggers skip the entry.
Outside the window, the next trigger refreshes it. Reads always
return the cached value immediately — there's no
"this query is stale, await first" behaviour. That stays as
resource()'s loading() accessor for the initial fetch.
Default staleTime is 0 — every trigger refreshes every
matching entry. Apps that want "max once per minute" set
staleTime: 60_000.
invalidateQuery(key)
Imperative cache-bust + refetch. Used after mutations:
const saveUser = serverAction('/api/user', async (data) => {
/* ... */
});
await saveUser.invoke({ id: 42, name: 'X' });
invalidateQuery(['user', 42]); // mark stale + refresh
Resets the entry's lastFetchedAt to 0 and calls
entry.resource.refresh(). No-op if the key isn't in the cache.
First-call-wins config
Same trade-off as ADR 0039's broadcastSignal. Two query() calls
with the same key but different options (e.g. different
staleTimes) — the first wins; subsequent calls reuse the
cached entry and ignore their config. Mismatches log a
console.warn so the silent footgun is visible in dev.
SSR
In an SSR context, query() bypasses the module-level cache
entirely and delegates straight to resource(fetcher, { key }).
This is load-bearing for correctness: the module Map is
process-global and lives across requests, so caching there would
let one request's resolved data leak into another user's render.
resource()'s own per-SSRRenderContext cache (keyed by the same
key) handles dedup within a single render and pairs the
resolved value to the client for hydration. The revalidation
triggers are not wired on the server (no window). On the client,
the module cache is page-lifetime as described above.
Explicit non-features
- No reactive
keyin v1.keyis a staticQueryKey. Apps with dynamic data (e.g. switching the user ID a route shows) callresource(() => userId(), fetcher)directly, or wrapquery({ key: ['user', userId()] })inside acomputewith the understanding that each new key creates a new cache entry. Reactive-key support lands in a follow-up once the shape is exercised. - No
gcTime/ cache eviction. Entries live for the page lifetime. Apps that worry about memory pressure callinvalidateQuery(key)themselves; v1 is "the cache only grows," matching the dedup-by-key behavior. Eviction is a follow-up. - No optimistic mutations / mutation queue. Apps use
resourceFromQuery.mutate(next)(inherited fromResourceAccessor) for optimistic updates and roll back in the server-action error handler. A dedicatedmutation()oroptimistic()helper is a separate ADR. - No window-focus trigger. Only
pageVisibilitySignal(visibility) — not the olderfocusevent. The new visibility model from ADR 0039 covers the practical SWR "user came back to the tab" case;focusadds noise (every alt-tab cycle without leaving the tab) without meaningful value. - No subscription-aware refcounting. Entries don't track
how many components read them. Triggers refresh based on
cache membership alone. Combined with no
gcTime, this is intentionally simple — apps that want fine-grained control bypassquery()and useresource()directly.
Consequences
Positive:
- Closes the most-requested "we have parts but not the glue"
loop.
resource()+ lifecycle signals +query()= production-grade SWR. - Drop-in compatible with
resource()— same accessor shape, callers don't learn a new API. - Exercises ADR 0039 (
pageVisibilitySignal,bfcacheRestoreSignal) and ADR 0041 (onlineSignal) with a real consumer. Validates the lifecycle-signal design under realistic load. - ~250 LOC + ~150 LOC tests. Tree-shakes when unused.
Negative:
- Module-level cache means tests need a reset hook (
@internal _resetQueryCache()— same pattern as other module-state signals). - First-call-wins on config is a footgun. Mitigated by dev-time
warn on mismatch (same pattern as
broadcastSignal). - Static-only
keyis a real limitation for the "switching IDs" use case. Documented; the workaround (resource()directly) is one line. Reactive-key follow-up can ship without breaking the v1 API. - The three revalidation triggers depend on the lifecycle
signals being correct. A bug in
pageVisibilitySignalsurfaces as silent over-refetching across everyquery()caller. Bounded by the lifecycle-signal singletons' test coverage.
Neutral:
- New exports:
query,invalidateQuery,QueryKey,QueryOptions<T>. The accessor staysResourceAccessor<T>. - Bundle delta: ~500 bytes gzipped (estimate).
- Tests cover: dedup-by-key, first-call-wins warn, three
trigger types,
staleTimedebounce,invalidateQuery, no-trigger options, SSR delegation.
Alternatives considered
Build query() as a separate @purityjs/query package.
Rejected: depends transitively on five @purityjs/core internals
(resource, lifecycle signals, online signal). Cross-package
versioning churn outweighs the ~500-byte tree-shake benefit.
Tree-shaking inside core works fine.
Make the cache request-scoped on the client too. Rejected: SWR's whole point is durable cross-component cache. Request-scoping would require app-level glue (a "QueryProvider" component threading context) — exactly the boilerplate this ADR removes. Module-scope page-lifetime cache is the standard shape; eviction follow-ups address the "what about long-lived sessions" concern.
Bake key into the fetcher signature — fetcher(info)
instead of fetcher(key, info). Rejected: the key is data the
fetcher almost always needs (build the URL from it, build the
request body from it). Passing it explicitly avoids the closure
juggle and matches the SWR / React Query precedent.
Optimistic mutations + rollback in v1. Rejected:
optimistic-update semantics deserve their own ADR. The mutation
pattern is serverAction() already; the missing piece is the
optimistic local update + rollback-on-error pairing, which has
its own design space (key-based invalidation vs. function-based,
multi-key invalidation, snapshot-rollback granularity). Ships in
ADR 0049 or later.
Auto-evict entries with no readers (refcount + GC). Rejected
for v1: per-entry refcounting needs a WeakRef-style or
disposable accessor pattern that breaks the
ResourceAccessor shape. Eviction policy + refcount is a
follow-up; v1 ships the cache-only-grows shape with
invalidateQuery as the user-driven escape hatch.
Take a thunk for key (key: () => QueryKey) but don't track
reactivity. Rejected: looks like reactivity but isn't.
Confusing surface; the static QueryKey is honest about what v1
does.