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

Perf rearchitecting #450

Merged
merged 1 commit into from
May 2, 2017
Merged

Perf rearchitecting #450

merged 1 commit into from
May 2, 2017

Conversation

majapw
Copy link
Collaborator

@majapw majapw commented Apr 14, 2017

This PR is a dramatic rearchitecture of the way that we handle modifiers in react-dates on the whole.

Instead of passing down an object of modifier string => modifiers functions to each CalendarDay component (one that gets rebuilt on every render call) and expecting the CalendarDay component to take care of updating itself, we now put the burden on the top of the tree instead of on the leaves. The previous model meant that whenever an interaction happened on an individual day, even a hover interaction, every single CalendarDay would have to recalculate its modifiers and rerender as a result. The idea was that an interaction on one day might have effects elsewhere in the calendar (like the hovered-span). The effect unfortunately was that react-dates was really god damn slow.

In this new model, the DayPickerRangeController maintains a map with the following structure:

{
  MONTH_ISO_1: {
    DAY_ISO_1: Set(['modifier_1', 'modifier_2', ...]),
    DAY_ISO_2: Set(['modifier_1', 'modifier_2', ...]),
    ...
  },
  ...
}

It passes this down the tree such that each CalendarMonth and each CalendarDay only gets the information that pertains to it. This means that the updating of these modifiers is also handled at the top-level and is done in the componentWillReceiveProps, onDayMouseEnter, and onDayMouseLeave methods of the DayPickerRangeController. Fortunately, this allows us to more finely tune which days get updated and speeds up the rerendering/updating process dramatically.

I did some preliminary testing and saw the following improvements:
Before:
screen shot 2017-04-13 at 10 35 39 pm

After:
screen shot 2017-04-13 at 10 55 41 pm

I will update those graphs when everything is in place.

I still need to finish writing some tests, clean up some other tests, and also get this working with enableOutsideDays and the SingleDatePicker. The bulk of the work is in DayPickerRangeController right now, but I've been musing on ways to pull that in an HOC or something. SUGGESTIONS ARE WELCOME.

In any case, I would appreciate a first review pass while I finish up the above points! <3
@lencioni @ljharb @moonboots @backwardok

UPDATE: Here are the final perf measurements!
Before:
screen shot 2017-05-01 at 3 19 09 pm

After:
screen shot 2017-05-01 at 4 38 44 pm

Here are the findings:

  • The overhead on initial mount remains about the same (~400-500ms)
  • Any calendar interaction (hover, click, etc.) consistently caused a 65ms update in the old version and now only takes about 10ms - 15ms.
  • Transitions are now a bit slower (likely due to a bug in the code and therefore an opportunity) at about 120ms as opposed to 95ms previously. There does seem to be an unnecessary update occurring that I'll investigate in a follow up PR.

@coveralls
Copy link

coveralls commented Apr 14, 2017

Coverage Status

Coverage decreased (-3.5%) to 80.497% when pulling 6faae02 on maja-prototype-perf-idea into 3815516 on master.

@majapw majapw added the semver-major: breaking change A non-backwards-compatible change; anything that breaks code - including adding a peerDep. label Apr 18, 2017
@coveralls
Copy link

coveralls commented Apr 18, 2017

Coverage Status

Coverage decreased (-56.8%) to 27.181% when pulling 5473b4f on maja-prototype-perf-idea into 3815516 on master.

@majapw majapw force-pushed the maja-prototype-perf-idea branch 2 times, most recently from 6eb0600 to 37eafdd Compare April 18, 2017 21:55
@coveralls
Copy link

Coverage Status

Coverage increased (+2.6%) to 86.593% when pulling 37eafdd on maja-prototype-perf-idea into 3815516 on master.

1 similar comment
@coveralls
Copy link

Coverage Status

Coverage increased (+2.6%) to 86.593% when pulling 37eafdd on maja-prototype-perf-idea into 3815516 on master.

@coveralls
Copy link

coveralls commented Apr 18, 2017

Coverage Status

Coverage increased (+2.6%) to 86.593% when pulling 37eafdd on maja-prototype-perf-idea into 3815516 on master.

