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

[SuperEditor] Fix typing lag in large documents (Resolves #751) #839

Merged
merged 15 commits into from
Nov 2, 2022

Conversation

angelosilvestre
Copy link
Collaborator

[SuperEditor] Fix typing lag in large documents. Resolves #751

There is a lag when typing in a document with hundreds of nodes.

This PR is based on the investigation of @jmatth and @Jei-sKappa.

Some analysis after testing with the Frankenstein document posted by @jmatth (added as a gist here):

  • getNodeIndex takes some hundred microseconds to complete. It basically walks the entire list of nodes doing a deep comparison. Typing a single letter causes getNodeIndex to be called more than 3000 times. This function is called by getNodeBefore , getNodeAfter and others.
  • getNodeById takes some microseconds, but far less than getNodeIndex.
  • ParagraphComponentViewModel.== is broken. Checking textStyleBuilder == other.textStyleBuilder always evaluates to false. Typing a single letter in a paragraph causes all the components to be considered changed.

This PR changes MutableDocument, adding a Map to quickly lookup a node index by its id. This Map is eagerly updated when the document is created and in each operation which affects the node indices. A Map to lookup a node by its id was also added. Because of this Map's, I had to change nodes to return as an unmodifiable List, to avoid the node list being out of sync with the node maps.

This PR also removes textStyleBuilder from the == operators of the view models. However, we still need to figure out how to handle the case in which textStyleBuilder do change.

After this, I experienced another issue: toggling an attribution wasn't causing the style to be updated immediately. This happens because toggling an attribution just changes AttributedText internally. When the viewmodel checks if the text changed, it is the same instance, so it's considered as not changed. This bug was hidden because comparing textStyleBuilder always causes the viewmodel to be considered as changed.

This PR changes the attribution commands to create new AttributedText's.

After these changes:

2022-10-26.21-27-48.mp4

Dragging to select has still some lag:

2022-10-26.21-34-12.mp4

Most of the time seems to be spent on getDocumentSelectionInRegion, more specifically in a call to localToGlobal at:

Rect? _getLocalOverlapWithComponent(Rect region, DocumentComponent component) {
final componentBox = component.context.findRenderObject() as RenderBox;
final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject());
final componentBounds = contentOffset & componentBox.size;
if (region.overlaps(componentBounds)) {
// Report the overlap in our local coordinate space.
return region.translate(-contentOffset.dx, -contentOffset.dy);
} else {
return null;
}
}

When testing with this document I found another bug that is present in the latest main. Typing 1. to create a list item it throws this exception: type 'ListItemNode' is not a subtype of type 'ParagraphNode' of 'element'. This exception doesn't happen with the default example document.

@angelosilvestre
Copy link
Collaborator Author

I made an attempt to optimize mouse dragging:

2022-10-26.22-12-20.mp4

But it doesn't help when selecting nodes at the end of the document.

Copy link
Contributor

@matthew-carroll matthew-carroll left a comment

Choose a reason for hiding this comment

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

Generally LGTM. As of this point, what remaining problems do we have?

super_editor/lib/src/core/document_editor.dart Outdated Show resolved Hide resolved
super_editor/test/super_editor/supereditor_style_test.dart Outdated Show resolved Hide resolved
@angelosilvestre
Copy link
Collaborator Author

@matthew-carroll Right now, the only thing I think is missing is a way to detect when textStyleBuilder actually changes.

BTW. I replaced List.unmodifiable with UnmodifiableListView because List.unmodifiable copies all elements. With this change, the performance improved a little bit.

@angelosilvestre angelosilvestre marked this pull request as ready for review October 27, 2022 21:54
@matthew-carroll
Copy link
Contributor

@Jei-sKappa @jmatth @Mijawel - Can each of you give this PR a try and verify that it resolves your performance problems?

@Mijawel
Copy link

Mijawel commented Oct 29, 2022

Looks good. No obvious lag at 500 nodes/50,000 words. At 1000 nodes/100,000 words there is a decent amount of lag but it is still usable.

Much better than before. Was getting lag at 50 to 100 nodes.

@Jei-sKappa
Copy link

For me the performance is good, btw I also notice what @Mijawel said, but it's totally fine unless you type at 200+ wpm.

Unfortunately while testing the editor I noticed a bug when pasting text, I want to make clear that it could be totally unrelated but I wanted to point it out.
Steps to reproduce:

  • Run the example app
  • Add "some" empty nodes (see the video, it occurred not only with those lines)
  • Select all text (in my case with CMD+A)
  • Copy it
  • Paste it at the bottom
Registrazione.schermo.2022-10-29.alle.14.38.28.mov

