You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
queryCache in react.tsx is a module-level singleton — risks cross-instance bleed and unbounded growth #1025
packages/agents/src/react.tsx:17 has a module-level singleton:
constqueryCache=newMap<string,CacheEntry>();
This single Map is shared by every component that imports agents/react, which causes several issues:
1. Cross-instance cache bleed
The cache key is JSON.stringify([agentNamespace, name, ...deps]). If two useAgent hooks connect to the same agent namespace + name but with different auth tokens or user contexts, they share cache entries. A query result fetched with User A's token could be served from cache to User B's hook.
2. Stale entries survive HMR
During hot module reload in development, the module may be re-evaluated but the old Map instance can persist if the bundler preserves module state. Stale promises from a previous render cycle linger in the cache and get served to new hook instances.
3. Unbounded growth
Entries are only evicted by TTL expiry (expiresAt check in getCacheEntry). If you mount/unmount many hooks with distinct cache keys over time, the Map grows without bound. There is no LRU eviction, no max-size cap, and no cleanup on unmount.
4. No invalidation on reconnect
When a useAgent hook disconnects and reconnects (e.g., network flap, tab re-focus), the cache still serves old entries until TTL expires. The reconnected WebSocket may have fresh server state, but queries hit stale cache.
Possible Fixes
Scope the cache per hook instance — move the Map into the hook via useRef, so each useAgent call has its own cache. Eliminates cross-instance bleed but loses sharing.
Add an auth/context dimension to the key — include a token hash or user ID in createCacheKey so different auth contexts never collide.
Add a max-size cap + LRU — evict oldest entries when the Map exceeds a threshold.
Invalidate on reconnect — when the WebSocket onopen fires after a disconnect, clear cache entries for that agent+name.
WeakRef-based approach — store WeakRef to promises so GC can reclaim unused entries.
Thanks for the report. After reviewing, I believe this is working as designed:
Re: "No invalidation on reconnect" (#4) — The onClose handler already invalidates the cache and triggers a re-render to fetch fresh query params before reconnecting:
Every disconnect→reconnect cycle gets fresh query results.
Re: "Cross-instance cache bleed" (#1) — The cache key includes queryDeps, which exists specifically for this purpose. If two hooks connect to the same agent with different auth contexts, pass a differentiating value (e.g., user ID) in queryDeps. The sharing for identical keys is intentional — it deduplicates concurrent async query calls (e.g., React StrictMode double-renders).
Re: "Stale entries survive HMR" (#2) — Bundlers like Vite fully re-evaluate non-HMR-aware modules. Even if the Map did persist, onClose invalidation cleans it up before reconnect.
Re: "Unbounded growth" (#3) — The Map holds one entry per unique [namespace, name, ...deps] tuple. In practice that is a handful of entries. TTL expiry and onClose deletion keep it bounded. LRU machinery would be over-engineering here.
Activity
threepointone commentedon Mar 2, 2026
Thanks for the report. After reviewing, I believe this is working as designed:
Re: "No invalidation on reconnect" (#4) — The
onClosehandler already invalidates the cache and triggers a re-render to fetch fresh query params before reconnecting:Every disconnect→reconnect cycle gets fresh query results.
Re: "Cross-instance cache bleed" (#1) — The cache key includes
queryDeps, which exists specifically for this purpose. If two hooks connect to the same agent with different auth contexts, pass a differentiating value (e.g., user ID) inqueryDeps. The sharing for identical keys is intentional — it deduplicates concurrent async query calls (e.g., React StrictMode double-renders).Re: "Stale entries survive HMR" (#2) — Bundlers like Vite fully re-evaluate non-HMR-aware modules. Even if the Map did persist,
onCloseinvalidation cleans it up before reconnect.Re: "Unbounded growth" (#3) — The Map holds one entry per unique
[namespace, name, ...deps]tuple. In practice that is a handful of entries. TTL expiry andonClosedeletion keep it bounded. LRU machinery would be over-engineering here.Closing as working as designed.