Repository navigation
[Due for payment 2026-10-23] [Onyx] Replace per-subscription bookkeeping with a single OnyxStore subscription registry #100271
Description
Summary
Replace Onyx's per-subscription bookkeeping (OnyxConnectionManager + OnyxSnapshotCache) with a single OnyxStore listener registry, and rebuild useOnyx on useSyncExternalStoreWithSelector reading the eager cache directly.
Background
Onyx is the app's reactive state layer. React components read it with useOnyx. Non-render code (action creators, libraries) reads it with Onyx.connect/Onyx.connectWithoutView. Both are subscriptions, and both go through OnyxConnectionManager.
The manager pools connections so subscribers to the same key share one, and keeps per-subscription bookkeeping: a subscription → key map, a key → subscription-IDs map, and a last-delivered-value record. useOnyx adds a snapshot cache of the last result per (key, selector). On a write, the notification path walks the affected subscriptions and decides, per subscription, whether to fire.
Collection reads already use structural sharing on main: frozen, reference-stable snapshots that rebuild lazily.
Problem
When a heavy account holds thousands of records and a screen has hundreds of live Onyx subscriptions, any write — or any subscribed component mounting/unmounting — makes the app do more main-thread work the more components subscribe. That drops frames and delays interactivity.
Solution
One OnyxStore listener registry replaces the connection manager and the snapshot cache.
- Subscribing and unsubscribing become single
Setoperations. - A write becomes one lookup of the affected key's listeners, instead of a walk across every subscription.
- The per-subscription bookkeeping is gone, so main-thread cost no longer grows with the number of subscriptions.
Impact
Measured on a heavy account, online.
Web, 4× CPU throttling:
| Metric | Before | After | Change |
|---|---|---|---|
| ManualAppStartup (inbox) | 9,964 ms | 8,113 ms | −19% |
| ManualNavigateToInboxTab | 394 ms | 346 ms | −12% |
| ManualOpenReport | 1,034 ms | 700 ms | −32% |
| ManualSendMessageVisible | 1,011 ms | 805 ms | −20% |
Mobile (native, no throttling):
| Metric | Before | After | Change |
|---|---|---|---|
| ManualNavigateToInboxTab | 135 ms | 137 ms | +1% (noise) |
| ManualOpenReport | 375 ms | 282 ms | −25% |
| ManualSendMessageVisible | 831 ms | 601 ms | −28% |
LHN fast-scroll churn (heavy account, Android, identical scripted 40-fling):
- Subscription-lifecycle operations: 12,959 → 4,478 (−65%). The
OnyxSnapshotCacheregister/deregister work (7,050 ops) is eliminated entirely; per row the cost drops from four bookkeeping operations (connect + registerConsumer + deregisterConsumer + disconnect) to twoSetadd/delete. - Perceived smoothness: on a fast fling the baseline visibly stalls and flashes blank rows, while the store stays filled. This is a JS-thread effect — the lighter subscription work leaves headroom to render rows in time.
Simpler: useOnyx drops from 245 to 76 lines, and the library is ~775 net lines lighter.
Implementation details
- Registry.
lib/OnyxStore.tsis aMap<key, Set<listener>>for exact-key subscribers, plus a by-dependency index for multi-key listeners.subscribeis aSet.add, unsubscribe is aSet.delete,getStatereads the cache, andnotifyKey/notifyCollectiondispatch a write. - useOnyx. Rebuilt on
useSyncExternalStoreWithSelector, readingonyxStore.getStatesynchronously. With cached data it renders once, and it skips re-renders when the selected value is unchanged (===/deepEqualon the reference-stable value). loadingstatus. Kept, but scoped.useOnyxreportsloadingonly on a key's first connection while a merge is in flight and nothing is cached yet, thenloaded. A cached key is never blanked, and a later merge on a connected key or a cleared value does not return toloading.- Deletions.
OnyxConnectionManager,OnyxSnapshotCache, and the now-unusedcreateMemoizedSelectorandmemoizedShallowEqual(their only consumer was the olduseOnyx).
Rollout plan
- react-native-onyx#833 — Add
OnyxStoreas a standalone, inert module. Newlib/OnyxStore.tsandtests/unit/OnyxStoreTest.ts. Nothing calls it yet, so it is zero behavior change and safe to merge anytime. Depends only onOnyxCacheandOnyxKeys. - react-native-onyx#834 — Flip the subscription layer onto
OnyxStore(atomic swap). Rewire the write/notify path andOnyx.connect/disconnect, rebuilduseOnyx, and delete the modules listed above. The notify path, connect path, anduseOnyxmust flip together: without a coexistence layer, landing one alone leaves the others without notifications. This PR carries the measured wins.
Risks
- Atomic swap. PR 2 replaces the whole subscription framework at once and can't be split further. E/App tests, Onyx tests, and early runtime tests pass, but regressions are possible. Mitigation: run a full QA regression pass on PR 2 before landing.
- Eager loading. The design assumes data is eager-loaded into cache at startup (in place for several months). A storage fallback is implementable but out of scope here.
loadingbehavior change. Scoped as described above. Analysis suggests no impact on E/App, but there are 183 usages ofisLoadingOnyxValueto keep in mind. Strippingloading/loadedentirely is deferred to a separate investigation.useSyncExternalStoreWithSelector. Undocumented in the mainreactpackage, but shipped in the React repo's standaloneuse-sync-external-storepackage and used by Redux and Zustand. If it is ever removed, the implementation is small enough to vendor.
What this unblocks
One registry is the foundation for things the connection-manager model makes hard:
useOnyxState— a first-class multi-key derived-state hook. "Recompute when any of these keys change" falls out of the registry's dependency index, which lets us absorbOnyxDerivedand drop its persistence-backed restore and recompute plumbing.- Hierarchical subscription tracking (follow-up).
Map<key, Set<listener>>tracks subscriptions only at the OnyxKey level, so render filtering happens after listeners are notified and selectors run. A subscription path trie could prune unchanged branches using structural sharing and referential equality, so only genuinely affected listeners are notified at all. Discovering a selector's dependency paths without changing E/App call sites could be done by evaluating the selector against aProxythat records property access. This is agreed as a follow-up after the store lands, not part of this scope. loading/loadedremoval — worth exploring separately.
Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1788270903471539
Issue Owner
Current Issue Owner: @roryabrahamMetadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
fabioh8010 commentedon Sep 3, 2026
Updates:
huult commentedon Sep 4, 2026
@mountiny I’ll be reviewing this PR, right? If so, could you please assign it to me? Thanks!
huult commentedon Sep 4, 2026
cc @roryabraham
roryabraham commentedon Sep 4, 2026
done
melvin-bot commentedon Sep 7, 2026
@huult Whoops! This issue is 2 days overdue. Let's get this updated quick!
74 remaining items
melvin-bot commentedon Oct 22, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Oct 22, 2026
Payment Summary
Resolving PRs:
[No QA] Bump Onyx to 3.0.111 #100681
Bump Onyx to 3.0.117 #98956
Contributor: @fabioh8010 does not require payment (Contractor)
Reviewer: @huult owed $175 via NewDot
Contributor: @LukasMod does not require payment (Contractor)
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Oct 26, 2026
Payment Summary
Contributor+: @huult due $500 via NewDot
Assuming the above is correct, comment/reopen if not
huult commentedon Oct 26, 2026
@mallenexpensify I’ve submitted a price increase request in #100271 (comment). Could you please take a look? CC @roryabraham
huult commentedon Oct 27, 2026
@mallenexpensify I reopened this ticket based on this comment.
mallenexpensify commentedon Oct 27, 2026
@roryabraham made you issue owner for 👀 on @huult 's price increase request above.
@huult , apologies for not seeing it, thx for reopening
roryabraham commentedon Oct 27, 2026
@huult I'll approve a $500 price tag, because while it was 4 PRs, it was really just two batches of testing react-native-onyx/App pairs. We normally don't pay per-PR anyways, but this was a pretty substantial and high-impact refactor, so that's why I'll approve the increase.
mallenexpensify commentedon Oct 27, 2026
@huult paid, summary updated above. Thx.