The error output is:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following StateError was thrown building TextComponent-[GlobalKey#2a2a8](dirty, state:
TextComponentState#86374):
Bad state: No element

The relevant error-causing widget was:
TextComponent-[GlobalKey#2a2a8]
TextComponent:file:///Users/jac/Downloads/super_editor-751_typing_lag/super_editor/lib/src/default_editor/paragraph.dar
t:91:12

When the exception was thrown, this was the stack:
#0 List.last (dart:core-patch/growable_array.dart:348:5)
#1 AttributedSpans.collapseSpans (package:attributed_text/src/attributed_spans.dart:913:24)
#2 ComputeTextSpan.computeTextSpan (package:super_editor/src/infrastructure/attributed_text_styles.dart:32:34)
#3 TextComponentState.build (package:super_editor/src/default_editor/text.dart:783:31)
#4 StatefulElement.build (package:flutter/src/widgets/framework.dart:4992:27)
#5 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:4878:15)
#6 StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5050:11)
#7 Element.rebuild (package:flutter/src/widgets/framework.dart:4604:5)
#8 ComponentElement._firstBuild (package:flutter/src/widgets/framework.dart:4859:5)
#9 StatefulElement._firstBuild (package:flutter/src/widgets/framework.dart:5041:11)
#10 ComponentElement.mount (package:flutter/src/widgets/framework.dart:4853:5)
... Normal element mounting (24 frames)
#34 Element.inflateWidget (package:flutter/src/widgets/framework.dart:3863:16)
#35 MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:6435:36)
#36 Element.updateChild (package:flutter/src/widgets/framework.dart:3592:18)
#37 RenderObjectElement.updateChildren (package:flutter/src/widgets/framework.dart:5964:32)
#38 MultiChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:6460:17)
#39 Element.updateChild (package:flutter/src/widgets/framework.dart:3570:15)
#40 SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:6307:14)
#41 Element.updateChild (package:flutter/src/widgets/framework.dart:3570:15)
#42 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:4904:16)
#43 StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5050:11)
#44 Element.rebuild (package:flutter/src/widgets/framework.dart:4604:5)
#45 BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2667:19)
#46 WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:882:21)
#47 RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:378:5)
#48 SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1175:15)
#49 SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1104:9)
#50 SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1015:5)
#51 _invoke (dart:ui/hooks.dart:148:13)
#52 PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:318:5)
#53 _drawFrame (dart:ui/hooks.dart:115:31)

@angelosilvestre
Copy link
Collaborator Author

@Jei-sKappa I tested on the latest main and the issue also happens, so it should be unrelated.

@matthew-carroll
Copy link
Contributor

@Jei-sKappa can you file a new issue for that copy bug?

@matthew-carroll
Copy link
Contributor

BTW @Mijawel and @Jei-sKappa - would you two mind telling us a bit about how you're using Super Editor? If you'd like to send that info privately, you're welcome to come over to the Super Editor Slack channel. I'm curious, because the more we know about community use-cases, the better we can focus our roadmap.

@Jei-sKappa
Copy link

@Jei-sKappa can you file a new issue for that copy bug?

Issue just created!

BTW @Mijawel and @Jei-sKappa - would you two mind telling us a bit about how you're using Super Editor? If you'd like to send that info privately, you're welcome to come over to the Super Editor Slack channel. I'm curious, because the more we know about community use-cases, the better we can focus our roadmap.

Of course, I’d be happy to share how I use the editor.
I'll reach you in Slack.

super_editor/lib/src/core/document_editor.dart Outdated Show resolved Hide resolved
super_editor/lib/src/core/document_editor.dart Outdated Show resolved Hide resolved
super_editor/lib/src/core/document_editor.dart Outdated Show resolved Hide resolved
if (midleIndex + 1 < _topToBottomComponentKeys.length) {
// Check the gap between two components.
final nextComponentBounds = _getComponentBoundsByIndex(midleIndex + 1);
if (dy > componentBounds.bottom && dy < nextComponentBounds.top) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we divide these gaps in half? Otherwise, the component that happens to be in the middle of a search receives special treatment with regard to when it's considered selected.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated

attribution,
range,

// During style phase, we check which components changed, so we only re-style those components.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you file an issue for this? We should probably have a way to compare content.

Also, please add a TODO to each of these comments that references the issue that you file.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Maybe this will change in #748 ? I will reference this issue then.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nevermind, I was confused with another statement.

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 have a way to compare content. The AttributedText equality operator is defined as:

@override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is AttributedText && runtimeType == other.runtimeType && text == other.text && spans == other.spans;
  }

If we just toggle the attributions in the same instance, the first condition will always be true.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you reframe this as an action item? I'm not sure what to do with the above information...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ParagraphComponentViewModel compares its AttributedText as part of its equality operator (text == other.text).

Before the change, we were only modifying the contents of the same AttributedText that was referenced in the viewmodel. The contents changed, but as it's referencing the same object, the equality operator returns true, as identical(this, other) is true.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Let's change the comment to:

// Create a new AttributedText with updated attribution spans, so that the presentation system can
// see that we made a change, and re-renders the text in the document.

Let's change the implementation to:

node.text = AttributedText(
  text: node.text.text,
  spans: node.text.spans.copy()..addAttribution(
    newAttribution: attribution,
    start: range.start,
    end: range.end,
  ),
);

super_editor/test/src/default_editor/text_test.dart Outdated Show resolved Hide resolved
onViewModelChange: ({required addedComponents, required changedComponents, required removedComponents}) {
// The listener is called two times. The first one for the text change,
// and the second one for the selection change.
componentChangedCount = max(componentChangedCount, changedComponents.length);
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not obvious to me what this max() is trying to accomplish. Can we check the behavior with hard-coded values to make things super clear?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's because typing a letter cause 2 calls to onViewModelChange, so the selection change overrides the value we care about. I will try to change to make it more obvious.

This might change after #748 is closed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you use an if-statement, or some explicit control structure, rather than calculate a max number?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

attribution,
range,

// During style phase, we check which components changed, so we only re-style those components.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you reframe this as an action item? I'm not sure what to do with the above information...

super_editor/test/src/default_editor/text_test.dart Outdated Show resolved Hide resolved
@jmatth
Copy link
Contributor

jmatth commented Nov 1, 2022

This resolves the performance issues for me.

@@ -162,7 +168,7 @@ class MutableDocument with ChangeNotifier implements Document {

@override
int getNodeIndex(DocumentNode node) {
return _nodes.indexOf(node);
return _nodeIndicesById[node.id] ?? -1;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is changing the behavior of the method slightly. It will now return an index if you pass a node that shares an id with one in the document even if they are otherwise unequal. Additionally, if someone implemented custom nodes with overridden == behavior, this method may now return -1 instead of a valid index.

In my implementation I used the cache to quickly look up the node and then performed the comparison to maintain the existing behavior. Code here.

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 updated to add the equality check.

Copy link
Contributor

Choose a reason for hiding this comment

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

I thought about this a bit more. Why would there ever be 2 nodes with the same ID and different content? The point of the ID is to uniquely refer to a node within a document...

Copy link
Contributor

Choose a reason for hiding this comment

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

@jmatth can you let me know when/why this change would be a problem?

Copy link
Contributor

Choose a reason for hiding this comment

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

I could trivially construct two nodes with the same ID that are otherwise unequal, store one in a MutableDocument, then pass the second one to getNodeIndex and get a valid index back. I find that result unexpected. Best example I can think of for why this would happen is converting between node types, such as a paragraph to a list item or similar, but I don't actually have any real world examples. If you don't think that's a reasonable use case to accommodate then this method should just be removed entirely, because getNodeIndexById also exists, its signature makes it much clearer what the criteria for a match are, and without the equality check the two methods serve the exact same purpose.

Copy link
Contributor

Choose a reason for hiding this comment

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

That said, if removal looks acceptable, there's probably no reason to make that breaking change now. We're trying to land a semi-stable release after this PR. So @angelosilvestre if you think removal looks OK, then add a "deprecated" annotation to the method and we'll merge it in as-is.

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 will take a look.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@matthew-carroll We are using this method in a number of places, but we could change them to use getNodeIndexById. Should I add the deprecated annotation and change the usages?

Copy link
Contributor

Choose a reason for hiding this comment

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

If you've inspected all of those locations, and you're comfortable that changing them won't be a big deal, then sure, you can go ahead and deprecate this method. Please be sure to include a deprecation message that points to the other method.

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 marked getNodeIndex as deprecated and changed the places which use this method.

}

if (_nodes[index] != node) {
// We found a node by id, but the contents aren't equal.
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 I would word this a bit differently: // We found a node by id, but it wasn't the node we expected. Therefore, we couldn't find the requested node

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

Copy link
Contributor

@matthew-carroll matthew-carroll left a comment

Choose a reason for hiding this comment

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

LGTM

@matthew-carroll matthew-carroll merged commit ae571de into superlistapp:main Nov 2, 2022
@angelosilvestre angelosilvestre deleted the 751_typing_lag branch November 2, 2022 23:27
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.

Typing lag
5 participants