Remove macro/micro tasks during subscriber update#724
Conversation
# Conflicts: # lib/Onyx.ts # lib/OnyxMerge/index.native.ts
# Conflicts: # lib/Onyx.ts
|
LGTM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6318f5ef65
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
lib/OnyxUtils.ts
Outdated
| if (lastConnectionCallbackData.has(mapping.subscriptionID) && valueToPass === lastValue) { | ||
| // If the subscriber was already notified (e.g. by a synchronous keyChanged call), | ||
| // skip the initial data delivery to prevent duplicate callbacks. | ||
| if (lastConnectionCallbackData.has(mapping.subscriptionID)) { |
There was a problem hiding this comment.
Preserve initial hydration after synchronous updates
Keep sendDataToConnection() from returning solely on lastConnectionCallbackData.has(...); this now drops the initial hydration callback whenever any synchronous keyChanged/keysChanged ran first. In the common race where Onyx.connect() is followed by an immediate Onyx.set() in the same tick, the subscription gets marked as "already notified" and the later storage-backed init payload is skipped even if it contains additional data (especially for collection subscribers), leaving subscribers with a partial state until a future update arrives.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
In this case, by the time the hydration promise resolves, it would only have equal or older data, making the skip correct.
There was a problem hiding this comment.
@VickyStash I asked Claude about this comment and according to it it's valid, here's a unit test it designed for me
diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts
index 64caec5..94aff05 100644
--- a/tests/unit/onyxTest.ts
+++ b/tests/unit/onyxTest.ts
@@ -140,6 +140,51 @@ describe('Onyx', () => {
});
});
+ it('should deliver full collection data when connect() is followed by immediate set() of a single member in the same tick', () => {
+ const mockCallback = jest.fn();
+ const collectionKey = ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION;
+
+ // Write collection members directly to storage, bypassing Onyx cache values.
+ // This simulates data that exists in persistent storage but hasn't been loaded into cache yet
+ // (e.g. from a previous session).
+ return StorageMock.setItem(`${collectionKey}1`, {ID: 1, value: 'one'})
+ .then(() => StorageMock.setItem(`${collectionKey}2`, {ID: 2, value: 'two'}))
+ .then(() => StorageMock.setItem(`${collectionKey}3`, {ID: 3, value: 'three'}))
+ .then(() => {
+ // Register the keys in Onyx's key cache so getAllKeys() can discover them.
+ // We intentionally do NOT add values to the cache — only keys — to simulate
+ // data that is known to exist but whose values haven't been hydrated yet.
+ cache.addKey(`${collectionKey}1`);
+ cache.addKey(`${collectionKey}2`);
+ cache.addKey(`${collectionKey}3`);
+
+ // Connect to the collection — this starts an async storage read (microtask)
+ connection = Onyx.connect({
+ key: collectionKey,
+ waitForCollectionCallback: true,
+ callback: mockCallback,
+ });
+
+ // Immediately set a single member in the same synchronous tick.
+ // This triggers synchronous keyChanged() which calls the subscriber with a partial
+ // collection (just _1 from the cache). This sets lastConnectionCallbackData for this
+ // subscriber. The async hydration from subscribeToKey should still deliver the full
+ // collection afterwards, since the data is different.
+ Onyx.set(`${collectionKey}1`, {ID: 1, value: 'updated'});
+
+ // Wait for all async operations (storage reads from subscribeToKey) to complete
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // The subscriber's final state should contain ALL collection members,
+ // including _2 and _3 that were only in storage (not cache) at the time of the synchronous keyChanged call.
+ const lastCall = mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0];
+ expect(lastCall).toHaveProperty(`${collectionKey}1`);
+ expect(lastCall).toHaveProperty(`${collectionKey}2`);
+ expect(lastCall).toHaveProperty(`${collectionKey}3`);
+ });
+ });
+
it('should merge an object with another object', () => {
let testKeyValue: unknown;
Could you validate if it makes sense?
There was a problem hiding this comment.
@VickyStash I asked Claude about this comment and according to it it's valid, here's a unit test it designed for me
Wow, nice catch! Claude have gave me totally different results when I asked it back to then.
I agree, that's truly can be a problem, I'm looking into how to handle it correctly.
Adjusted jest test, if anyone is interested
test('connect() followed by immediate set() should still deliver full collection from storage', async () => {
const mockCallback = jest.fn();
// ===== Session 1 =====
// Data is written to persistent storage (simulates a previous app session).
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, {id: 1, title: 'Test One'});
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`, {id: 2, title: 'Test Two'});
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`, {id: 3, title: 'Test Three'});
// ===== Session 2 =====
// App restarts. Onyx.init() calls getAllKeys() which populates storageKeys
// with all 3 keys, but their values are NOT read into cache yet.
Onyx.init({keys: ONYX_KEYS});
// A component connects to the collection (starts async hydration via multiGet).
Onyx.connect({
key: ONYX_KEYS.COLLECTION.TEST_KEY,
waitForCollectionCallback: true,
callback: mockCallback,
});
Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, {id: 1, title: 'Updated Test One'});
await waitForPromisesToResolve();
// The subscriber should eventually receive ALL collection members.
// The async hydration reads test_2 and test_3 from storage.
const lastCall = mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0];
expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`);
expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`);
expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`);
// Verify the updated value is present (not stale)
expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]).toEqual({id: 1, title: 'Updated Test One'});
});
|
Reviewing... |
|
@VickyStash Console error when creating a workspace: Monosnap.screencast.2026-03-04.01-25-34.mp4 |
|
Haven't faced any other issues apart from this: Monosnap.screencast.2026-03-04.01-33-46.mp4The new transaction is also highlighted: Monosnap.screencast.2026-03-04.01-42-41.mp4 |
We have already discussed this before, right? Regarding the scroll and highlight issue, that's what I mean:
VideosHow it works on main:how.it.should.be.mp4How it works with onyx updates: how.it.is.mp4 |
Hmm, I’m not sure if we should proceed with that bug. There might be multiple similar cases across the app. @fabioh8010 @mountiny could you please take a look? |
|
@Krishna2323 Fair point, thanks for raising, I think we should fix it to keep the product behaviour same @VickyStash Are you able to look into that and how to fix it? if its tricky, I would be open to do it as a follow up, but I think we should explore the options first before considering this as a follow up |
|
Okay, let me explain why this issue happens:
The behaviour can be improved on the app side. For example, we can check the focused state right before the scrolling (since scrolling is already delayed with the timeout): Expensify/App@2ce98cc That's how it looksfixed.mp4The only thing is that for the highlight, we won't have the
Videohighlight_example.mp4I've checked other places where we scroll/highlight to the new expense, and it seems to work, as it doesn't relate to the focus parm. |
|
@VickyStash I think that the updated solution in App looks good! |
|
@Krishna2323 is there anything else that should be addressed? |
|
I'll give it a review and do thorough testing today. I'll approve if everything looks good and we can wait until you're back on March 11 before moving forward. |
|
The app seems to be working well with these changes 🎉 Screenshots/VideosAndroid: HybridAppandroid_native.mp4Android: mWeb Chromeandroid_chrome.mp4iOS: HybridAppios_native.mp4iOS: mWeb Safariios_safari.mp4MacOS: Chrome / Safariweb_chrome.mp4 |
|
I think we should open an issue to apply these changes in the App PR once the Onyx version is bumped: mountiny Could you please open the issue and assign VickyStash and me if you agree? Thanks! |
|
@Krishna2323 I think that since we know this is required, we will need to apply it in the onyx bump, not sure if we need new issues explicitly for these |
|
I thought it would be good for tracking, but yeah, since we already have a bump PR, that was a dumb idea on my side. Sorry! |
|
Hey @Krishna2323, I'm back from vacation. |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppandroid_native.mp4Android: mWeb Chromeandroid_chrome.mp4iOS: HybridAppios_native.mp4iOS: mWeb Safariios_safari.mp4MacOS: Chrome / Safariweb_chrome.mp4 |
|
@mountiny please, take a look when you have a moment! |
mountiny
left a comment
There was a problem hiding this comment.
Thanks for testing, changes look good to me, going to move it ahead so we can create onyx bump
Details
Check the issue description for details.
Related Issues
$ Expensify/App#82871
Automated Tests
Should be covered by existing tests
Manual Tests
The E/App should work the same way as before. Let's verify basic test steps:
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.mp4
Android: mWeb Chrome
android_web.mp4
iOS: Native
ios.mov
iOS: mWeb Safari
ios_web.mov
MacOS: Chrome / Safari
web.mp4