[WIP][POC] Store-based idea#789
Draft
fabioh8010 wants to merge 5 commits into
Draft
Conversation
Replace OnyxConnectionManager + OnyxSnapshotCache + the subscriber half of OnyxUtils with a single OnyxStore module. Add Onyx.getState/subscribe/ subscribeMembers primitives. Add useOnyxState hook for derived multi-key selectors with a virtual state proxy and manual dependencies. useOnyx is rewritten around the new store: no snapshot cache, no connection manager, no sourceValue, no third positional dependencies arg. Onyx.connect / connectWithoutView kept as thin wrappers over the new primitives so existing call sites compile. Net: -935 lines, +594 lines across lib/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssion, lazy storage load, legacy callback shape Several behavior gaps surfaced by the existing test suite: - Connect wrapper now dedups initial-fire vs racing notifyKey (fixes "called once with X" → previously "called twice: undefined then X"). - Collection-snapshot subscribers no longer fire per per-key remove during collection-batch ops; threaded `suppressCollectionSnapshot` through `notifyKey`/`prepareKeyValuePairsForStorage` (fixes setCollection-with-nulls firing the snapshot listener N+1 times). - useOnyx now lazy-loads from storage when a key is missing from cache (covers `StorageMock.setItem` bypass test path); `loading → loaded` transition relies on `hasCacheForKey` plus the post-subscribe callback fire. - `notifyCollection` reads the merged value from `cache.getCollectionData(key)` for per-member and exact-member fires (fixes mergeCollection callback delivering only the merge-input fields, dropping pre-existing fields). - Skippable-member subscriptions short-circuit in the connect wrapper (no listener wired, no callback fired) — matches old `subscribeToKey` skippable branch. - Snapshot-mode connect callback restores the legacy 3-arg `(value, key, partial)` shape. `partial` is computed locally in the wrapper from prev/current snapshots (no `sourceValue` flowing through the store, so no PR Expensify#679 staleness). - Initial-fire-only "empty collection → undefined" coercion matches `sendDataToConnection`; subsequent fires still deliver `{}`. - Per-member subscriptions fire `(undefined, undefined)` on initial when no members exist (legacy "no matching keys" signal). - Single-key subscriptions fire `(undefined, undefined)` on initial when nothing is cached, `(value, key)` otherwise. Test housekeeping: - Deleted obsolete `OnyxConnectionManagerTest.ts` and `OnyxSnapshotCacheTest.ts` (both target deleted modules). - Deleted the `keysChanged` describe block in `onyxUtilsTest.ts` (directly tests `OnyxUtils.keysChanged`/`keyChanged` which are gone — their behavior is now covered by integration tests in `onyxTest.ts`). - Removed one `getCallbackToStateMapping` test (deleted API). - `useOnyxTest.ts` lost two stale `onyxSnapshotCache` import lines. Currently 402/412 pass; remaining 10 are edge cases (pending-merge loading state, clear-during-load, render count assertions on key switch, two `Onyx.update` batching cases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ending-merge gate, lazy storage fallback
Per design decisions:
- useOnyx always returns `[value, {status: 'loaded'}]` — `status` retained for destructure compat; no loading transitions.
- `CollectionConnectCallback` narrowed to `(value, key)` — `sourceValue`/`partial` 3rd arg removed in line with PR Expensify#679's anti-pattern reasoning.
- `useOnyx` no longer lazy-loads from storage (eager-load handles the common case).
- `useOnyx` no longer surfaces pending-merge loading state.
- Direct writes to a collection root (`Onyx.merge(COLLECTION_KEY, ...)`) treated as opaque single-key writes; per-member subscribers no longer notified of root writes. The single test relying on this pattern is deleted.
Test housekeeping:
- onyxTest.ts: deleted the merge-on-collection-root test, updated 8 `toHaveBeenCalledWith`/`toHaveBeenNthCalledWith` assertions to drop the trailing 3rd-arg (sourceValue/partial).
- onyxClearWebStorageTest.ts: updated 3 assertions for the same reason.
- useOnyxTest.ts: deleted ~15 tests that exercised the removed loading transitions / lazy storage fallback / pending-merge gate.
Remaining failures (2): legacy timing-dependent `Onyx.update` tests that fired only once with the old deep-promise-chain subscribe but fire twice with the simpler new wrapper. To be updated next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… timing tests Merged `e6f1da9d` from origin/main, which reverts PR Expensify#770 (skippable collection member subscriptions). My branch had already removed the skippable check from the connect wrapper for similar reasons, so the revert lined up cleanly. The remaining conflicts were because origin/main still has `subscribeToKey`/`unsubscribeFromKey` (which my branch deleted in favor of OnyxStore); kept the HEAD (deleted) side. The legacy skippable tests were removed via the revert. Adjusted the two legacy-timing tests in onyxTest.ts to expect the new behavior: - `Onyx › update › should squash...` now expects 2 callbacks (initial undefined + post-update final state) instead of 1. The legacy ConnectionManager's deep-promise chain accidentally suppressed the initial fire; the new wrapper doesn't. - `Onyx › update › should return a promise that completes...` switched all `toHaveBeenNthCalledWith(1, ...)` assertions to `toHaveBeenLastCalledWith` to assert on the post-update final state without pinning specific call indices. The `sourceValue` 3rd argument was also dropped from every assertion. All 389 tests now pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n connect wrapper
Two Onyx-side fixes that resolve 15 expensify-app CI failures across six categories without behavioral regressions.
**1. `OnyxCache.getCollectionData` — empty collection after `Onyx.clear()`**
The `storageKeys.size > 0` heuristic was used as a proxy for "init done" to decide whether an empty collection returns the frozen `{}` snapshot or `undefined`. That works at startup but flips back to "not done" after `Onyx.clear()` wipes the storage-keys index, so a known-empty collection post-clear was returning `undefined`.
That caused `Onyx.connect({waitForCollectionCallback: true})` callbacks that bail on a nullish value (`if (!actions) return; allReportActions = actions;` in ReportActionsUtils, similar patterns elsewhere) to skip the post-clear notification, leaving module-level caches pointing at pre-clear data. Four DebugUtils GBR tests and the OptionListContextProvider migration test all stemmed from this.
Switched to `collectionSnapshots.has(collectionKey)`, which `setCollectionKeys()` seeds during `Onyx.init` and which survives `Onyx.clear()`. Updated the one `onyxCacheTest` assertion that pinned the old "post-init, no keys loaded → undefined" behavior to expect `{}` (the cleaner contract).
**2. `Onyx.connect` wrapper — initial-fire timing**
The legacy `subscribeToKey` chain (`deferredInitTask.then().then(getAllKeys).then(multiGet).then(sendDataToConnection)`) accidentally deferred initial-fire by ~3 microtask hops past in-flight writes. Test patterns like `tests/utils/TestHelper.getOnyxData` and the ~534 inline `Onyx.disconnect(connection)`-after-first-callback patterns across expensify-app's test suite rely on that timing.
The new store-based wrapper had a single `Promise.resolve().then(...)` hop, so callers that `Onyx.connect()` immediately after a fire-and-forget `Onyx.update()` were seeing pre-write state.
Added a `scheduleInitialFire` helper that chains three `.then()` hops — known-ugly, but documented as such and chosen over `setTimeout(0)` because Jest test bodies run entirely in microtask land via chained `.then()`s, so a macrotask-deferred initial fire would land after the test's assertions.
Same depth applied uniformly to snapshot mode, per-member mode, and single-key mode in `connect()`. One Onyx self-test (`should squash all updates of collection-related keys into a single mergeCollection call`) was updated back to expect a single dedup'd fire — matching the legacy contract that the new timing restores.
All 389 Onyx tests pass. All 15 originally failing expensify-app tests now pass with no other regressions in the affected suites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Details
Code is not production-ready and needs refinement⚠️
E/App PR: Expensify/App#91270
Related Issues
GH_LINK
Automated Tests
Manual Tests
Author Checklist
### Related Issuessection aboveTestssectiontoggleReportand notonIconClick)myBool && <MyComponent />.STYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)/** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Avataris modified, I verified thatAvataris working as expected in all cases)mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari