-
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 all 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,327 @@ | ||
| # Reduce VisiblePosition usage in Editing Commands | ||
|
|
||
| This document proposes a phased plan to reduce `VisiblePosition` usage inside Blink's editing command bodies. | ||
|
|
||
| --- | ||
|
|
||
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. VisiblePosition 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. | ||
|
|
||
| --- | ||
|
|
||
| ## Problem Statement | ||
|
|
||
| ### User-visible symptoms | ||
| - **Wrong caret/selection bugs** - stale VisiblePositions are silently used after DOM mutations (`IsValid()` is `DCHECK`-only in release). Caret lands in wrong position after paste, list insertion, or indent. | ||
| - **Loss of developer intent** - raw positions get silently re-canonicalized into different positions than the caller intended, taking away flexibility from developers. | ||
| - **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; VisiblePosition construction multiplies layout cost by paragraph count. | ||
| - **Blocks future work** - every VisiblePosition inside `DoApply()` is an implicit main-thread layout dependency, blocking batched undo and speculative editing. | ||
|
|
||
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 VisiblePosition after mutation is the root cause. | ||
|
|
||
| ### Scale | ||
|
|
||
| | Metric | Count | | ||
shwetabin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| |--------|-------| | ||
| | `VisiblePosition` type usage | ~370 across 16 files | | ||
| | `CreateVisiblePosition()` calls | ~124 across 15 files | | ||
| | `.DeepEquivalent()` calls (VisiblePosition unwrapping) | ~271 across 16 files | | ||
|
|
||
| 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 124 `CreateVisiblePosition` calls (~89%) are unnecessary type adapters. Only ~14 sites are genuinely load-bearing. | ||
|
|
||
| > Unit test files (`*_test.cc`) are not included in any of the counts above. | ||
|
|
||
| ### 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
|
||
|
|
||
| --- | ||
|
|
||
| ## Design Principles | ||
|
|
||
| ### 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 VisiblePositions deep inside `DoApply()`. | ||
|
|
||
| --- | ||
|
|
||
| ## Anti-Patterns to Eliminate | ||
|
|
||
| Patterns are ordered by priority: highest VisiblePosition count reduction first, then safety risk. | ||
|
|
||
| ### P1: VisiblePosition 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 VisiblePositions before calling. Fixing the three method | ||
| signatures (Step 3) removes ~80 VisiblePositions 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 | | ||
| | VisiblePosition 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: VisiblePosition as a type requirement for navigation tests | ||
|
|
||
| `IsEndOfParagraph` / `IsStartOfParagraph` have no `Position` overload today, so VisiblePosition | ||
| creation is forced purely to satisfy the type signature. High frequency across all | ||
| command files; mechanical to fix once Step 1 overloads exist. | ||
|
|
||
| ### P4: Create-then-extract | ||
|
|
||
| A VisiblePosition is created from a `PositionWithAffinity` or raw `Position` and immediately | ||
| passed to a navigation function. The VisiblePosition serves only as a type adapter - the position | ||
| was already correct before the wrap. Fixed mechanically alongside P3 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 VisiblePosition 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 VisiblePosition-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 VisiblePosition overloads by calling | ||
| `PreviousVisuallyDistinctCandidate` / `NextVisuallyDistinctCandidate` directly. Do | ||
| not change existing VisiblePosition 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` | ||
|
|
||
| These already take `Position` or `PositionWithAffinity` and return the same. They are | ||
| moved in CL 1-A and removed from `visible_units.h`. | ||
|
|
||
| **Word:** `StartOfWordPosition`, `EndOfWordPosition`, `MiddleOfWordPosition`, | ||
| `PreviousWordPosition`, `NextWordPosition`. | ||
|
|
||
| **Sentence:** `StartOfSentencePosition`, `EndOfSentence(const Position&)`, | ||
| `PreviousSentencePosition`, `NextSentencePosition`, `ExpandEndToSentenceBoundary`, | ||
| `ExpandRangeToSentenceBoundary`. | ||
|
|
||
| **Line:** `StartOfLine(PositionWithAffinity)`, `EndOfLine(PositionWithAffinity)`, | ||
| `InSameLine(PositionWithAffinity, PositionWithAffinity)`. | ||
|
|
||
| **Document:** `StartOfDocument(Position)`. | ||
|
|
||
| Note: `EndOfSentence(const VisiblePosition&)` stays in `visible_units.h` - it is a | ||
| VisiblePosition overload, not a `Position`-based one. | ||
|
|
||
| ### `PositionWithAffinity` ownership rule | ||
|
|
||
| A function belongs in `position_units.h` if it answers a "where is the boundary of | ||
| unit X?" question using DOM structure, regardless of whether it takes or returns | ||
| `PositionWithAffinity`. `PositionWithAffinity` is just a `Position` with an affinity | ||
| tag - it is not a layout type. | ||
|
|
||
| A function stays in `visible_units.h` if it is a layout or hit-test utility that | ||
| happens to use `PositionWithAffinity` as a carrier type (e.g. | ||
| `AdjustForwardPositionToAvoidCrossingEditingBoundaries`, | ||
| `PositionForContentsPointRespectingEditingBoundary`). These are tied to layout | ||
| machinery, not navigation units. | ||
|
|
||
| ### 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 VisiblePosition overload and the new `Position` overload call into it. | ||
|
|
||
| --- | ||
|
|
||
| ## Step 2: Migrate Simple Commands | ||
|
|
||
| Migrate the 5 lowest-VisiblePosition command files. These validate that the new overloads are | ||
| drop-in replacements against the WPT editing test suite. | ||
|
|
||
| | File | VisiblePosition refs | Notes | | ||
| |------|---------|-------| | ||
| | `style_commands.cc` | 1 | | | ||
| | `undo_step.cc` | 2 | | | ||
| | `editor_command.cc` | 6 | | | ||
| | `insert_line_break_command.cc` | 6 | P3 and P4 examples | | ||
| | `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)` | | ||
| | `VisiblePosition::FirstPositionInNode(*node).DeepEquivalent()` | `Position::FirstPositionInNode(*node)` | | ||
| | `VisiblePosition::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): VisiblePosition created solely to pass to `MoveParagraph` / `MoveParagraphWithClones` / `CleanupAfterDeletion`. | ||
| - **Category B** (fixed by Step 1): VisiblePosition created solely to call `IsEndOfParagraph`, `IsStartOfParagraph`, `StartOfParagraph`, `EndOfParagraph`, `StartOfBlock`, etc. | ||
|
|
||
| | File | VisiblePosition 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 VisiblePosition in `RebalanceWhitespaceOnTextSubstring`, | ||
| `MoveParagraphContentsToNewBlockIfNecessary`, `BreakOutOfEmptyListItem`, | ||
| `BreakOutOfEmptyMailBlockquotedParagraph` are addressed in Step 4. | ||
|
|
||
| ### Estimated VisiblePosition reduction | ||
|
|
||
| | Work item | Estimated VisiblePositions 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 VisiblePosition (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?", VisiblePosition 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 VisiblePosition refs, primarily P3/P4 | ||
| - `core/editing/editing_utilities.cc` - ~12 VisiblePosition refs in internal helpers | ||
| - `core/editing/serializers/styled_markup_serializer.cc` - 7 VisiblePosition refs, P3/P4 | ||
| - `core/editing/editor.cc` - ~5 VisiblePosition 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.