Skip to content

DocumentEditor: large tracked-change deletion hangs main thread on big documents #211

@ohcedar

Description

@ohcedar

Package: @syncfusion/ej2-documenteditor@33.1.44
Component: DocumentEditorContainer

Symptom

On a ~130-page DOCX with track changes enabled, programmatically deleting a selection that spans most of the document blocks the main thread long enough that Chrome shows a "page not responding" prompt (which the user has to dismiss multiple times before the operation completes). With track changes disabled, the exact same deletion on the exact same selection completes in a fraction of the time and does not trigger the unresponsive-page warning at all.

The operation does eventually succeed and the resulting document is correct — this is a latency problem, not a correctness bug.

Reproduction

  1. Load a DOCX of ~100+ pages into a DocumentEditorContainerComponent.
  2. Enable track changes.
  3. Programmatically select a span covering most of the document (e.g. editor.selection.select(startOffset, endOffset) where startOffset is near the top and endOffset is near the end).
  4. Call editor.editor.delete() followed by editor.editor.insertText("replacement").

Observed: several seconds of blocked main thread with track changes on; multiple Chrome unresponsive-page prompts on the worst-case whole-document span. With enableTrackChanges = false the same call sequence on the same selection is much faster and does not block long enough to trigger the prompt.

Environment

  • @syncfusion/ej2-documenteditor 33.1.44
  • DocumentEditorContainerComponent from @syncfusion/ej2-react-documenteditor
  • Chrome on macOS
  • Test document: ~130 pages, real-world DOCX with mixed formatting (not a synthetic stress test)

Hypothesis (from tracing the installed source)

Reading through editor.js in v33.1.44, the dominant cost on the tracked-changes path seems to be a per-paragraph reLayoutParagraph call inside Editor.prototype.removeInlines:

src/document-editor/implementation/editor/editor.js:18159-18161

if (!this.documentHelper.layout.isReplacingAll) {
    this.documentHelper.layout.reLayoutParagraph(paragraph, 0, 0);
}

removeInlines appears to run once per paragraph touched by the deletion. With track changes off, paragraphs get physically removed and the loop shrinks as it proceeds — which matches the observed fast behaviour. With track changes on, nothing is removed: every paragraph in the range still exists, so every one pays a full reLayoutParagraph pass on top of the per-inline addRevision bookkeeping. On a 130-page selection that's on the order of a thousand relayout passes in a tight loop.

This is a hypothesis from reading the source, not a profiler trace — happy to be corrected if the real hotspot is elsewhere.

Precedent — replaceAll already handles this pattern

Noticed that Search.prototype.replaceAll in src/document-editor/implementation/search/search.js (lines 189-234) uses two internal flags to skip the per-paragraph relayout during its own bulk operation and defer to a single final layout at the end:

this.documentHelper.layout.isReplacingAll = true;     // line 190
this.viewer.owner.isLayoutEnabled = false;            // line 198
// ... loop doing many edits ...
this.documentHelper.layout.isReplacingAll = false;    // line 233

The guard on line 18159 above is specifically checking isReplacingAll, so this mechanism is clearly deliberate for bulk work inside the library. Editor.prototype.deleteAllComments (editor.js:1130) uses the same isLayoutEnabled = false pattern around its bulk loop.

Question

Is there a supported public API for applying the same "one bulk edit, single final layout pass" treatment to a programmatic delete() + insertText() pair — i.e. a way to tell the DocumentEditor "I'm about to make one big edit, please don't relayout per paragraph along the way"? The isReplacingAll / isLayoutEnabled flags look internal and relying on them from application code feels fragile across Syncfusion versions. Ideally this would be something like an editor.beginBulkEdit() / endBulkEdit() pair, or an option on delete() that suppresses per-paragraph relayout.

A secondary (softer) question: whether the deletion-revision bookkeeping itself (one addRevision per inline) could be represented as a single range-level revision for very large tracked deletions. That's probably a bigger architectural change and I suspect the answer is "no, it's inherent to the data model," but flagging it in case it's on the roadmap.

Happy to run any requested instrumentation against the same reproduction DOCX — let me know if a profiler trace or a minimal reproduction in stackblitz would help.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions