-
Notifications
You must be signed in to change notification settings - Fork 274
Add design doc: Reducing VisiblePosition usage in editing commands #1288
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
Open
shwetabin
wants to merge
4
commits into
MicrosoftEdge:main
Choose a base branch
from
shwetabin:editing-visible-position-cleanup
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+327
−0
Open
Changes from 3 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,299 @@ | ||
| # Design: Reducing VisiblePosition Usage in Editing Commands | ||
|
|
||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| --- | ||
|
|
||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ## Background | ||
|
|
||
| `VisiblePosition` is Blink's canonicalized cursor position type - it answers *where the caret visually appears given current layout*. It belongs at exactly two points in the editing pipeline: | ||
|
|
||
| - **Entry** - renderer hands a `VisibleSelection` to the command system. | ||
| - **Exit** - command hands its result back to update the caret on screen. | ||
|
|
||
| Inside `DoApply()`, commands work on DOM structure - raw `Position` questions, no layout needed. VP is used throughout command bodies as a type adapter because `IsEndOfParagraph`, `MoveParagraph`, etc. had no `Position` overloads when written. Every such call silently forces a full layout update, interleaved with DOM mutations. | ||
shwetabin marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| --- | ||
|
|
||
| ## Problem Statement | ||
|
|
||
| ### User-visible symptoms | ||
| - **Typing lag** - each keystroke triggers multiple full style+layout passes against a partially-mutated tree. | ||
| - **Paste jank** - `ReplaceSelectionCommand` triggers dozens of layout passes for a single Ctrl+V on complex content. | ||
| - **Slow indent/outdent** - `IndentOutdentCommand` loops per-paragraph; VP construction multiplies layout cost by paragraph count. | ||
| - **Wrong-caret bugs** - stale VPs are silently used after DOM mutations (`IsValid()` is `DCHECK`-only in release). Caret lands in wrong position after paste, list insertion, or indent. | ||
shwetabin marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| - **Blocks future work** - every VP inside `DoApply()` is an implicit main-thread layout dependency, blocking batched undo, speculative editing, and off-thread IME. | ||
shwetabin marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ### Known bugs blocked on this work | ||
| - [Hotlist 7675360](https://issues.chromium.org/hotlists/7675360) - 25-30 open issues: wrong caret after paste, bad selection after list insertion, undo/redo producing wrong selection, indent leaving caret in detached subtree. | ||
| - Several WPT failures in `editing/` and `contenteditable/` where stale VP after mutation is the root cause. | ||
|
|
||
| ### Scale | ||
|
|
||
| | Metric | Count | | ||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| |--------|-------| | ||
| | `VisiblePosition` type usage | ~391 across 18 files | | ||
| | `CreateVisiblePosition()` calls | ~126 across 16 files | | ||
| | `.DeepEquivalent()` calls (VP unwrapping) | ~288 across 18 files | | ||
shwetabin marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| Top offenders: `composite_edit_command.cc` (112), `replace_selection_command.cc` (98), `insert_list_command.cc` (91), `delete_selection_command.cc` (64), `indent_outdent_command.cc` (61). | ||
|
|
||
| ~110 of 126 `CreateVisiblePosition` calls (~87%) are unnecessary type adapters. Only ~16 sites are genuinely load-bearing. | ||
|
|
||
| ### After this work | ||
|
|
||
| | Metric | Before | After | | ||
| |--------|--------|-------| | ||
| | `CreateVisiblePosition` calls in `commands/` | 126 | ~16 | | ||
| | `VisiblePosition` type references in `commands/` | ~391 | ~66 | | ||
| | Unnecessary `UpdateStyleAndLayout` per editing op | multiple | 0 | | ||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| --- | ||
|
|
||
| ### The Correct Abstraction Boundary | ||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| ``` | ||
| User Interaction | ||
| | | ||
| v | ||
| [Boundary-IN] VisibleSelection → SelectionInDOMTree (renderer → command) | ||
| | | ||
| v | ||
| [Command body] Works entirely with Position / PositionWithAffinity / EphemeralRange | ||
| | | ||
| v | ||
| [Boundary-OUT] SelectionInDOMTree → SetEndingSelection(...) (command → renderer) | ||
| ``` | ||
|
|
||
| `VisiblePosition` belongs only at the two boundary points. The problem is that commands create VPs deep inside `DoApply()`. | ||
|
|
||
| --- | ||
|
|
||
| ## Anti-Patterns to Eliminate | ||
|
|
||
| Patterns are ordered by priority: highest VP count reduction first, then safety risk. | ||
|
|
||
| ### P1: VP parameter forces caller canonicalization | ||
|
|
||
| `MoveParagraph` / `MoveParagraphWithClones` / `MoveParagraphs` in | ||
| `composite_edit_command.h` take `const VisiblePosition&`. Every caller in | ||
| `indent_outdent_command.cc`, `insert_list_command.cc`, `delete_selection_command.cc`, | ||
| etc. must wrap raw positions into VPs before calling. Fixing the three method | ||
| signatures (Step 3) removes ~80 VPs across all callers at once. | ||
|
|
||
| ### P2: `EndingVisibleSelection()` called mid-command for raw position access | ||
|
|
||
| `EndingVisibleSelection()` unconditionally calls `UpdateStyleAndLayout` then wraps | ||
| the result in a `VisibleSelection`. It appears in two unnecessary forms throughout | ||
| `replace_selection_command.cc`, `composite_edit_command.cc`, | ||
| `indent_outdent_command.cc`, `insert_list_command.cc`, and others: | ||
|
|
||
| | Sub-pattern | Replacement | | ||
| |---|---| | ||
| | Raw position only | `EndingSelection().Start()` / `.End()` - no layout | | ||
| | After explicit layout gate, position used for navigation | Keep `UpdateStyleAndLayout`; replace `EndingVisibleSelection().VisibleStart()` with `EndingSelection().Start()` and use `Position` overloads | | ||
| | VP from `EndingVisibleSelection()` passed directly to navigation function | `NavigationFn(EndingSelection().Start(), rule)` - explicit gate already covers layout | | ||
|
|
||
| `EndingSelection()` returns a `SelectionForUndoStep` with raw `Position` values that | ||
| are already correct - no re-canonicalization needed. | ||
|
|
||
| ### P3: VP stored across a DOM mutation | ||
shwetabin marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| A `VisiblePosition` is captured before a DOM mutation and referenced after. | ||
| `IsValid()` is `DCHECK`-only in release builds, so stale VP is silently used rather | ||
| than caught. See crbug.com/648949. Low frequency but highest safety risk - silent | ||
| wrong-caret corruption with no assertion to catch it. | ||
|
|
||
| ### P4: VP as a type requirement for navigation tests | ||
|
|
||
| `IsEndOfParagraph` / `IsStartOfParagraph` have no `Position` overload today, so VP | ||
| creation is forced purely to satisfy the type signature. High frequency across all | ||
| command files; mechanical to fix once Step 1 overloads exist. | ||
|
|
||
| ### P5: Create-then-extract | ||
|
|
||
| A VP is created from a `PositionWithAffinity` or raw `Position` and immediately | ||
| passed to a navigation function. The VP serves only as a type adapter - the position | ||
| was already correct before the wrap. Fixed mechanically alongside P4 sites. | ||
|
|
||
| --- | ||
|
|
||
| ## Proposed Approach: bottom-up foundation, top-down infrastructure | ||
|
|
||
| 1. **Add missing `Position`-based overloads** for navigation functions in `position_units` and `editing_commands_utilities` (bottom-up, purely additive). | ||
| 2. **Change `CompositeEditCommand` infrastructure signatures** to take `Position` instead of `VisiblePosition` (top-down, uses the new overloads internally). | ||
| 3. **Migrate command files** using the new overloads and the new infrastructure signatures, in ascending order of VP count. | ||
|
|
||
| --- | ||
|
|
||
| ## Step 1: Add Position-based Navigation Overloads | ||
|
|
||
| **Nature:** Purely additive. No existing code is changed. Each overload is its own CL with tests. Zero regression risk. | ||
|
|
||
| ### New file: `position_units.h` / `position_units.cc` | ||
|
|
||
| All `Position`-based navigation overloads live in a new dedicated pair. No | ||
| `VisiblePosition` type appears anywhere in these files. The existing | ||
| `Position`/`PositionWithAffinity` overloads currently in `visible_units.h` are | ||
| relocated here in CL 1-A; the originals are removed. Callers that needed only those | ||
| overloads switch to including `position_units.h`; files that still use VP-returning | ||
| functions keep `visible_units.h`. | ||
|
|
||
| ### Gaps to fill | ||
|
|
||
| New `Position` overloads to add to `position_units.h` / `position_units.cc`: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How many functions we are overloading? Rough number? If new files are being created, call those names out. |
||
|
|
||
| **Paragraph boundary** (call algorithm templates in `visible_units_paragraph.cc`): | ||
| `StartOfParagraph`, `EndOfParagraph`, `IsStartOfParagraph`, `IsEndOfParagraph`, | ||
| `StartOfNextParagraph`, `InSameParagraph`. | ||
|
|
||
| **Document boundary**: `EndOfDocument`, `IsStartOfDocument`, `IsEndOfDocument`. | ||
|
|
||
| **Position traversal**: `PreviousPositionOf(const Position&, EditingBoundaryCrossingRule)`, | ||
| `NextPositionOf(const Position&, EditingBoundaryCrossingRule)`. These bypass the | ||
| `CreateVisiblePosition` wrap in the existing VP overloads by calling | ||
| `PreviousVisuallyDistinctCandidate` / `NextVisuallyDistinctCandidate` directly. Do | ||
| not change existing VP signatures. | ||
|
|
||
| **Character query**: `CharacterAfter(const Position&)`. | ||
|
|
||
| **Line boundary**: `IsStartOfLine` and `IsEndOfLine` taking `const PositionWithAffinity&` | ||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| and `const Position&`. No `Position` overloads exist today; needed because | ||
| `IsStartOfLine(CreateVisiblePosition(pos, affinity))` appears in | ||
| `editing_commands_utilities.cc` with no layout-free replacement. | ||
|
|
||
| **In `editing_commands_utilities.h` / `.cc`** (not `position_units` - depends on | ||
| block-structure helpers private to that file): `StartOfBlock`, `EndOfBlock`, | ||
| `IsStartOfBlock`, `IsEndOfBlock`, `EnclosingEmptyListItem`. | ||
|
|
||
| ### Already in `visible_units.h` - relocate to `position_units.h` | ||
|
|
||
| `StartOfWordPosition`, `EndOfWordPosition`, `StartOfLine(PositionWithAffinity)`, | ||
| `EndOfLine(PositionWithAffinity)`, `InSameLine(PositionWithAffinity, PositionWithAffinity)`, | ||
| `StartOfDocument(Position)`. | ||
|
|
||
| ### Implementation strategy | ||
|
|
||
| New overloads in `position_units.cc` call through to the existing private algorithm | ||
| templates in the originating files (e.g. `StartOfParagraphAlgorithm<EditingStrategy>` | ||
| in `visible_units_paragraph.cc`). Algorithm templates are not moved. For functions | ||
| with no existing `Position`-typed algorithm, write the algorithm first and have both | ||
| the VP overload and the new `Position` overload call into it. | ||
|
|
||
| --- | ||
|
|
||
| ## Step 2: Migrate Simple Commands | ||
|
|
||
| Migrate the 5 lowest-VP command files. These validate that the new overloads are | ||
| drop-in replacements against the WPT editing test suite. | ||
|
|
||
| | File | VP refs | Notes | | ||
| |------|---------|-------| | ||
| | `style_commands.cc` | 1 | | | ||
| | `undo_step.cc` | 2 | | | ||
| | `editor_command.cc` | 6 | | | ||
| | `insert_line_break_command.cc` | 6 | P3 and P5 examples | | ||
shwetabin marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| | `insert_text_command.cc` | 7 | | | ||
|
|
||
| ### Migration patterns | ||
|
|
||
| | Before | After | | ||
| |---|---| | ||
| | `CreateVisiblePosition(pos).DeepEquivalent()` | `pos` - drop entirely; do not substitute `CanonicalPositionOf` | | ||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| | `IsEndOfParagraph(CreateVisiblePosition(pos))` | `IsEndOfParagraph(pos)` | | ||
| | `StartOfParagraph(vp).DeepEquivalent()` | `StartOfParagraph(pos)` | | ||
| | `VP::FirstPositionInNode(*node).DeepEquivalent()` | `Position::FirstPositionInNode(*node)` | | ||
| | `VP::LastPositionInNode(*node).DeepEquivalent()` | `Position::LastPositionInNode(*node)` | | ||
| | `LineBreakExistsAtVisiblePosition(vp)` | `LineBreakExistsAtPosition(pos)` | | ||
| | `VisiblePosition::BeforeNode(*node)` | `Position::BeforeNode(*node)` | | ||
| | `EndingVisibleSelection().VisibleStart().DeepEquivalent()` | `EndingSelection().Start()` | | ||
|
|
||
| --- | ||
|
|
||
| ## Step 3: Infrastructure Signature Changes | ||
|
|
||
| Change the five `CompositeEditCommand` protected methods from `const VisiblePosition&` | ||
| to `const Position&`. Method bodies migrate simultaneously using the Step 1 overloads. | ||
|
|
||
| - `MoveParagraph`, `MoveParagraphs` - three params each | ||
| - `MoveParagraphWithClones` - two params | ||
| - `CleanupAfterDeletion` - one param with default | ||
| - `ReplaceCollapsibleWhitespaceWithNonBreakingSpaceIfNeeded` - one param | ||
|
|
||
| All callers in `indent_outdent_command.cc`, `insert_list_command.cc`, | ||
| `delete_selection_command.cc`, `apply_block_element_command.cc`, | ||
| `format_block_command.cc` drop their `CreateVisiblePosition` wrappers and pass | ||
| `Position` directly. | ||
|
|
||
| --- | ||
|
|
||
| ## Step 4: Heavy Command Migration | ||
|
|
||
| With Step 1 overloads and Step 3 infrastructure in place, the heavy files fall into | ||
| two categories: | ||
|
|
||
| - **Category A** (fixed by Step 3): VP created solely to pass to `MoveParagraph` / `MoveParagraphWithClones` / `CleanupAfterDeletion`. | ||
| - **Category B** (fixed by Step 1): VP created solely to call `IsEndOfParagraph`, `IsStartOfParagraph`, `StartOfParagraph`, `EndOfParagraph`, `StartOfBlock`, etc. | ||
|
|
||
| | File | VP refs | Primary category | | ||
| |------|---------|-----------------| | ||
| | `insert_paragraph_separator_command.cc` | 19 | B | | ||
| | `apply_block_element_command.cc` | 37 | B + A | | ||
| | `indent_outdent_command.cc` | 61 | A | | ||
| | `delete_selection_command.cc` | 64 | A + B | | ||
| | `insert_list_command.cc` | 91 | A + B | | ||
| | `replace_selection_command.cc` | 98 | B | | ||
|
|
||
| `composite_edit_command.cc` (112 refs): Step 3 covers the method signatures. The | ||
| remaining VP in `RebalanceWhitespaceOnTextSubstring`, | ||
| `MoveParagraphContentsToNewBlockIfNecessary`, `BreakOutOfEmptyListItem`, | ||
| `BreakOutOfEmptyMailBlockquotedParagraph` are addressed in Step 4. | ||
|
|
||
| ### Estimated VP reduction | ||
|
|
||
| | Work item | Estimated VPs removed | | ||
| |-----------|-----------------------| | ||
| | Simple command migration (Step 2) | ~25 | | ||
| | Infrastructure signature changes (Step 3) | ~80 | | ||
| | Heavy command migration (Step 4) | ~220 | | ||
| | **Total** | **~325 of ~391 (83%)** | | ||
|
|
||
| --- | ||
|
|
||
| ## What is Legitimately VP (Do Not Remove) | ||
|
|
||
| 1. **Entry boundary.** `CompositeEditCommand` constructor calling `ComputeVisibleSelectionInDOMTreeDeprecated()`. | ||
|
|
||
| 2. **Exit boundary.** `EndingVisibleSelection()` in `Apply()` for the `IsRichlyEditablePosition` check. | ||
|
|
||
| 3. **Visual-caret equivalence checks.** When the question is "did the caret actually move?", VP equivalence is the right tool - some positions are structurally distinct but render to the same caret. `MostForwardCaretPosition(x) == MostForwardCaretPosition(y)` is often a layout-free replacement, but each site requires individual confirmation. | ||
|
|
||
| 4. **`VisiblePosition::FirstPositionInNode` / `LastPositionInNode` as equality tests.** Replaceable with `Position::` variants only after per-site confirmation that positions are canonical. | ||
|
|
||
| --- | ||
|
|
||
| ## Testing Strategy | ||
|
|
||
| **Per-CL:** | ||
| - `blink_unittests --gtest_filter="*Edit*"` - all pass | ||
| - `third_party/blink/web_tests/editing/` - no new failures | ||
| - `third_party/blink/web_tests/editing/contenteditable/` - no new failures | ||
|
|
||
| **Per-step gate:** | ||
| - Full `blink_unittests` suite | ||
| - `content_browsertests` editing subset | ||
| - Verify `visible_position.h` include count in `commands/` is decreasing | ||
|
|
||
| **Manual smoke test:** Basic typing, delete/backspace, paste, undo/redo, list creation, indent/outdent, Enter in list items, table editing. | ||
|
|
||
| **Regression monitoring:** Watch CQ for editing test failures for 2 weeks after each step lands. | ||
|
|
||
| --- | ||
|
|
||
| ## Next Steps: Beyond Commands | ||
|
|
||
| - `core/editing/selection_adjuster.cc` - 28 VP refs, primarily P4/P5 | ||
| - `core/editing/editing_utilities.cc` - ~12 VP refs in internal helpers | ||
| - `core/editing/serializers/styled_markup_serializer.cc` - 7 VP refs, P4/P5 | ||
| - `core/editing/editor.cc` - ~5 VP refs in internal helpers | ||
|
|
||
| Out of scope until Step 4 lands. | ||
|
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.