Skip to content

Remove macro/micro tasks during subscriber update#724

Merged
mountiny merged 28 commits intoExpensify:mainfrom
callstack-internal:VickyStash/poc/get-rid-of-macro-micro-tasks
Mar 12, 2026
Merged

Remove macro/micro tasks during subscriber update#724
mountiny merged 28 commits intoExpensify:mainfrom
callstack-internal:VickyStash/poc/get-rid-of-macro-micro-tasks

Conversation

@VickyStash
Copy link
Contributor

@VickyStash VickyStash commented Jan 23, 2026

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:

  1. Login with a account.
  2. Navigate to a chat, send a message. React to that message.
  3. Reply to that message in a new thread.
  4. Create a new task, navigate to its details and mark it as completed.
  5. Create a new expense to someone else, navigate to its details.
  6. Go to Reports tab, Expenses, make sure you can find your expense and see its details.
  7. Create a new workspace, make sure the rooms were created.

Author Checklist

  • I linked the correct issue in the ### Related Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android / native
    • Android / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

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

@koko57
Copy link
Contributor

koko57 commented Feb 16, 2026

LGTM

Copy link
Contributor

@kubabutkiewicz kubabutkiewicz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@VickyStash VickyStash changed the title [POC] Remove macro/micro tasks during subscriber update Remove macro/micro tasks during subscriber update Feb 20, 2026
@VickyStash VickyStash marked this pull request as ready for review February 20, 2026 08:55
@VickyStash VickyStash requested a review from a team as a code owner February 20, 2026 08:55
@melvin-bot melvin-bot bot requested review from grgia and removed request for a team February 20, 2026 08:56
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, by the time the hydration promise resolves, it would only have equal or older data, making the skip correct.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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'});
});

@Krishna2323
Copy link

Reviewing...

@Krishna2323
Copy link

@VickyStash Console error when creating a workspace:

Monosnap.screencast.2026-03-04.01-25-34.mp4

@Krishna2323
Copy link

Krishna2323 commented Mar 3, 2026

Haven't faced any other issues apart from this:

Monosnap.screencast.2026-03-04.01-33-46.mp4

The new transaction is also highlighted:

Monosnap.screencast.2026-03-04.01-42-41.mp4

@VickyStash
Copy link
Contributor Author

@VickyStash Console error when creating a workspace

We have already discussed this before, right?

Regarding the scroll and highlight issue, that's what I mean:

  1. Open the chat with existing expenses
  2. Create an expense
  3. Chat is scrolled to expenses previews, but not scrolled to the specific expense in the horizontal list (see the video)
Videos How it works on main:
how.it.should.be.mp4

How it works with onyx updates:

how.it.is.mp4

@Krishna2323
Copy link

Regarding the scroll and highlight issue, that's what I mean:

  1. Open the chat with existing expenses
  2. Create an expense
  3. Chat is scrolled to expenses previews, but not scrolled to the specific expense in the horizontal list (see the video)

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?

@mountiny
Copy link
Contributor

mountiny commented Mar 4, 2026

@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

@VickyStash
Copy link
Contributor Author

Okay, let me explain why this issue happens:

  1. User submits an expense creation on the confirmation screen
  2. The action writes new transaction data to Onyx and triggers navigation back to the chat screen
  3. With current Onyx updates, the Onyx subscriber updates faster than the navigation transition, before the chat screen regains focus.
  4. useNewTransactions runs: it compares prevTransactions vs transactions, detects the new transaction, and returns it
  5. But in MoneyRequestReportPreview/index.tsx, isFocused is still false (navigation transition in progress), so newTransactionIDs is set to undefined
  6. On the next render, when the screen does regain focus, usePrevious has already captured the updated transactions list as prevTransactions. Now prevTransactions === transactions (same set), so useNewTransactions returns an empty array
  7. useFocusEffect in MoneyRequestReportPreviewContent fires, but newTransactionIDs is empty → no scroll, no highlight

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 looks
fixed.mp4

The only thing is that for the highlight, we won't have the isFocused check, so it's possible that:

  1. User creates an expense on the report preview screen
  2. Goes quickly back to the chat
  3. See the new expense highlighted
Video
highlight_example.mp4

I'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.

@mountiny
Copy link
Contributor

mountiny commented Mar 5, 2026

@VickyStash I think that the updated solution in App looks good!

@VickyStash
Copy link
Contributor Author

VickyStash commented Mar 6, 2026

@Krishna2323 is there anything else that should be addressed?
I'm going to be OOO March 9-10 🌴
It would be great to wait for my return till the next steps for this PR, so we won't accidentally block Onyx with my updates.

@Krishna2323
Copy link

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.

@Krishna2323
Copy link

The app seems to be working well with these changes 🎉

Screenshots/Videos

Android: HybridApp
android_native.mp4
Android: mWeb Chrome
android_chrome.mp4
iOS: HybridApp
ios_native.mp4
iOS: mWeb Safari
ios_safari.mp4
MacOS: Chrome / Safari
web_chrome.mp4

@Krishna2323
Copy link

Krishna2323 commented Mar 7, 2026

I think we should open an issue to apply these changes in the App PR once the Onyx version is bumped:
#724 (comment) and #724 (comment).

mountiny Could you please open the issue and assign VickyStash and me if you agree? Thanks!

@mountiny
Copy link
Contributor

mountiny commented Mar 7, 2026

@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

@Krishna2323
Copy link

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!

@VickyStash VickyStash requested a review from Krishna2323 March 11, 2026 08:38
@VickyStash
Copy link
Contributor Author

Hey @Krishna2323, I'm back from vacation.
I've merged the latest main and adjusted the comment.
Please, take a look!

@Krishna2323
Copy link

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
android_native.mp4
Android: mWeb Chrome
android_chrome.mp4
iOS: HybridApp
ios_native.mp4
iOS: mWeb Safari
ios_safari.mp4
MacOS: Chrome / Safari
web_chrome.mp4

Copy link

@Krishna2323 Krishna2323 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM and works well! 🚀

@VickyStash
Copy link
Contributor Author

@mountiny please, take a look when you have a moment!

Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for testing, changes look good to me, going to move it ahead so we can create onyx bump

@mountiny mountiny merged commit e58e7c3 into Expensify:main Mar 12, 2026
8 checks passed
@os-botify
Copy link
Contributor

os-botify bot commented Mar 12, 2026

🚀 Published to npm in 3.0.46 🎉

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants