perf: avoid uneccessary notes list item rerenders (#1904)

This commit is contained in:
Mo
2022-10-30 10:48:23 -05:00
committed by GitHub
parent 32f03d9470
commit 89927a3790
14 changed files with 208 additions and 68 deletions

View File

@@ -7,7 +7,7 @@ import { Component } from 'react'
export type PureComponentState = Partial<Record<string, any>>
export type PureComponentProps = Partial<Record<string, any>>
export abstract class PureComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> {
export abstract class AbstractComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> {
private unsubApp!: () => void
private reactionDisposers: IReactionDisposer[] = []

View File

@@ -4,7 +4,7 @@ import { ApplicationEvent, Challenge, removeFromArray, WebAppEvent } from '@stan
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants/Constants'
import { alertDialog, RouteType } from '@standardnotes/ui-services'
import { WebApplication } from '@/Application/Application'
import Navigation from '@/Components/Navigation/Navigation'
import Navigation from '@/Components/Tags/Navigation'
import NoteGroupView from '@/Components/NoteGroupView/NoteGroupView'
import Footer from '@/Components/Footer/Footer'
import SessionsModal from '@/Components/SessionsModal/SessionsModal'

View File

@@ -40,6 +40,7 @@ const ContentList: FunctionComponent<Props> = ({
const { selectPreviousItem, selectNextItem } = selectionController
const { hideTags, hideDate, hideNotePreview, hideEditorIcon } = itemListController.webDisplayOptions
const { sortBy } = itemListController.displayOptions
const selectedTag = navigationController.selected
const onScroll: UIEventHandler = useCallback(
(e) => {
@@ -72,25 +73,27 @@ const ContentList: FunctionComponent<Props> = ({
[selectionController],
)
const getTagsForItem = (item: ListableContentItem) => {
if (hideTags) {
return []
}
const getTagsForItem = useCallback(
(item: ListableContentItem) => {
if (hideTags) {
return []
}
const selectedTag = navigationController.selected
if (!selectedTag) {
return []
}
if (!selectedTag) {
return []
}
const tags = application.getItemTags(item)
const tags = application.getItemTags(item)
const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1
if (isNavigatingOnlyTag) {
return []
}
const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1
if (isNavigatingOnlyTag) {
return []
}
return tags
}
return tags
},
[hideTags, selectedTag, application],
)
return (
<div

View File

@@ -1,8 +1,8 @@
import { ContentType } from '@standardnotes/snjs'
import { FunctionComponent } from 'react'
import React, { FunctionComponent } from 'react'
import FileListItem from './FileListItem'
import NoteListItem from './NoteListItem'
import { AbstractListItemProps } from './Types/AbstractListItemProps'
import { AbstractListItemProps, doListItemPropsMeritRerender } from './Types/AbstractListItemProps'
const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
switch (props.item.content_type) {
@@ -15,4 +15,4 @@ const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
}
}
export default ContentListItem
export default React.memo(ContentListItem, (a, b) => !doListItemPropsMeritRerender(a, b))

View File

@@ -13,6 +13,7 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
import ListItemNotePreviewText from './ListItemNotePreviewText'
import { ListItemTitle } from './ListItemTitle'
import { log, LoggingDomain } from '@/Logging'
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
application,
@@ -70,6 +71,8 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
useContextMenuEvent(listItemRef, openContextMenu)
log(LoggingDomain.ItemsList, 'Rendering note list item', item.title)
return (
<div
ref={listItemRef}

View File

@@ -1,7 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { FilesController } from '@/Controllers/FilesController'
import { NotesController } from '@/Controllers/NotesController'
import { SortableItem, SNTag } from '@standardnotes/snjs'
import { SortableItem, SNTag, Uuids } from '@standardnotes/snjs'
import { ListableContentItem } from './ListableContentItem'
export type AbstractListItemProps = {
@@ -18,3 +18,89 @@ export type AbstractListItemProps = {
sortBy: keyof SortableItem | undefined
tags: SNTag[]
}
export function doListItemPropsMeritRerender(previous: AbstractListItemProps, next: AbstractListItemProps): boolean {
const simpleComparison: (keyof AbstractListItemProps)[] = [
'onSelect',
'hideDate',
'hideIcon',
'hideTags',
'hidePreview',
'selected',
'sortBy',
]
for (const key of simpleComparison) {
if (previous[key] !== next[key]) {
return true
}
}
if (previous['item'] !== next['item']) {
if (doesItemChangeMeritRerender(previous['item'], next['item'])) {
return true
}
}
return doesTagsChangeMeritRerender(previous['tags'], next['tags'])
}
function doesTagsChangeMeritRerender(previous: SNTag[], next: SNTag[]): boolean {
if (previous === next) {
return false
}
if (previous.length !== next.length) {
return true
}
if (previous.length === 0 && next.length === 0) {
return false
}
if (Uuids(previous).sort().join() !== Uuids(next).sort().join()) {
return true
}
if (
previous
.map((t) => t.title)
.sort()
.join() !==
next
.map((t) => t.title)
.sort()
.join()
) {
return true
}
return false
}
function doesItemChangeMeritRerender(previous: ListableContentItem, next: ListableContentItem): boolean {
if (previous.uuid !== next.uuid) {
return true
}
const propertiesMeritingRerender: (keyof ListableContentItem)[] = [
'title',
'protected',
'updatedAtString',
'createdAtString',
'hidePreview',
'preview_html',
'preview_plain',
'archived',
'starred',
'pinned',
]
for (const key of propertiesMeritingRerender) {
if (previous[key] !== next[key]) {
return true
}
}
return false
}

View File

@@ -1,6 +1,6 @@
import { WebApplication } from '@/Application/Application'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import { destroyAllObjectProperties, preventRefreshing } from '@/Utils'
import { ApplicationEvent, ApplicationDescriptor, WebAppEvent } from '@standardnotes/snjs'
import {
@@ -41,7 +41,7 @@ type State = {
arbitraryStatusMessage?: string
}
class Footer extends PureComponent<Props, State> {
class Footer extends AbstractComponent<Props, State> {
public user?: unknown
private didCheckForOffline = false
private completedInitialSync = false

View File

@@ -1,5 +1,5 @@
import { FileItem, FileViewController, NoteViewController } from '@standardnotes/snjs'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import { WebApplication } from '@/Application/Application'
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
@@ -22,7 +22,7 @@ type Props = {
application: WebApplication
}
class NoteGroupView extends PureComponent<Props, State> {
class NoteGroupView extends AbstractComponent<Props, State> {
private removeChangeObserver!: () => void
constructor(props: Props) {

View File

@@ -1,49 +1,50 @@
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
import ComponentView from '@/Components/ComponentView/ComponentView'
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import PanelResizer, { PanelResizeType, PanelSide } from '@/Components/PanelResizer/PanelResizer'
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
import { ElementIds } from '@/Constants/ElementIDs'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { StringDeleteNote, STRING_DELETE_LOCKED_ATTEMPT, STRING_DELETE_PLACEHOLDER_ATTEMPT } from '@/Constants/Strings'
import { log, LoggingDomain } from '@/Logging'
import { debounce, isDesktopApplication, isMobileScreen } from '@/Utils'
import { classNames } from '@/Utils/ConcatenateClassNames'
import {
ApplicationEvent,
isPayloadSourceRetrieved,
isPayloadSourceInternalChange,
ContentType,
SNComponent,
SNNote,
ComponentArea,
PrefKey,
ComponentViewerInterface,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
ContentType,
EditorFontSize,
EditorLineHeight,
isPayloadSourceInternalChange,
isPayloadSourceRetrieved,
NoteType,
NoteViewController,
PayloadEmitSource,
PrefKey,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
SNComponent,
SNNote,
WebAppEvent,
EditorLineHeight,
EditorFontSize,
NoteType,
} from '@standardnotes/snjs'
import { debounce, isDesktopApplication, isMobileScreen } from '@/Utils'
import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { EditorEventSource } from '../../Types/EditorEventSource'
import { confirmDialog, KeyboardModifier, KeyboardKey } from '@standardnotes/ui-services'
import { STRING_DELETE_PLACEHOLDER_ATTEMPT, STRING_DELETE_LOCKED_ATTEMPT, StringDeleteNote } from '@/Constants/Strings'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import ComponentView from '@/Components/ComponentView/ComponentView'
import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { ElementIds } from '@/Constants/ElementIDs'
import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import EditingDisabledBanner from './EditingDisabledBanner'
import { reloadFont } from './FontFunctions'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
import { NoteViewProps } from './NoteViewProps'
import {
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from './TransactionFunctions'
import { reloadFont } from './FontFunctions'
import { NoteViewProps } from './NoteViewProps'
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import { classNames } from '@/Utils/ConcatenateClassNames'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
const MinimumStatusDuration = 400
const TextareaDebounce = 100
@@ -98,7 +99,7 @@ const PlaintextFontSizeMapping: Record<EditorFontSize, string> = {
Large: 'text-xl',
}
class NoteView extends PureComponent<NoteViewProps, State> {
class NoteView extends AbstractComponent<NoteViewProps, State> {
readonly controller!: NoteViewController
private statusTimeout?: NodeJS.Timeout
@@ -193,7 +194,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
;(this.onPanelResizeFinish as unknown) = undefined
;(this.stackComponentExpanded as unknown) = undefined
;(this.toggleStackComponent as unknown) = undefined
;(this.onSystemEditorLoad as unknown) = undefined
;(this.onSystemEditorRef as unknown) = undefined
;(this.debounceReloadEditorComponent as unknown) = undefined
;(this.textAreaChangeDebounceSave as unknown) = undefined
;(this.editorContentRef as unknown) = undefined
@@ -207,6 +208,24 @@ class NoteView extends PureComponent<NoteViewProps, State> {
return this.controller.item
}
override shouldComponentUpdate(_nextProps: Readonly<NoteViewProps>, nextState: Readonly<State>): boolean {
const complexObjects: (keyof State)[] = ['availableStackComponents', 'stackComponentViewers']
for (const key of Object.keys(nextState) as (keyof State)[]) {
if (complexObjects.includes(key)) {
continue
}
const prevValue = this.state[key]
const nextValue = nextState[key]
if (prevValue !== nextValue) {
log(LoggingDomain.NoteView, 'Rendering due to state change', key, prevValue, nextValue)
return true
}
}
return false
}
override componentDidMount(): void {
super.componentDidMount()
@@ -253,6 +272,8 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
onNoteInnerChange(note: SNNote, source: PayloadEmitSource): void {
log(LoggingDomain.NoteView, 'On inner note change', PayloadEmitSource[source])
if (note.uuid !== this.note.uuid) {
throw Error('Editor received changes for non-current note')
}
@@ -431,12 +452,15 @@ class NoteView extends PureComponent<NoteViewProps, State> {
streamItems() {
this.removeComponentStreamObserver = this.application.streamItems(ContentType.Component, async ({ source }) => {
log(LoggingDomain.NoteView, 'On component stream observer', PayloadEmitSource[source])
if (isPayloadSourceInternalChange(source) || source === PayloadEmitSource.InitialObserverRegistrationPush) {
return
}
if (!this.note) {
return
}
await this.reloadStackComponents()
this.debounceReloadEditorComponent()
})
@@ -454,6 +478,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
if (this.state.editorComponentViewerDidAlreadyReload && !force) {
return
}
const component = viewer.component
this.application.componentManager.destroyComponentViewer(viewer)
this.setState(
@@ -489,6 +514,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
async reloadEditorComponent() {
log(LoggingDomain.NoteView, 'Reload editor component')
if (this.state.showProtectedWarning) {
this.destroyCurrentEditorComponent()
return
@@ -581,13 +607,16 @@ class NoteView extends PureComponent<NoteViewProps, State> {
onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value
this.setState({
editorText: text,
})
this.textAreaChangeDebounceSave()
}
textAreaChangeDebounceSave = () => {
log(LoggingDomain.NoteView, 'Performing save after debounce')
this.controller
.save({
editorValues: {
@@ -609,10 +638,14 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
onTitleChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
log(LoggingDomain.NoteView, 'Performing save after title change')
const title = currentTarget.value
this.setState({
editorTitle: title,
})
this.controller
.save({
editorValues: {
@@ -662,10 +695,12 @@ class NoteView extends PureComponent<NoteViewProps, State> {
this.application.alertService.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT).catch(console.error)
return
}
if (this.note.locked) {
this.application.alertService.alert(STRING_DELETE_LOCKED_ATTEMPT).catch(console.error)
return
}
const title = this.note.title.length ? `'${this.note.title}'` : 'this note'
const text = StringDeleteNote(title, permanently)
if (
@@ -727,6 +762,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
async reloadPreferences() {
log(LoggingDomain.NoteView, 'Reload preferences')
const monospaceFont = this.application.getPreference(
PrefKey.EditorMonospaceEnabled,
PrefDefaults[PrefKey.EditorMonospaceEnabled],
@@ -776,9 +812,8 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
}
/** @components */
async reloadStackComponents() {
log(LoggingDomain.NoteView, 'Reload stack components')
const stackComponents = sortAlphabetically(
this.application.componentManager
.componentsForArea(ComponentArea.EditorStack)
@@ -844,10 +879,13 @@ class NoteView extends PureComponent<NoteViewProps, State> {
})
}
onSystemEditorLoad = (ref: HTMLTextAreaElement | null) => {
onSystemEditorRef = (ref: HTMLTextAreaElement | null) => {
if (this.removeTabObserver || !ref) {
return
}
log(LoggingDomain.NoteView, 'On system editor ref')
/**
* Insert 4 spaces when a tab key is pressed,
* only used when inside of the text editor.
@@ -1070,7 +1108,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
onFocus={this.onContentFocus}
onBlur={this.onContentBlur}
readOnly={this.state.noteLocked}
ref={(ref) => ref && this.onSystemEditorLoad(ref)}
ref={(ref) => ref && this.onSystemEditorRef(ref)}
spellCheck={this.state.spellcheck}
value={this.state.editorText}
className={classNames(

View File

@@ -1,6 +1,6 @@
import { WebApplication } from '@/Application/Application'
import { createRef } from 'react'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import Button from '@/Components/Button/Button'
import DecoratedPasswordInput from '../Input/DecoratedPasswordInput'
import ModalDialog from '../Shared/ModalDialog'
@@ -38,7 +38,7 @@ type FormData = {
status?: string
}
class PasswordWizard extends PureComponent<Props, State> {
class PasswordWizard extends AbstractComponent<Props, State> {
private currentPasswordInput = createRef<HTMLInputElement>()
constructor(props: Props) {

View File

@@ -1,12 +1,12 @@
import { WebApplication } from '@/Application/Application'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
type Props = {
application: WebApplication
close: () => void
}
class SyncResolutionMenu extends PureComponent<Props> {
class SyncResolutionMenu extends AbstractComponent<Props> {
constructor(props: Props) {
super(props, props.application)
}

View File

@@ -28,6 +28,7 @@ import { mergeRefs } from '@/Hooks/mergeRefs'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController'
import { TagListSectionType } from './TagListSection'
import { log, LoggingDomain } from '@/Logging'
type Props = {
tag: SNTag
@@ -237,6 +238,8 @@ export const TagsListItem: FunctionComponent<Props> = observer(
}
}, [addDragTarget, linkingController, removeDragTarget, tag])
log(LoggingDomain.NavigationList, 'Rendering TagsListItem')
return (
<>
<div

View File

@@ -1,15 +1,22 @@
import { log as utilsLog } from '@standardnotes/utils'
import { isDev } from './Utils'
export enum LoggingDomain {
DailyNotes,
NoteView,
ItemsList,
NavigationList,
}
const LoggingStatus: Record<LoggingDomain, boolean> = {
[LoggingDomain.DailyNotes]: false,
[LoggingDomain.NoteView]: false,
[LoggingDomain.ItemsList]: false,
[LoggingDomain.NavigationList]: false,
}
export function log(domain: LoggingDomain, ...args: any[]): void {
if (!LoggingStatus[domain]) {
if (!isDev || !LoggingStatus[domain]) {
return
}