fix(composer): performance improvement in composer

Summary:
The issue was that on every key stroke the whole composer, participants
and all, were getting re-rendered. According to React.perf, the
`TokenizingTextField`s were taking a very long time to render and never
changing. This was fixed by adding a simple `shouldComponentUpdate` check.

The composer also has several regions that only change when the `props`
do. These are now cached. The cache reset when the `props` do.

After all of that, rendering the whole composer still takes 20-40ms. If
you're tying in the composer very quickly, text entry can approach that
render time. This starts to stack multiple React rendering passes up and
bogs the whole system down.

Luckily, we can simply render the composer less frequently. Now, after
changes are persisted to the `DraftStoreProxy`, we simply debounce the
proxy `trigger`. The users don't see this because the native
`contenteditable` field will update immediately. When the debounced proxy
trigger fires, it will transparently update the view to the latest state.

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Subscribers: mg

Differential Revision: https://phab.nylas.com/D1749
This commit is contained in:
Evan Morikawa 2015-07-16 10:41:04 -04:00
parent aeb83c3c50
commit 3f6257c009
5 changed files with 39 additions and 6 deletions

View file

@ -64,6 +64,10 @@ class ComposerView extends React.Component
componentWillMount: =>
@_prepareForDraft(@props.localId)
shouldComponentUpdate: (nextProps, nextState) =>
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
componentDidMount: =>
@_draftStoreUnlisten = DraftStore.listen @_onSendingStateChanged
@_uploadUnlisten = FileUploadStore.listen @_onFileUploadStoreChange
@ -104,6 +108,7 @@ class ComposerView extends React.Component
@_focusOnUpdate = false
componentWillReceiveProps: (newProps) =>
@_ignoreNextTrigger = false
if newProps.localId isnt @props.localId
# When we're given a new draft localId, we have to stop listening to our
# current DraftStoreProxy, create a new one and listen to that. The simplest
@ -426,6 +431,7 @@ class ComposerView extends React.Component
@refs.contentBody.selectEnd()
_onDraftChanged: =>
return if @_ignoreNextTrigger
return unless @_proxy
draft = @_proxy.draft()
@ -522,7 +528,24 @@ class ComposerView extends React.Component
return unless @_proxy
if @_getSelections().currentSelection?.atEndOfContent
@props.onRequestScrollTo?(messageId: @_proxy.draft().id, location: "bottom")
@_addToProxy(body: event.target.value)
# The body changes extremely frequently (on every key stroke). To keep
# performance up, we don't want to trigger every single key stroke
# since that will cause an entire composer re-render. We, however,
# never want to lose any data, so we still add data to the proxy on
# every keystroke.
#
# We want to use debounce instead of throttle because we don't want ot
# trigger janky re-renders mid quick-type. Let's just do it at the end
# when you're done typing and about to move onto something else.
@_addToProxy({body: event.target.value}, {fromBodyChange: true})
@_throttledTrigger ?= _.debounce =>
@_ignoreNextTrigger = false
@_proxy.trigger()
, 100
@_throttledTrigger()
return
_onChangeEditableMode: ({showQuotedText}) =>
@setState showQuotedText: showQuotedText
@ -534,6 +557,12 @@ class ComposerView extends React.Component
oldDraft = @_proxy.draft()
return if _.all changes, (change, key) -> _.isEqual(change, oldDraft[key])
# Other extensions might want to hear about changes immediately. We
# only need to prevent this view from re-rendering until we're done
# throttling body changes.
if source.fromBodyChange then @_ignoreNextTrigger = true
@_proxy.changes.add(changes)
@_saveToHistory(selections) unless source.fromUndoManager

View file

@ -124,7 +124,7 @@ class ContenteditableComponent extends React.Component
# Note: Related to composer-view#_onClickComposeBody
event.stopPropagation()
_onInput: (event) =>
_onInput: =>
@_dragging = false
@_runExtensionFilters()

View file

@ -1,7 +1,7 @@
React = require 'react'
_ = require 'underscore'
{Contact, ContactStore} = require 'nylas-exports'
{Utils, Contact, ContactStore} = require 'nylas-exports'
{TokenizingTextField, Menu} = require 'nylas-component-kit'
class ParticipantsTextField extends React.Component
@ -28,6 +28,10 @@ class ParticipantsTextField extends React.Component
@defaultProps:
visible: true
shouldComponentUpdate: (nextProps, nextState) =>
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
render: =>
classSet = {}
classSet[@props.field] = true

View file

@ -31,10 +31,10 @@ class DraftChangeSet
clearTimeout(@_timer) if @_timer
@_timer = null
add: (changes, immediate) =>
add: (changes, {immediate, silent}={}) =>
@_pending = _.extend(@_pending, changes)
@_pending['pristine'] = false
@_onChange()
@_onChange() unless silent
if immediate
@commit()
else

View file

@ -433,7 +433,7 @@ class DraftStore
@sessionForLocalId(messageLocalId).then (session) ->
files = _.clone(session.draft().files) ? []
files = _.reject files, (f) -> f.id is file.id
session.changes.add({files}, true)
session.changes.add({files}, immediate: true)
module.exports = new DraftStore()