Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Batch updates second try #315

Merged
merged 32 commits into from
Sep 15, 2023
Merged

Conversation

Szymon20000
Copy link
Contributor

@Szymon20000 Szymon20000 commented Aug 23, 2023

The Problem this pull-request solves

One of the main problems with react-native-onyx is that it renders every component in a separate micro-task. Because right now we don't use concurrent rendering every setState not within the same event listener will cause a separate render. Every react render has certain overhead so if it's possible to render 2 state changes together we will only pay for the overhead once. Additionally we introduce additional overhead in render side-effects. For instance in SidebarLinks component we compute which report should be visible. That method is heavy and we run it almost on every render. If we batch 2 setStates together that would save us one such computation. This pull-requests batches all setStates caused by onyx changes that occurs within the same microtask cycle. That saves a ton of time.

Examples of the problem

  1. Imagine there are 10 components that are listening to a current report changes. Currently all those components will be notified separately
  2. Imagine we have one component that listens to 4 different onyx props that are changed one by one (SidebarLinks is such component) right now we will render is as many times as we have changes. With this pr we will merge it only once per micotask cycle what will avoid a lot of recomputation

Note that is change won't be needed anymore once we use concurrent rendering.

Details

We use unstable_batchedUpdates method from react (That is currently used in even listeners so it's pretty safe).
This method triggers single react render for all the setState changes passed to it.

How it works

Screenshot 2023-08-28 at 14 46 16
For every notifySubscribers or collection subscribers we don't queue the update on micro task queue instead we add that change to pending changes list that is flushed at the end of the current micro task cycle. We do it by setting macro task which is setTimeout(() => {}, 0).

Related Issues

GH_LINK

Automated Tests

Linked PRs

@hannojg hannojg force-pushed the @szymon/batch_updates_2_try branch from ccb13e7 to 3f5b5fb Compare August 24, 2023 15:09
@Szymon20000 Szymon20000 marked this pull request as ready for review August 28, 2023 13:09
@Szymon20000 Szymon20000 requested a review from a team as a code owner August 28, 2023 13:09
@melvin-bot melvin-bot bot requested review from Li357 and removed request for a team August 28, 2023 13:09
@Li357
Copy link

Li357 commented Aug 28, 2023

Is this ready for review?

@Szymon20000
Copy link
Contributor Author

We are still adjusting unit tests in Expensfiy/App to this change but we also don't expect a need to modify this pr.
Feel free to review it :)

lib/Onyx.js Outdated
pendingCollectionUpdates = [];
unstable_batchedUpdates(() => {
for (let i = 0; i < updatesCopy.length; ++i) {
keyChanged(updatesCopy[i][0], updatesCopy[i][1], updatesCopy[i][2]);
Copy link
Contributor

Choose a reason for hiding this comment

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

I couldn't find anything that confirms that the pendingUpdate|updatesCopy is has a 3rd child. Presumably, this is guaranteed as that's how our Onyx updates are written?

Copy link
Contributor

Choose a reason for hiding this comment

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

If by 3rd child you mean the 3 argument at updatesCopy[i][2] then it will at least always be undefined.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Screenshot 2023-09-05 at 09 39 03
The keyChanged method has 3 arguments so we only delay the execution.

Julesssss
Julesssss previously approved these changes Aug 31, 2023
Copy link
Contributor

@Julesssss Julesssss left a comment

Choose a reason for hiding this comment

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

While this looks good, it would be great to test the component renders to prove the updates are now batched.

Copy link
Collaborator

@tgolen tgolen left a comment

Choose a reason for hiding this comment

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

Is there any way to write some unit tests to verify that the updates were batched as expected? Something like... have a mocked function that is triggered on render, and updating multiple properties that the component is listening to triggers the render callback the right number of times.

This is an example test of what I'm thinking of:

it('should update withOnyx subscriber multiple times when merge is used', () => {
const TestComponentWithOnyx = withOnyx({
text: {
key: ONYX_KEYS.COLLECTION.TEST_KEY,
},
})(ViewWithCollections);
const onRender = jest.fn();
render(<TestComponentWithOnyx onRender={onRender} />);
return waitForPromisesToResolve()
.then(() => {
Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, {ID: 123});
Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`, {ID: 234});
return Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`, {ID: 345});
})
.then(() => {
expect(onRender).toHaveBeenCalledTimes(4);
});
});

jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => {}}));
jest.mock('react-native-quick-sqlite', () => ({
open: () => ({execute: () => {}}),
}));

jest.useRealTimers();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this added here? Do you mind adding a code comment to explain?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hanno added that because otherwise setTimeout is not executed unless we call jest.advanceTime(). So to avoid calling that everywhere we used real times.

