Skip to content

refactor(cache): move the cache lifecycle inside useCachedResource - #29614

Draft
chrisnojima wants to merge 1 commit into
nojima/HOTPOT-arch-09-engine-handlersfrom
nojima/HOTPOT-arch-08-cached-resource
Draft

refactor(cache): move the cache lifecycle inside useCachedResource#29614
chrisnojima wants to merge 1 commit into
nojima/HOTPOT-arch-09-engine-handlersfrom
nojima/HOTPOT-arch-08-cached-resource

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

util/use-cached-resource.tsx is a genuinely deep module, but it resets whatever cache object it is handed when enabled is false — an ordering constraint on the caller, stated in no type. So every consumer had to answer "which cache object do I hold this render?" before it could call the hook.

Six consumers each hand-rolled the same ~40-line scaffold: a module-scope cache map, a registerExternalResetter for sign-out, an invalidation-listener Set, a cache-map gate, a debounce(2000, {leading, trailing}), 3–5 useEngineActionListeners, and a recycler inside load(). A seventh (teams/common/activity.tsx) opted out entirely.

chat/conversation/team-hooks.tsx gated only on forceLocalCache, not on !enabled || !validTeamID.

Change

useCachedResource({namespace, cacheKey, load, staleMs, invalidateOn, recycle})

cacheKey: undefined means no shared entry, so the shadow-instance hazard becomes structurally unrepresentable rather than merely corrected. The namespace owns the map and the resetter; invalidateOn folds the ad-hoc listener sets into one coalesced, epoch-allocating trigger.

Ten consumers de-scaffolded. Gone: 3 cache-map gates, 4 hand-rolled debounces, 2 invalidation Sets, 8 registerExternalResetters, 1 hand-rolled LRU, 2 in-load() recyclers, ~19 useEngineActionListener calls.

Did the team-hooks bug reproduce?

The divergence is real, but a test driving it through the public API passeduseChatTeamMembers exposes no enabled parameter, so the two flags can only disagree for an invalid teamID whose shared entry no loader uses. A latent hazard, not a live bug. What did reproduce against the pre-fix code: handing the shared cache to a disabled instance reset loadedAt to 0 and caused a second RPC inside the stale window. That now lives as a test: "a disabled instance never touches the shared entry".

Regressions caught in review, each mutation-checked

  • effect: 'clear' didn't cancel the queued debounced reload, so a deleted or left team was re-fetched up to 2s later and the cleared entry repopulated. A new regression for team-channels and chat-team-members, whose old reloads were undebounced. Now clearNow cancels first.
  • Explicit namespace.invalidate() broadcasts were routed through the same debounce, so after creating a channel the flash-to-empty lasted up to 2s instead of one round trip. Broadcasts now go straight through — they are already one event carrying one epoch. (Deliberate deviation from "fold the listener Sets into the debounced trigger"; the debounce-before-epoch rule still holds for the engine path it was protecting.)
  • A trailing debounce queued for the old key fired against the new key after a switch, superseding its in-flight load. Now cancelled on [cache, cacheKey], not only on unmount.
  • cacheKey was silently discarded when namespace was omitted; duplicate invalidateOn types double-subscribed. Both latent, both fixed.

⚠️ Not done

  • util/use-rpc-load.tsx is untouched, not deleted. Stopped deliberately rather than half-migrate 16 call sites across 12 modules. The blockers are semantic: enabled: false means "skip auto-load, keep data" there and "go inert and blank" in useCachedResource (differs at device-revoke, bot/install, featured-bots, incoming-share); when: 'manual' has no equivalent (settings/chat, git/row, devices/index); setData, error and onResult are all in live use. Its own PR, with its own gate.
  • The util/recycle.tsx consolidation (the optional part) is skipped. The recycle option exists and the two in-load() recyclers use it; the other four are untouched.

Pre-existing limitation, neither introduced nor fixed

On an engine notification, N mounted consumers of one entry each allocate their own epoch and supersede each other — N RPCs for one event. Only the invalidate() path shares an epoch. The old hand-rolled code behaved identically.

Validation

lint:all clean — 0 bailed out, 0 whole-props deps, tsc clean both projects.
jest --runInBand231 suites / 2257 tests (baseline 230 / 2236).

useCachedResource resets whatever cache object it is handed while disabled. That
is an ordering constraint on the caller stated in no type, so eight consumers
each answered "which cache object do I hold this render?" with the same ~40-line
scaffold: a module-scope map, a registerExternalResetter, an invalidation
listener Set, a useXCacheMap gate, a debounce, several useEngineActionListeners,
and a recycler inside load().

The hook now takes a namespace and a key instead of a cache object. The
namespace owns the map and the sign-out reset. `key: undefined` means the
instance has no entry: it shares nothing and resets nothing, so the shadow
hazard is unrepresentable rather than something each caller has to gate against
- chat/conversation/team-hooks gated on forceLocalCache alone, not on
!enabled || !validTeamID like its sibling, and could seed and reset a shared
entry an instance had no business holding.

invalidateOn folds the engine listeners, the invalidation Sets and the debounce
into one trigger. The debounce runs BEFORE the epoch is allocated, so a burst
that coalesces into one reload is one event rather than several that supersede
each other; an invalidate() broadcast carries its own epoch through so every
consumer of it shares a single rpc.

recycle moves the identity recyclers out of load(), where they had to reach into
the cache object to see the previous value.

Team channels and chat team members now coalesce their reload bursts the way the
team and the teams list already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rccpV5nLxxC5opF5xzrz7
@chrisnojima
chrisnojima force-pushed the nojima/HOTPOT-arch-08-cached-resource branch from c64a92d to b2fed16 Compare September 11, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant