Skip to content

Conversation

@tgolen
Copy link
Contributor

@tgolen tgolen commented Sep 15, 2020

This fixes #443 I hope. I wasn't able to reproduce the problem locally, so maybe one of you are able to?

This PR adds an HOC which will allow a component to render a collection in batches. In the case of the report main view, the batches are based off of first the visible reports, then 5 seconds later, the rest of the reports. On the report actions view, the first batch is 100 comments, then 5 seconds later, the rest of the comments.

Tests

  1. From a cold boot of the app, sign in, and search with the chatpicker
  2. Verify it is very very fast to do so

@tgolen tgolen self-assigned this Sep 15, 2020

// If the batchCallback returned false, then the props to get the items
// from didn't exist from Ion yet
if (items === false) {
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 will go away once #482 is merged

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.

Thanks for taking a crack at this @tgolen. It took me a while to wrap my head around how withBatchedRendering works, but I appreciate what you’ve done here so far.

Now, my instinct is that this HOC may point to a deeper problem somewhere else in the code. And I wonder if we really need it at all. However, I haven’t investigated performance issues yet so I won't share any wild guesses right now. And I won't block this solution if it's the glue that gets us from A to B.

I did spot a few problems that we should address and had a few suggestions for how to improve things.

};

class MainView extends React.Component {
// This is a PureComponent because since this method is connected to an Ion collection,
Copy link
Contributor

Choose a reason for hiding this comment

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

because since

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you maybe mean "component" not "method"?

// it has setState() called on it anytime a single report in the collection changes. When
// reports are first fetched, this component is rendered n times (where n is the number of reports).
// By switching to a PureComponent, it will only re-render if the props change which is
// much more performant.
Copy link
Contributor

Choose a reason for hiding this comment

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

this component is rendered n times (where n is the number of reports)

Might be reading this wrong, but I think this PR should make this statement no longer true? We will get all the reports then set the entire collection to withIon state before the component renders. So, we should expect it to only render once.

By switching to a PureComponent, it will only re-render if the props change which is much more performant.

Haven't used PureComponent much, but based on what I've read in the docs my understanding is that it just does a shallow comparison for all props and state to see if the component needs to re-render. Lmk if there's some other difference you see. It's still unclear to me why this would be more performant after reading this comment, but I am curious to learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Might be reading this wrong, but I think this PR should make this statement no longer true?

I don't think it does, no. I checked out that branch, and I added a console.log('render') to the render method in MainView.js and this is what happens on page load (ignore the network errors):

image

I then changed that component to be a PureComponent (still on that same branch) and this is what happens:

image

Lmk if there's some other difference you see

No, there are no other differences, that's exactly what PureComponents do and that's what we want it to do. The only thing this component needs to do is render/show/hide the ReportViews for every report. It doesn't need any information from the reports collection other than the reportID, which is never going to change after first render. The only other thing it uses is this.props.match.params.reportID, and since those props are updated via the router, it will re-render the component when necessary.

It's still unclear to me why this would be more performant after reading this comment, but I am curious to learn more.

It's the difference between rendering all the ReportViews 52 times (literally) or 1 time, so there is a huge performance gain.

Copy link
Contributor

Choose a reason for hiding this comment

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

It doesn't need any information from the reports collection other than the reportID, which is never going to change after first render

Cool. That explanation helps! Would be good to include something about the reportIDs in the comment.

So if a new report appears in the props (e.g. if Pusher handles a new report comment for a report not yet in your list) then it should appear because it's a new key on this.props.reports, but other updates to existing reports should not trigger the re-render?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that's the idea. Let me be sure to check that pusher case 👍

return (
<>
{_.map(this.props.reports, report => (
{_.map(this.props.itemsToRender, report => report.reportName && (
Copy link
Contributor

Choose a reason for hiding this comment

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

Just wondering here... are we rendering all these reports because after they are loaded it makes for a better overall performance when switching between them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, exactly.

Copy link
Contributor

Choose a reason for hiding this comment

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

Any ideas about why performance degrades when switching reports?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea, it's because all the comments have to be inserted into the DOM when reports are switched. Since the process to do DOM insertion is expensive, if we can do it in a way that doesn't impact the user, then it's a better experience.

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense. So, if there are less comments to insert into the DOM (or whatever the equivalent is on mobile) then it would be faster to switch between chats. But we have instead decided to "front-load" that work. And this PR is basically prioritizing that work by delaying some of the "front-loaded" parts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that's correct.


// Array of report actions for this report
reportActions: PropTypes.PropTypes.objectOf(PropTypes.shape(ReportActionPropTypes)),
itemsToRender: PropTypes.PropTypes.arrayOf(PropTypes.shape(ReportActionPropTypes)),
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we possibly use a dynamic prop name here? It's an interesting idea to use this HOC as a prop filtering middleware of sorts... but I wonder if we need to lose the prop name as it makes it more difficult to follow the rest of the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a good idea. I'll look into that to see if I can keep the original prop name intact.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I looked into this, and I actually don't want to use the original prop name (because that will overwrite the data that Ion passes to the component). I like having a separate prop for what withIon adds and withBatchedRendering adds. I have a little idea to improve this to make it more intuitive.

return component.displayName || component.name || 'Component';
}

export default function (batchCallback, batchesToRender = 2, batchRenderDelay = 5000) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Random thought... what if batchCallback returned an array where each item was a batch that we want to render + a render delay?

e.g.

(props) => {
    return [
        {items: _.filter(props.something, someFilter), delay: 0},
        {items: _.filter(props.something, someOtherFilter), delay: 2000},
    ];
}

I think this makes it slightly easier to visualize the "batching" that's happening and also give us more control.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that's a really cool idea. I like how that looks.


setTimeout(() => {
this.setState({
itemsToRender: items,
Copy link
Contributor

Choose a reason for hiding this comment

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

Only update itemsToRender when a batch runs? What about when the props update? Don't we want to continue to set the latest props once all the filtering is done?


class MainView extends React.Component {
// This is a PureComponent because since this method is connected to an Ion collection,
// it has setState() called on it anytime a single report in the collection changes. When
Copy link
Contributor

Choose a reason for hiding this comment

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

We are calling setState() on withIon not here, but I got your meaning

@quinthar
Copy link
Contributor

quinthar commented Sep 16, 2020 via email

_.each(batches(this.props), (batch) => {
setTimeout(() => {
this.setState({
itemsToRender: batch.items,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we need to listen for changes to whatever prop we are filtering with this HOC in componentDidUpdate() and then set it to state or else we will never get the updated props?

Try leaving a comment on a report. It won't work at all and I think this is the reason why.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I'll look into that, yeah. I wasn't done with the most recent changes I wanted to make so there are still a few things I haven't tested yet. THanks!

return (
<>
{_.map(this.props.reports, report => (
{_.map(this.props.itemsToRender, report => report.reportName && (
Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense. So, if there are less comments to insert into the DOM (or whatever the equivalent is on mobile) then it would be faster to switch between chats. But we have instead decided to "front-load" that work. And this PR is basically prioritizing that work by delaying some of the "front-loaded" parts.

const sortedReportActions = _.sortBy(props.reportActions, 'sequenceNumber');
return [
{items: _.chain(sortedReportActions).last(100).indexBy('reportID').value(), delay: 0},
{items: _.indexBy(sortedReportActions, 'reportID'), delay: 7000},
Copy link
Contributor

Choose a reason for hiding this comment

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

sequenceNumber right? not reportID?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep :D

@tgolen
Copy link
Contributor Author

tgolen commented Sep 16, 2020

@quinthar RE: the 100 comment, yes, it does prioritize the most recent 100 report actions.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 17, 2020

@marcaaron OK, I've updated it so that it works with componentDidUpdate(). However, this does lead to a slight problem. Envision this scenario:

  1. Report action batch 1 is rendered (100 comments)
  2. User posts a new comment
  3. Report action batch 2 is rendered (the rest of the comments from step 1)
  4. User's comment from step 2 disappears because it's overwritten

There are a couple of solutions I guess.

  • Change the implementation of componentDidMount() and find a way to do it so that the batches aren't "prescheduled" (for lack of a better term)
  • Decrease the delay of the second batch to make the window small enough that this problem isn't likely to happen
  • Only do batched rendering in MainView.js to begin with, which won't have this problem

Thoughts?

@marcaaron
Copy link
Contributor

marcaaron commented Sep 17, 2020

Ah hmm, yeah one way around that would be to do something like this:

return [
   {batchMethod: (props) => {...}, delay: 0},
   {batchMethod: (props) => {...}, delay: 10000}
];

So rather than making batches a function you make each batch have a function that will refer to latest props

@marcaaron
Copy link
Contributor

Gonna take this over for Tim and pick up where we left off.

@marcaaron marcaaron requested review from chiragsalian, marcaaron and roryabraham and removed request for marcaaron October 15, 2020 02:49
@marcaaron
Copy link
Contributor

Removing myself as reviewer since I added a few commits to get this cleaned up - @chiragsalian @roryabraham would you all mind taking a look at this?

}}
onChangeText={this.updateSearch}
onClearButtonClick={this.reset}
onClearButtonClick={() => this.reset()}
Copy link
Contributor

Choose a reason for hiding this comment

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

This was a random bug I found that was passing a Class as a param to this.reset() didn't seem to be breaking anything but was popping up in console.logs for proptypes errors.

* @param {object} component
* @returns {string}
*/
function getDisplayName(component) {
Copy link
Contributor

Choose a reason for hiding this comment

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

IIRC this function is the same for all our HOC's, can we DRY this up and make it a separate utility?

Copy link
Contributor

@marcaaron marcaaron Oct 15, 2020

Choose a reason for hiding this comment

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

Yeah I was thinking of doing this but just left it since the proposed changes are kind of complicated and I prefer to have fewer file changes per PR if possible.

Copy link
Contributor

Choose a reason for hiding this comment

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

Alright, it's NAB for me because this function is super simple

},
})(ReportActionsView);
export default compose(
withIon({
Copy link
Contributor

Choose a reason for hiding this comment

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

Just wondering, does it matter the order in which these HOC's are composed? I think it's right the way you have it here, but I'm still curious if having withIon be wrapped by withBatchedRendering would break withIon?

Just kinda thinking out loud here, but it seems like withBatchedRendering temporarily withholds certain props from being passed to the wrapped component. If that wrapped component were withIon, I'm wondering if withIon's componentDidMount would still set up all the Ion subscriptions correctly

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah the order does matter, but I think even if you have withBatchedRendering followed by withIon that nothing would inherently be wrong with that. We are just not likely to ever do it. withBatchedRendering is a just a dumb middleware that takes props and delays passing them down to the wrapped component. Whether that component is wrapped in withIon or not doesn't shouldn't really make a difference.

I could see the argument that if you used withBatchedRendering in front of a withIon that was using the function version of defining an Ion key like

{key: props => props.someValueThatWithBatchedRenderingIsTemporarilyWitholding}

Then maybe this initial subscription wouldn't work out in componentDidMount()

But in that case, it's a possible indication that we are holding something wrong.

Even if that did happen we would retry to subscribe pretty much the next time the component updates...

https://github.com/Expensify/ReactNativeChat/blob/43a1888714c33f5bc96c6f7e59f99cf92bbc16ac/src/components/withIon.js#L53-L58

Copy link
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

Overall looks good, just the one DRY comment for getDisplayName for now - the other is NAB and more just a question.

});
}

componentDidUpdate(prevProps) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Also, if I'm being honest I'm confused about why we need this function and why this.props[propNameToBatch] is a list.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok hmm I'm trying to think of a better way to put this...

Right now I have

We need this to allow the flow of props down from a parent component to work normally after all the batches have finished rendering

But maybe the part you're missing is that...

  1. We are effectively replacing the property coming in from props with the one set in state in this HOC
  2. We explicitly remove the property from this.props in the render() so the props passed by a parent are filtered through this component
  3. If we don't listen for when that property changes and set it to state we'll just remove it and the wrapped component's props will never update

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, I think I get it now

roryabraham
roryabraham previously approved these changes Oct 15, 2020
Copy link
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

Changing this to an approval, but I'll let @chiragsalian have a look too.

chiragsalian
chiragsalian previously approved these changes Oct 15, 2020
Copy link
Contributor

@chiragsalian chiragsalian left a comment

Choose a reason for hiding this comment

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

Looks great to me and works really well on web and mobile too. Left two minor comments. Approved anyway since they are mostly NABs.

* Cancels all the timers
*/
cancelBatchTimers() {
_.each(this.timers, timerID => clearTimeout(timerID));
Copy link
Contributor

Choose a reason for hiding this comment

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

NAB, feel weird to have this single line method. Its only used in one spot and already has a comment above explaining what it does so it feels like this _.each(this.timers, timerID => clearTimeout(timerID)); could directly be used here. Not sure if you placed is separately for readability but the comment already helps with that.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that makes sense I was going to trigger this from two spots at one point then changed my mind.

}}
onChangeText={this.updateSearch}
onClearButtonClick={this.reset}
onClearButtonClick={() => this.reset()}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why this change, is it by accident?

Copy link
Contributor

Choose a reason for hiding this comment

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

This was to fix a random error I found. not a bug or related to these changes. one of the annoying prop-types kind of errors.

Copy link
Contributor

@chiragsalian chiragsalian left a comment

Choose a reason for hiding this comment

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

LGTM 👍

@AndrewGable
Copy link
Contributor

FYI - Conflicts

@marcaaron
Copy link
Contributor

Ok, so I know we got pretty far with this one. But the original issue is now closed and we aren't experiencing the problem linked there (slow start up times for iOS apps). So we should probably leave this idea for a rainy day. As interesting as this solution is, it doesn't solve any problem we are currently experiencing. If anyone disagrees we can reopen.

@marcaaron marcaaron closed this Oct 20, 2020
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.

Mobile takes 45 seconds to load from login to DM search

7 participants