@@ -16,7 +16,7 @@ const propTypes = forbidExtraProps({
day: momentPropTypes.momentObj,
daySize: nonNegativeInteger,
isOutsideDay: PropTypes.bool,
modifiers: PropTypes.object,
modifiers: PropTypes.instanceOf(Set),
Copy link
Member

Choose a reason for hiding this comment

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

it's worth noting that this may make the PR semver-major, as this adds a requirement for es6-shimmed or above, and breaks IE <= 8 (if it wasn't already broken)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This PR is already semver-major due to the fact that the DayPicker now functions totally differently. Do you think this is a concern? I kind of think this project was not working with IE8 already.

Copy link
Member

Choose a reason for hiding this comment

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

As long as the PR is already semver-major, and as long as we ensure that these requirements (Set and Array.from being globally polyfilled) are documented in the readme, I'm a solid 👍 on it.

const className = cx('CalendarDay', {
'CalendarDay--outside': isOutsideDay,
}, modifiersForDay.map(mod => `CalendarDay--${mod}`));

}, [...modifiers].map(mod => `CalendarDay--${mod}`));
Copy link
Member

Choose a reason for hiding this comment

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

Array.from(modifiers, mod => CalendarDay--${mod}) is much much more efficient because it avoids the intervening array.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

okeedoke. Didn't know that syntax!

@majapw majapw changed the title WIP: Perf rearchitecting Perf rearchitecting Apr 24, 2017
@coveralls
Copy link

coveralls commented Apr 24, 2017

Coverage Status

Coverage increased (+3.0%) to 86.961% when pulling c3b5350 on maja-prototype-perf-idea into 3815516 on master.

