Skip to content

[Due for payment 2026-10-23] [Onyx] Replace per-subscription bookkeeping with a single OnyxStore subscription registry #100271

Closed

Description

@melvin-bot

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 Set operations.
  • 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 OnyxSnapshotCache register/deregister work (7,050 ops) is eliminated entirely; per row the cost drops from four bookkeeping operations (connect + registerConsumer + deregisterConsumer + disconnect) to two Set add/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.ts is a Map<key, Set<listener>> for exact-key subscribers, plus a by-dependency index for multi-key listeners. subscribe is a Set.add, unsubscribe is a Set.delete, getState reads the cache, and notifyKey/notifyCollection dispatch a write.
  • useOnyx. Rebuilt on useSyncExternalStoreWithSelector, reading onyxStore.getState synchronously. With cached data it renders once, and it skips re-renders when the selected value is unchanged (===/deepEqual on the reference-stable value).
  • loading status. Kept, but scoped. useOnyx reports loading only on a key's first connection while a merge is in flight and nothing is cached yet, then loaded. A cached key is never blanked, and a later merge on a connected key or a cleared value does not return to loading.
  • Deletions. OnyxConnectionManager, OnyxSnapshotCache, and the now-unused createMemoizedSelector and memoizedShallowEqual (their only consumer was the old useOnyx).

Rollout plan

  1. react-native-onyx#833 — Add OnyxStore as a standalone, inert module. New lib/OnyxStore.ts and tests/unit/OnyxStoreTest.ts. Nothing calls it yet, so it is zero behavior change and safe to merge anytime. Depends only on OnyxCache and OnyxKeys.
  2. react-native-onyx#834 — Flip the subscription layer onto OnyxStore (atomic swap). Rewire the write/notify path and Onyx.connect/disconnect, rebuild useOnyx, and delete the modules listed above. The notify path, connect path, and useOnyx must 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.
  • loading behavior change. Scoped as described above. Analysis suggests no impact on E/App, but there are 183 usages of isLoadingOnyxValue to keep in mind. Stripping loading/loaded entirely is deferred to a separate investigation.
  • useSyncExternalStoreWithSelector. Undocumented in the main react package, but shipped in the React repo's standalone use-sync-external-store package 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 absorb OnyxDerived and 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 a Proxy that records property access. This is agreed as a follow-up after the store lands, not part of this scope.
  • loading/loaded removal — worth exploring separately.

Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1788270903471539

Issue OwnerCurrent Issue Owner: @roryabraham

Activity

  1. fabioh8010 commented on Sep 3, 2026

    @fabioh8010
    Contributor
  2. huult commented on Sep 4, 2026

    @huult
    Contributor

    Updates:

    @mountiny I’ll be reviewing this PR, right? If so, could you please assign it to me? Thanks!

  3. huult commented on Sep 4, 2026

    @huult
    Contributor

    Updates:

    @mountiny I’ll be reviewing this PR, right? If so, could you please assign it to me? Thanks!

    cc @roryabraham

  4. roryabraham commented on Sep 4, 2026

    @roryabraham
    Contributor

    done

  5. melvin-bot commented on Sep 7, 2026

    @melvin-bot
    Author

    @huult Whoops! This issue is 2 days overdue. Let's get this updated quick!

  6. 74 remaining items

  7. melvin-bot commented on Oct 22, 2026

    @melvin-bot
    Author

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  8. melvin-bot commented on Oct 22, 2026

    @melvin-bot
    Author

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
    • [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
  9. mallenexpensify commented on Oct 26, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @huult due $500 via NewDot

    Assuming the above is correct, comment/reopen if not

  10. huult commented on Oct 26, 2026

    @huult
    Contributor

    @mallenexpensify I’ve submitted a price increase request in #100271 (comment). Could you please take a look? CC @roryabraham

  11. huult commented on Oct 27, 2026

    @huult
    Contributor

    Assuming the above is correct, comment/reopen if not

    @mallenexpensify I reopened this ticket based on this comment.

  12. reopened this on Oct 27, 2026
  13. mallenexpensify commented on Oct 27, 2026

    @mallenexpensify
    Contributor

    @roryabraham made you issue owner for 👀 on @huult 's price increase request above.
    @huult , apologies for not seeing it, thx for reopening

  14. roryabraham commented on Oct 27, 2026

    @roryabraham
    Contributor

    @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.

  15. mallenexpensify commented on Oct 27, 2026

    @mallenexpensify
    Contributor

    @huult paid, summary updated above. Thx.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionDailyKSv2Task

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions