Skip to content

One shared cache for read RPCs: useRpcData replaces useRPCLoad and useCachedResource - #29895

Open
chrisnojima wants to merge 4 commits into
masterfrom
nojima/HOTPOT-rpc-data-cache-clean
Open

chrisnojima wants to merge 4 commits into
masterfrom
nojima/HOTPOT-rpc-data-cache-clean

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Read RPCs now go through one cache, keyed by method and params and shared by every screen (shared/query). useRpcData replaces useRPCLoad and useCachedResource; both old hooks, reload-epoch, use-reload-on-reconnect and every per-resource cache map, sign-out resetter and "private cache for disabled instances" workaround are deleted.

All 28 call sites of the old hooks are migrated. Writes are unchanged: they stay plain useRPC / *RpcPromise calls and call refreshRpc(rpc) or setRpcData(...) afterwards where they need to.

const {data, error, loaded, settled, loading, loadCount, reload, reloadAndWait} = useRpcData(rpc, params, {
  enabled, select, staleMs, waitingKey, onError, refreshKey, reloadOnFocus, keep,
})
  • useLoadData(name, key, load, options) is the same thing for a load made of more than one call (loaded team, team channels, chat team members).
  • useCachedRpcData(rpc, params, select) only reads what another screen loaded. List rows use it for the teams list.
  • refreshRpc / refreshLoad say the data changed; forgetLoad drops data that no longer exists (a team left or deleted); loadRpcIfStale reloads only if a screen is loading it.

The rules every read now follows

  • Screens asking for the same method and params make one call and see one answer. A screen opened later shows the cached answer while it reloads.
  • A change notice during a load lets that load finish and runs one more, so a burst costs at most two loads. Several listeners hearing one notice count as one notice.
  • An optimistic write (setRpcData) drops a load already on the wire; the writer calls refreshRpc once its write has settled.
  • A mount just after a failed load waits out a 5-second backoff and then loads; stale checks respect the same backoff.
  • Forgotten data (a team left or deleted) is not reloaded by notices, only by a screen asking for it.
  • A lost link is not an error: the data stays loading and reloads after the reconnect handshake.
  • An account change empties the cache at once and drops late answers. Screens still mounted load again when the next account is in.
  • A load still on the wire when the last screen leaves finishes and is cached.
  • Results keep the identity of whatever did not change (objects, arrays, Maps, Sets, byte arrays), so memoized consumers bail.
  • Which notification makes which read stale lives in one table, shared/query/notifications.tsx.

Generator change

Generated *RpcPromise functions now carry their method name (rpc.method, used as the cache key) and take an optional AbortSignal third argument that cancels the call client-side through the engine. CallPort gains cancelSession. The regenerated files are included.

Behaviour changes

  • Shared data. Screens that held their own copy (activity levels, add-device, password, git, proof suggestions, settings) now show cached data first and reload behind it.
  • loaded means a load succeeded. settled is "the attempt is over, with data or an error", which is what useRPCLoad called loaded. Device revoke, add-device and the git row use settled.
  • Explicit reload() supersedes a load already on the wire (its answer is dropped). Notices use the finish-then-one-more rule instead.
  • Team notices (teams list, role map, loaded team) keep their 2-second leading/trailing debounce, now applied in the cache.
  • Mutual teams uses the waiting key of whichever screen loaded last.
  • The user-emoji cache's 32-entry cap is replaced by dropping data nothing has looked at for 5 minutes.
  • Failed loads are logged once per load, not once per mounted consumer.

Decisions for review

  1. In-house cache rather than a query library. I prototyped the same screens on TanStack Query first. It worked, but needed about 250 lines of glue to get this app's semantics (the burst rule, waiting for the handshake after a lost link, readers that must not load, test timing), relied on an experimental API for streamed results, and only paid off across a much larger migration. The in-house layer is about 690 lines with those semantics built in and no new dependency. Its hook shape is close to useQuery, so swapping the internals later would not touch call sites.
  2. Entries are emptied, never removed. A component can hold an entry it is not subscribed to (between render and effects, or while hidden), so deleting one would orphan it. Empty entries are a few fields each; data is still dropped when idle and on account change.
  3. A load is not cancelled when its last screen leaves. The service does the work either way, and cancelling meant the next mount asked again (the emoji suggestor remounting per :). The abort signal is still used when a load is superseded, forgotten, or the account changes.
  4. One test moved off a spy. use-proof-suggestions.test.tsx replaced the RPC function with a jest spy, which has no method name to key on. It now runs the same three cases against the fake engine.

Testing

  • yarn lint:all clean, 0 compiler bailouts. yarn test:unit: 471 suites, 6,251 tests pass.
  • The existing screen tests for devices, link previews, teams and chat team hooks pass without edits to the test files, apart from the one above.
  • 40 new tests cover the cache rules against the real engine and the fake service. Each rule was broken on purpose (41 mutations) and the tests caught every one.
  • Targeted desktop e2e on the current head: devices, teams browse/inner/channels/members, git repos and persisted settings, 31 flows pass.
  • teams-settings "description" and "showcase on my profile" fail on this branch and on the base branch alike (checked against a clean copy of base), so they are not in the list above.
  • Review history: correctness, security and simplification reviews on the first commit; the automated PR review (four findings) on the second; a second independent correctness review on the third (fourteen findings, mostly combinations of cache states). Each later commit is the fixes for the review before it. A third independent correctness review of the current head was still running when this was pushed, so the latest commit has not been independently reviewed yet.

Not verified

  • Mobile was not run. Incoming share is iOS-only and is covered by unit tests only.
  • Reload-on-refocus when stale has no dedicated unit test.
  • Desktop remote windows each have their own cache; none of them use these hooks today.

…eCachedResource

Read rpcs now go through one cache keyed by method and params (shared/query).
Screens asking for the same data share a call and an answer, a change notice
during a load costs one more load, a lost link reloads after the reconnect
handshake, an account change empties the cache, and leaving the last screen
cancels the call. Generated rpc functions carry their method name and take an
abort signal. The per-resource caches, reload epochs and reconnect hook are
removed.
A load still on the wire when the last screen leaves finishes and is cached.
Forgotten data no longer reads as loading. An optimistic write drops an older
load's answer. One notice heard by several listeners is one load, and team
notices are debounced again. loaded means a load succeeded (settled covers a
failed one), a failed load is reported once through onError, entries are
emptied rather than removed, and list rows read the teams list through a
plain cache reader.

This comment was marked as outdated.

A notice that arrives during a load no screen is waiting for keeps that
answer stale. reloadAndWait and loadRpcIfStale wait for a queued follow-up
load too. Idle data is dropped on a timer rather than when a new key happens
to be created. A disabled instance never reports loading.
One notice heard by several listeners is counted once for the load, the stale
flag and the debounce. A screen that mounts inside the failure backoff loads
when it ends, and stale checks respect it. An optimistic write no longer
strands a first load, and its writers refresh once the write settles. A
superseded or failed reload keeps the staleness it was meant to cure, a lost
link ends a wait with a load queued, forgotten data is not reloaded by
notices, and readers subscribe to the data alone. Emptied entries let go of
their closures, and data landing after its last screen left is still swept.
Device revoke waits for its own load before closing, git row errors clear on
the next load, and the bot role is asked for afresh per screen.
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.

2 participants