DATA_VERSION cache invalidation — how it works, and how to scope it #
Status: working as designed; scoping is deferred. This note is for future research if the coarse invalidation ever becomes a performance problem. There is no correctness bug today — only over-fetching.
How it works #
The app has one app-wide "cache epoch": a single reactive counter that every read-side data query subscribes to, and that every write bumps.
-
The counter (
src/session.rs):pub static DATA_VERSION: GlobalSignal<u32> = Signal::global(|| 0); pub fn bump_data_version() { *DATA_VERSION.write() += 1; } -
Every read subscribes to it via the
use_data_resource!macro (src/session.rs), which wraps Dioxus'suse_resourceand folds a read ofDATA_VERSION()into the resource's reactive dependency set:// explicit-deps form let __data_version = DATA_VERSION(); // read == subscribe use_resource(use_reactive!(|(deps.., __data_version)| { /* query */ })) // plain-closure form use_resource(move || { let _ = DATA_VERSION(); /* query */ }) -
Why a bump refetches everything:
use_resourcere-runs its future whenever any signal it read last run changes. Because everyuse_data_resource!reads the same globalDATA_VERSION, incrementing it marks every mounted data resource dirty at once, so they all re-query. Onebump_data_version()refreshes the whole view with no reload.
This is the "refetch on write" layer. Separately, a few genuinely live views use
GraphQL subscriptions via use_live (src/subscription.rs) for push updates:
the open poll's tally (vote.rs), the speaker list (speak.rs), and the
screen/follow views' active relation (screen.rs).
What bumps the counter #
Roughly 24 sites, all after a successful mutation. Grouped:
- Session-wide (must stay global): login (
auth.rs), logout (layout.rs), pull-to-refresh (pull_refresh.rs). - Node-tree / folder-scoped: editor save (
editor.rs), sort save (sort.rs), folder new-child insert + attachable-lock toggle (folder.rs), deep-copy paste (folder.rs), content delete (content.rs), file delete (file.rs). - Vote/poll (context-scoped): add/delete question, propose amendment, close
poll, create poll (
vote.rs). NB a plain vote cast does not bump globally —PollAppuses a localrefreshsignal + ause_livesubscription. - Member (context-scoped): save member edit, invite, bulk roster import,
single invite, remove member, promote/activate toggles (
member.rs), and accept/decline invitation (layout.rs).
Who refetches on every bump #
Every use_data_resource! consumer, app-wide, including the highest-fanout ones:
the page resolver (loader.rs), the drawer tree (layout.rs), the contexts list
(layout.rs), the social feed (social.rs), comment threads (comments.rs),
folder children (folder.rs), the member roster (member.rs), and every visible
admin poll tally (admin.rs). So editing one member's name re-runs all of them.
The cost #
The counter has no notion of which node, context, or table changed, so a single mutation refetches every mounted resource, not just the affected ones. That is the over-fetching (extra latency + Hasura load + wasm work) that scoping would remove.
Why it is not already scoped (the load-bearing constraint) #
The global bump is doing double duty: it is also the cross-view consistency
mechanism for views that have no live subscription. The clearest case is
AdminApp (the results table, admin.rs): it has no use_live and no local
refresh, so its poll tallies stay current only because an unrelated global bump
reaches them. Narrow the bump and those views go stale until a reload.
Several views also legitimately span multiple contexts — the contexts list and
drawer tree (layout.rs), the social feed (social.rs), profile memberships
(profile.rs), recent items (home.rs), the page resolver (loader.rs) — so
scoping their invalidation to a single context id would miss updates.
Proposed low-regression scoping (if perf demands it) #
Additive, opt-in, with the global bump kept as a fallback so nothing regresses until a call site is deliberately migrated.
- Add a per-context registry alongside the global counter: keep
DATA_VERSIONas a global epoch; add e.g.CONTEXT_VERSIONS: GlobalSignal<HashMap<String, u32>>plusbump_context_version(ctx_id).- Caveat: a
HashMap-in-a-GlobalSignalhas whole-map reactivity (any change wakes every reader), which defeats the point. Prefer a registry of per-contextSignals (a lazily-populated map of signals), so each context's resources subscribe to a distinct signal.
- Caveat: a
- Add a
scope =arm touse_data_resource!that reads BOTH the global epoch AND that scope's counter. Existing (unscoped) call sites are left exactly as-is: they keep reading only the global epoch, so they refetch on every global bump exactly like today. - Keep
bump_data_version()bumping the global epoch, so any un-migrated bump site and the must-stay-global sites still invalidate everything. - Migrate incrementally, one context-local flow at a time, cheapest and highest-fanout first (folder insert/edit, member edits). A scoped bump then increments only that context's counter, sparing unrelated contexts' resources.
Because each scoped resource reads both the global epoch and its scope counter, correctness degrades gracefully: the worst case is an over-refetch (today's behavior), never a stale view.
Do NOT scope these #
auth.rslogin,layout.rslogout — must flush all scopes (clear the registry + bump the epoch) so no previous-session data lingers.pull_refresh.rs— a deliberate whole-view refresh.- Cross-context consumers (contexts list, drawer tree, social feed, profile memberships, recent, page resolver) — leave UNSCOPED so they keep refetching on every bump.
- Any vote bump (
vote.rscreate/close) — do not narrow untilAdminAppgets its ownuse_livesubscription on the context's vote nodes (mirrorPollApp's), or its tallies go stale.
Verification #
Reactivity regressions here are only observable in a real browser — the Servo
test harness has missed exactly this class of breakage before (see the
use_reactive vs keyed-remount note in loader.rs). So any migration must be
verified interactively, not just by the unit tests.
Rejected alternative #
Keying resources by a content hash instead of a version counter — fights
use_resource's identity model and risks lost invalidations (a stale view),
which is worse than over-fetching.