lib/Onyx.js Outdated
// [key, value, canUpdateSubscriber]
let pendingUpdates = [];
let scheduledPromise;
let pendingCollectionUpdates = [];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you also please add a doc comment for this (like you did for pendingUpdates)?

}

scheduledPromise = new Promise((resolve) => {
setTimeout(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please add a code comment explaining why it's necessary to have a setTimout(0) here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a comment explaining when the batch will be flushed. Explaining how timers and batching works in react native is not possible in a single comment so I simplified it a bit.

@@ -20,6 +21,7 @@ const METHOD = {

// Key/value store of Onyx key and arrays of values to merge
const mergeQueue = {};
const mergeQueuePromise = {};
Copy link
Collaborator

Choose a reason for hiding this comment

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

I followed most of the changes in this PR except for where it came to mergeQueuePromise. Could you add some code comments to give a general idea around what it's for and how it's used?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change is mostly for adjusting tests from what I remember.
Looks like we use it in tests for making sure all the pending merges has been applied.

Copy link
Contributor

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

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

Just small typos to fix now. I will resolve my NABs.

lib/Onyx.js Show resolved Hide resolved
lib/Onyx.js Outdated Show resolved Hide resolved
lib/Onyx.js Outdated Show resolved Hide resolved
lib/Onyx.js Show resolved Hide resolved
Szymon20000 and others added 2 commits September 6, 2023 07:48
Co-authored-by: Marc Glasser <marc.aaron.glasser@gmail.com>
Co-authored-by: Marc Glasser <marc.aaron.glasser@gmail.com>
@Szymon20000
Copy link
Contributor Author

The App tests are failing with this pr. I will let you know when they are fixed.
I just realised that we only want to batch withOnyx subscribers as regular ones should be notified right away.

@Szymon20000
Copy link
Contributor Author

@tgolen @marcaaron Are there any places in the app when we trigger Onyx update in Onyx.connect subscriber?
If that's the case then I think we shouldn't batch them.
I can image that we have places like that when once onyx.update triggers another one.
So for examples. update A -> update B -> update C would require 3 batches now.
If you think we may have such case in the app I create a pr that fixes that problem. margelo#1

@tgolen
Copy link
Collaborator

tgolen commented Sep 6, 2023

I just looked at all the usages of Onyx.update and there are none that happen inside a connect() subscriber.

@marcaaron
Copy link
Contributor

I just realised that we only want to batch withOnyx subscribers as regular ones should be notified right away

Oh, right, because they do not call setState(). Makes sense.

Are there any places in the app when we trigger Onyx update in Onyx.connect subscriber?
If that's the case then I think we shouldn't batch them.

Sorry, I did not follow the reasoning. Is there some kind of critical problem that we will see if we batch those updates?

@Szymon20000
Copy link
Contributor Author

Ah if we don't change state in onyx connect then probably it doesn't make any difference. I did it because I thought you may have some reactive code that is triggered once we merge something.

@Szymon20000
Copy link
Contributor Author

Screenshot 2023-09-07 at 16 03 19 @tgolen is may be the case with network calls

@tgolen
Copy link
Collaborator

tgolen commented Sep 7, 2023

Ah, you're right. I'm on a branch where we've refactored that specific code to be a little different and it's not clear anymore that the Onyx.update() is inside the connect().

https://github.com/Expensify/App/blob/5373041c5776fd8a51ea3646710ed9c7ccc50658/src/libs/Network/SequentialQueue.js#L126

@Szymon20000
Copy link
Contributor Author

I was able to fix most of failing tests in the app repo. There are 6 that left.
I think we need the pr for only batching withOnyx subscribers as networking flushing would be delayed otherwise.

tgolen
tgolen previously approved these changes Sep 12, 2023
marcaaron
marcaaron previously approved these changes Sep 12, 2023
@marcaaron
Copy link
Contributor

We have two approving reviews. What's next? Test the changes?

@Szymon20000 Szymon20000 dismissed stale reviews from marcaaron and tgolen via cd48425 September 14, 2023 12:59
@Szymon20000
Copy link
Contributor Author

The next step is to adjust tests in the App repo. Expensify/App#27230

@mountiny
Copy link
Contributor

To get this going ahead I will merge this now given there are two approvals here

@mountiny mountiny merged commit 18a07d1 into Expensify:main Sep 15, 2023
3 checks passed
@marcaaron
Copy link
Contributor

Should we make an announcement not to update the Onyx version until that testing is done? These feel like pretty significant changes.

@Julesssss
Copy link
Contributor

Should we make an announcement not to update the Onyx version until that testing is done? These feel like pretty significant changes.

Yes, great point.

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.

None yet

7 participants