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
VisibleSelectionto 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.
- Typing lag - each keystroke triggers multiple full style+layout passes against a partially-mutated tree.
- Paste jank -
ReplaceSelectionCommandtriggers dozens of layout passes for a single Ctrl+V on complex content. - Slow indent/outdent -
IndentOutdentCommandloops per-paragraph; VP construction multiplies layout cost by paragraph count. - Wrong-caret bugs - stale VPs are silently used after DOM mutations (
IsValid()isDCHECK-only in release). Caret lands in wrong position after paste, list insertion, or indent. - Blocks future work - every VP inside
DoApply()is an implicit main-thread layout dependency, blocking batched undo, speculative editing, and off-thread IME.
- Hotlist 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/andcontenteditable/where stale VP after mutation is the root cause.
| Metric | Count |
|---|---|
VisiblePosition type usage |
~391 across 18 files |
CreateVisiblePosition() calls |
~126 across 16 files |
.DeepEquivalent() calls (VP unwrapping) |
~288 across 18 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 126 CreateVisiblePosition calls (~87%) are unnecessary type adapters. Only ~16 sites are genuinely load-bearing.
| Metric | Before | After |
|---|---|---|
CreateVisiblePosition calls in commands/ |
126 | ~16 |
VisiblePosition type references in commands/ |
~391 | ~66 |
Unnecessary UpdateStyleAndLayout per editing op |
multiple | 0 |
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().
Patterns are ordered by priority: highest VP count reduction first, then safety risk.
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.
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.
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.
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.
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.
- Add missing
Position-based overloads for navigation functions inposition_unitsandediting_commands_utilities(bottom-up, purely additive). - Change
CompositeEditCommandinfrastructure signatures to takePositioninstead ofVisiblePosition(top-down, uses the new overloads internally). - Migrate command files using the new overloads and the new infrastructure signatures, in ascending order of VP count.
Nature: Purely additive. No existing code is changed. Each overload is its own CL with tests. Zero regression risk.
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.
New Position overloads to add to position_units.h / position_units.cc:
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&
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.
StartOfWordPosition, EndOfWordPosition, StartOfLine(PositionWithAffinity),
EndOfLine(PositionWithAffinity), InSameLine(PositionWithAffinity, PositionWithAffinity),
StartOfDocument(Position).
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.
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 |
insert_text_command.cc |
7 |
| Before | After |
|---|---|
CreateVisiblePosition(pos).DeepEquivalent() |
pos - drop entirely; do not substitute CanonicalPositionOf |
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() |
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 eachMoveParagraphWithClones- two paramsCleanupAfterDeletion- one param with defaultReplaceCollapsibleWhitespaceWithNonBreakingSpaceIfNeeded- 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.
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.
| 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%) |
-
Entry boundary.
CompositeEditCommandconstructor callingComputeVisibleSelectionInDOMTreeDeprecated(). -
Exit boundary.
EndingVisibleSelection()inApply()for theIsRichlyEditablePositioncheck. -
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. -
VisiblePosition::FirstPositionInNode/LastPositionInNodeas equality tests. Replaceable withPosition::variants only after per-site confirmation that positions are canonical.
Per-CL:
blink_unittests --gtest_filter="*Edit*"- all passthird_party/blink/web_tests/editing/- no new failuresthird_party/blink/web_tests/editing/contenteditable/- no new failures
Per-step gate:
- Full
blink_unittestssuite content_browsertestsediting subset- Verify
visible_position.hinclude count incommands/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.
core/editing/selection_adjuster.cc- 28 VP refs, primarily P4/P5core/editing/editing_utilities.cc- ~12 VP refs in internal helperscore/editing/serializers/styled_markup_serializer.cc- 7 VP refs, P4/P5core/editing/editor.cc- ~5 VP refs in internal helpers
Out of scope until Step 4 lands.