lencioni
lencioni previously approved these changes Apr 24, 2017
export default function isAfterDay(a, b) {
if (!moment.isMoment(a) || !moment.isMoment(b)) return false;

const isSameYear = a.year() === b.year();
Copy link
Contributor

Choose a reason for hiding this comment

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

You might be able to squeeze out a bit more perf by storing the results of x.year/month/date, depending on how expensive those calls are and how often this function is called.

const firstDayOfFirstMonth = month.clone().startOf('month');
const lastDayOfLastMonth = month.clone().add(numberOfMonths - 1, 'months').endOf('month');

return !day.isBefore(firstDayOfFirstMonth) && !day.isAfter(lastDayOfLastMonth);
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this use your isBeforeDay/isAfterDay utility methods?

// TODO(maja): get this working for enableOutsideDays
export default function isDayVisible(day, month, numberOfMonths) {
const firstDayOfFirstMonth = month.clone().startOf('month');
const lastDayOfLastMonth = month.clone().add(numberOfMonths - 1, 'months').endOf('month');
Copy link
Contributor

Choose a reason for hiding this comment

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

You can avoid this work some of the time by doing an early return and putting this after it.

@coveralls
Copy link

coveralls commented Apr 25, 2017

Coverage Status

Coverage increased (+4.6%) to 88.576% when pulling 04a1e5e on maja-prototype-perf-idea into 3815516 on master.

@coveralls
Copy link

coveralls commented Apr 25, 2017

Coverage Status

Coverage increased (+4.7%) to 88.673% when pulling 9220ddb on maja-prototype-perf-idea into 3815516 on master.

selected: day => this.isSelected(day),
};

const initialVisibleMonthThunk = props.initialVisibleMonth || (() => (props.date || moment()));
Copy link
Member

Choose a reason for hiding this comment

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

this could be more efficient with props.initialVisibleMonth || (props.date ? () => props.date : () => moment()) (and even more so if the moment thunk was cached at module level instead of recreated every render)

const initialVisibleMonthThunk = props.initialVisibleMonth || (() => (props.date || moment()));
const evaluatedMonth = initialVisibleMonthThunk();
const currentMonth = moment.isMoment(evaluatedMonth) ? evaluatedMonth : moment();
const visibleDays = getVisibleDays(currentMonth, props.numberOfMonths, props.enableOutsideDays);
Copy link
Member

Choose a reason for hiding this comment

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

also, all this prop-dependent logic needs to be in both the constructor and componentWillReceiveProps. Rather than duplicating it, is there some chunk of it that could be in a shared function?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think there is a problem with the this.props/props paradigm. Also do you mean that this needs to be reset when the month changes?

Copy link
Member

Choose a reason for hiding this comment

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

What I mean is, any prop-dependent logic in any constructor needs to be re-executed in componentWillReceiveProps - so in this case, yes, when numberOfMonths or enableOutsideDays changes, visibleDays and thus this.state.visibleDays would need to be recomputed.

if (this.isTouchDevice || !hoverDate) return;

let modifiers = {};
modifiers = this.deleteModifier(modifiers, hoverDate, 'hovered');
Copy link
Member

Choose a reason for hiding this comment

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

const modifiers = this.deleteModifier({}, hoverDate, 'hovered');?

altho this looks kind of weird, why the empty object?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The empty object is so that when we call addModifier/deleteModifier the effects stack rather than overwriting each other. So for instance if something happens that causes two different modifiers to be added, we spread everything onto this object and then set the state at the end.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks, that makes sense.

@@ -0,0 +1,18 @@
import moment from 'moment';

export default function isAfterDay(a, b) {
Copy link
Member

Choose a reason for hiding this comment

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

it seems unfortunate to have both isAfterDay and isBeforeDay - would it be worth it to make one of the implementations (say this one) into return !isBeforeDay(a, b) && !isSameDay(a, b);?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we could also probs update usage to only have one. I just thought it was a bit clear to use one over the other in some cases.

Copy link
Member

Choose a reason for hiding this comment

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

I don't mean, let's get rid of both - I mean, let's implement one in terms of the other.

if (isBeforeDay(day, firstDayOfFirstMonth)) return false;

const lastDayOfLastMonth = month.clone().add(numberOfMonths - 1, 'months').endOf('month');
if (isAfterDay(day, lastDayOfLastMonth)) return false;
Copy link
Member

Choose a reason for hiding this comment

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

return !isAfterDay(day, lastDayOfLastMonth);?


export default function isInclusivelyBeforeDay(a, b) {
if (!moment.isMoment(a) || !moment.isMoment(b)) return false;
return a.isBefore(b) || isSameDay(a, b);
return isBeforeDay(a, b) || isSameDay(a, b);
Copy link
Member

Choose a reason for hiding this comment

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

isn't this return !isAfterDay(a, b);?


export default function isInclusivelyAfterDay(a, b) {
if (!moment.isMoment(a) || !moment.isMoment(b)) return false;
return a.isAfter(b) || isSameDay(a, b);
return isAfterDay(a, b) || isSameDay(a, b);
Copy link
Member

Choose a reason for hiding this comment

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

isn't this return !isBeforeDay(a, b);?

@majapw majapw force-pushed the maja-prototype-perf-idea branch 2 times, most recently from b15a284 to 3c1bd25 Compare May 1, 2017 23:37
@majapw
Copy link
Collaborator Author

majapw commented May 1, 2017

@ljharb I think I addressed your concerns! Can you take a look?

@coveralls
Copy link

coveralls commented May 1, 2017

Coverage Status

Coverage increased (+2.2%) to 86.12% when pulling 3c1bd25 on maja-prototype-perf-idea into 767bebc on master.

ljharb
ljharb previously approved these changes May 1, 2017
@@ -249,7 +629,7 @@ export default class DayPickerRangeController extends React.Component {

isDayAfterHoveredStartDate(day) {
const { startDate, endDate, minimumNights } = this.props;
const { hoverDate } = this.state;
const { hoverDate } = this.state || {};
Copy link
Member

Choose a reason for hiding this comment

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

when would the state not be initialized to an empty object?

Copy link
Collaborator Author

@majapw majapw May 1, 2017

Choose a reason for hiding this comment

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

When this function is called from the constructor, this.state has not yet been set.

... this same response applies on the other areas too.

It's a weird edge case, but this seemed like the cleanest solution? Like this will never be true in the constructor so I could also just return if this.state is not defined... or figure out some way to skip these modifiers

Copy link
Member

Choose a reason for hiding this comment

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

gotcha (sorry for the multi-comment).

#450 (comment) is what i'd expect to be the solution

return {
...updatedDays,
...{
[monthIso]: {
Copy link
Member

Choose a reason for hiding this comment

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

this spread isn't necessary - you can just do:

return {
   ...updatedDays,
  [monthISO]: {
    ...month,
    [iso]: modifiers,
  },
};

@@ -344,7 +620,8 @@ export default class SingleDatePicker extends React.Component {
}

isHovered(day) {
return isSameDay(day, this.state.hoverDate);
const { hoverDate } = this.state || {};
Copy link
Member

Choose a reason for hiding this comment

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

when would the state not be initialized to an empty object?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

When this function is called from the constructor, this.state has not yet been set.

Copy link
Member

Choose a reason for hiding this comment

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

:-/ hmm - in that case it kind of seems like the meat of it should be extracted out to a pure function in a closure, and have both the constructor and isHovered call into it with the data it needs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

can you clarify that a little bit?

do you mean something like isHovered(day, hoverDate)? I feel like that might be odd given that it'd suddenly be different from every other modifiers method

Copy link
Member

Choose a reason for hiding this comment

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

Hmm - yeah, i can see how that'd be unnecessarily weird. I guess this is fine as-is.

@coveralls
Copy link

coveralls commented May 2, 2017

Coverage Status

Coverage increased (+2.2%) to 86.12% when pulling 0b20ab4 on maja-prototype-perf-idea into 767bebc on master.

lencioni
lencioni previously approved these changes May 2, 2017
ljharb
ljharb previously approved these changes May 2, 2017
@majapw majapw mentioned this pull request May 2, 2017
Instead of passing down an object of modifier string => modifiers functions to each CalendarDay component and expecting the CalendarDay component to take care of updating itself, we now put the burden on the top of the tree instead of on the leaves. The previous model basically ended up meaning that whenever an interaction happened on an individual day, even a hover interaction, every single CalendarDay would have to recalculate its modifiers and rerender as a result. This was, as you can imagine, really god damn slow.

In this new model, the DayPickerRangeController maintains a map with the following structure:
```
{
  MONTH_ISO_1: {
    DAY_ISO_1: Set(['modifer_1', 'modifier_2', ...]),
    DAY_ISO_2: Set(['modifer_1', 'modifier_2', ...]),
    ...
  },
  ...
}
```
It passes this down the tree such that each `CalendarMonth` and each `CalendarDay` only gets the information that pertains to it. This means that the updating of these modifiers is also handled at the top-level and is done in the `componentWillReceiveProps`, `onDayMouseEnter`, and `onDayMouseLeave` methods of the `DayPickerRangeController`. Fortunately, this allows us to more finely tune which days get updated and speeds up the rerendering/updating process dramatically.
@majapw majapw dismissed stale reviews from ljharb and lencioni via c95cbed May 2, 2017 20:02
@majapw majapw force-pushed the maja-prototype-perf-idea branch from 0b20ab4 to c95cbed Compare May 2, 2017 20:02
@coveralls
Copy link

coveralls commented May 2, 2017

Coverage Status

Coverage increased (+2.2%) to 85.737% when pulling c95cbed on maja-prototype-perf-idea into c4bc6d6 on master.

@dburles
Copy link
Contributor

dburles commented Jun 14, 2017

Hey guys, I have the following on a DayPicker component in order to highlight a range of dates that contain information, along with highlighting the currently selected day. However it now no longer works after this refactor. Is there another approach I can take to make it work? thanks.

            modifiers={{
              selected: day =>
                moment(this.props.selectedDay).isSame(day, 'day'),
              'highlighted-calendar': day1 =>
                this.props.highlightedDays.some(day2 =>
                  moment(day2).isSame(day1, 'day'),
                ),
            }}

@majapw
Copy link
Collaborator Author

majapw commented Jun 14, 2017

Hey @dburles, can you open an issue for this so that we can have a convo on a separate thread?

@dburles
Copy link
Contributor

dburles commented Jun 14, 2017

@majapw sure thing! #562

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
semver-major: breaking change A non-backwards-compatible change; anything that breaks code - including adding a peerDep.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants