LibWeb: Do not scroll cursor into view on programmatic selection changes

We were mimicking Firefox' behavior that whenever a programmatic change
to an <input>'s or <textarea>'s selection happened, the new selection
focus is brought into view by scrolling. Currently we run a layout
update synchronously for that to make sure we have the fragment's
correct dimensions, which caused a significant performance regression in
Speedometer.

Since this is non-standard behavior, let's mimic Chromium instead which
does not scroll at all - only for direct user initiated input such as
typing.

Relevant issues:

* https://github.com/whatwg/html/issues/6217
* https://bugzilla.mozilla.org/show_bug.cgi?id=232405
* https://issues.chromium.org/issues/41081857
This commit is contained in:
Jelle Raaijmakers 2026-02-16 13:51:24 +01:00 committed by Andreas Kling
parent fa04c8db83
commit ded42e649b
14 changed files with 124 additions and 69 deletions

View file

@ -121,8 +121,8 @@ enum class InvalidateLayoutTreeReason {
X(RangeGetClientRects) \
X(ResolvedCSSStyleDeclarationProperty) \
X(SVGDecodedImageDataRender) \
X(ScrollCursorIntoView) \
X(SVGGraphicsElementGetBBox) \
X(ScrollFocusIntoView) \
X(SourceSetNormalizeSourceDensities) \
X(WindowScroll)

View file

@ -106,9 +106,10 @@ void EditingHostManager::move_cursor_to_start(CollapseSelection collapse)
if (collapse == CollapseSelection::Yes) {
MUST(selection->collapse(node, 0));
m_document->reset_cursor_blink_cycle();
return;
} else {
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, 0));
}
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, 0));
selection->scroll_focus_into_view();
}
void EditingHostManager::move_cursor_to_end(CollapseSelection collapse)
@ -121,9 +122,10 @@ void EditingHostManager::move_cursor_to_end(CollapseSelection collapse)
if (collapse == CollapseSelection::Yes) {
m_document->reset_cursor_blink_cycle();
MUST(selection->collapse(node, node->length()));
return;
} else {
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, node->length()));
}
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, node->length()));
selection->scroll_focus_into_view();
}
void EditingHostManager::increment_cursor_position_offset(CollapseSelection collapse)

View file

@ -132,6 +132,10 @@ WebIDL::ExceptionOr<bool> Document::exec_command(FlyString const& command, [[may
affected_editing_host->dispatch_event(event);
}
// AD-HOC: Scroll the cursor into view after executing a command.
if (auto selection = get_selection())
selection->scroll_focus_into_view();
// 8. Return true.
return true;
}

View file

@ -841,7 +841,7 @@ void FormAssociatedTextControlElement::set_the_selection_range(Optional<WebIDL::
});
}
selection_was_changed();
selection_was_changed(source);
}
}
@ -872,6 +872,7 @@ void FormAssociatedTextControlElement::handle_insert(FlyString const& input_type
data_for_input_event = data_for_insertion;
did_edit_text_node(input_type, data_for_input_event);
scroll_cursor_into_view();
}
void FormAssociatedTextControlElement::handle_delete(FlyString const& input_type)
@ -897,6 +898,7 @@ void FormAssociatedTextControlElement::handle_delete(FlyString const& input_type
text_node->invalidate_style(DOM::StyleInvalidationReason::EditingDeletion);
did_edit_text_node(input_type, {});
scroll_cursor_into_view();
}
Optional<Utf16String> FormAssociatedTextControlElement::selected_text_for_stringifier() const
@ -920,7 +922,7 @@ void FormAssociatedTextControlElement::collapse_selection_to_offset(size_t posit
void FormAssociatedTextControlElement::scroll_cursor_into_view()
{
auto& element = form_associated_element_to_html_element();
element.document().update_layout(DOM::UpdateLayoutReason::ScrollFocusIntoView);
element.document().update_layout(DOM::UpdateLayoutReason::ScrollCursorIntoView);
auto text_node = form_associated_element_to_text_node();
if (!text_node)
@ -933,7 +935,7 @@ void FormAssociatedTextControlElement::scroll_cursor_into_view()
paintable->scroll_ancestor_to_offset_into_view(m_selection_end);
}
void FormAssociatedTextControlElement::selection_was_changed()
void FormAssociatedTextControlElement::selection_was_changed(SelectionSource source)
{
auto& element = form_associated_element_to_html_element();
if (auto* input_element = as_if<HTMLInputElement>(element)) {
@ -959,10 +961,14 @@ void FormAssociatedTextControlElement::selection_was_changed()
}
text_paintable->set_needs_display();
// AD-HOC: Skip scroll-into-view during mouse selection, since the user controls the viewport.
auto navigable = element.document().cached_navigable();
if (!(navigable && navigable->event_handler().is_handling_mouse_selection()))
scroll_cursor_into_view();
// AD-HOC: Only scroll the cursor into view for UI-driven selection changes (like keyboard input). Programmatic
// changes (input.value, setSelectionRange) do not cause the cursor to scroll into view. This matches the
// behavior of other browsers.
if (source == SelectionSource::UI) {
auto navigable = element.document().navigable();
if (navigable && !navigable->event_handler().is_handling_mouse_selection())
scroll_cursor_into_view();
}
}
void FormAssociatedTextControlElement::select_all()
@ -971,7 +977,7 @@ void FormAssociatedTextControlElement::select_all()
if (!text_node)
return;
set_the_selection_range(0, text_node->length());
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::set_selection_anchor(GC::Ref<DOM::Node> anchor_node, size_t anchor_offset)
@ -982,7 +988,7 @@ void FormAssociatedTextControlElement::set_selection_anchor(GC::Ref<DOM::Node> a
if (!text_node || anchor_node != text_node)
return;
collapse_selection_to_offset(anchor_offset);
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::set_selection_focus(GC::Ref<DOM::Node> focus_node, size_t focus_offset)
@ -993,7 +999,7 @@ void FormAssociatedTextControlElement::set_selection_focus(GC::Ref<DOM::Node> fo
if (!text_node || focus_node != text_node)
return;
m_selection_end = focus_offset;
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::move_cursor_to_start(CollapseSelection collapse)
@ -1006,7 +1012,7 @@ void FormAssociatedTextControlElement::move_cursor_to_start(CollapseSelection co
} else {
m_selection_end = 0;
}
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::move_cursor_to_end(CollapseSelection collapse)
@ -1019,7 +1025,7 @@ void FormAssociatedTextControlElement::move_cursor_to_end(CollapseSelection coll
} else {
m_selection_end = text_node->length();
}
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::increment_cursor_position_offset(CollapseSelection collapse)
@ -1034,7 +1040,7 @@ void FormAssociatedTextControlElement::increment_cursor_position_offset(Collapse
m_selection_end = *offset;
}
}
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::decrement_cursor_position_offset(CollapseSelection collapse)
@ -1049,7 +1055,7 @@ void FormAssociatedTextControlElement::decrement_cursor_position_offset(Collapse
m_selection_end = *offset;
}
}
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::increment_cursor_position_to_next_word(CollapseSelection collapse)
@ -1072,7 +1078,7 @@ void FormAssociatedTextControlElement::increment_cursor_position_to_next_word(Co
break;
}
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_word(CollapseSelection collapse)
@ -1095,7 +1101,7 @@ void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_wor
break;
}
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::increment_cursor_position_to_next_line(CollapseSelection collapse)
@ -1113,7 +1119,7 @@ void FormAssociatedTextControlElement::increment_cursor_position_to_next_line(Co
else
m_selection_end = *new_offset;
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_line(CollapseSelection collapse)
@ -1131,7 +1137,7 @@ void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_lin
else
m_selection_end = *new_offset;
selection_was_changed();
selection_was_changed(SelectionSource::UI);
}
GC::Ptr<DOM::Position> FormAssociatedTextControlElement::cursor_position() const

View file

@ -269,7 +269,7 @@ private:
void collapse_selection_to_offset(size_t);
void scroll_cursor_into_view();
void selection_was_changed();
void selection_was_changed(SelectionSource);
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-textarea/input-selection
WebIDL::UnsignedLong m_selection_start { 0 };

View file

@ -15,8 +15,6 @@
#include <LibWeb/DOM/Text.h>
#include <LibWeb/GraphemeEdgeTracker.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/Page/EventHandler.h>
#include <LibWeb/Painting/Paintable.h>
#include <LibWeb/Selection/Selection.h>
@ -579,12 +577,6 @@ void Selection::set_range(GC::Ptr<DOM::Range> range)
HTML::run_focusing_steps(new_editing_host, nullptr, HTML::FocusTrigger::Other);
}
}
// AD-HOC: Scroll the focus position into view within the nearest scrollable ancestor.
// Skip this during mouse selection, since the user controls the viewport.
auto navigable = m_document->cached_navigable();
if (range && !(navigable && navigable->event_handler().is_handling_mouse_selection()))
scroll_focus_into_view();
}
GC::Ptr<DOM::Position> Selection::cursor_position() const
@ -612,6 +604,7 @@ void Selection::move_offset_to_next_character(bool collapse_selection)
} else {
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *offset));
}
scroll_focus_into_view();
}
}
@ -628,6 +621,7 @@ void Selection::move_offset_to_previous_character(bool collapse_selection)
} else {
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *offset));
}
scroll_focus_into_view();
}
}
@ -640,7 +634,7 @@ void Selection::move_offset_to_next_word(bool collapse_selection)
while (true) {
auto focus_offset = this->focus_offset();
if (focus_offset == text_node->data().length_in_code_units())
return;
break;
if (auto offset = text_node->word_segmenter().next_boundary(focus_offset); offset.has_value()) {
auto word = text_node->data().substring_view(focus_offset, *offset - focus_offset);
@ -655,6 +649,7 @@ void Selection::move_offset_to_next_word(bool collapse_selection)
}
break;
}
scroll_focus_into_view();
}
void Selection::move_offset_to_previous_word(bool collapse_selection)
@ -678,6 +673,7 @@ void Selection::move_offset_to_previous_word(bool collapse_selection)
}
break;
}
scroll_focus_into_view();
}
void Selection::move_offset_to_next_line(bool collapse_selection)
@ -696,6 +692,7 @@ void Selection::move_offset_to_next_line(bool collapse_selection)
} else {
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *new_offset));
}
scroll_focus_into_view();
}
void Selection::move_offset_to_previous_line(bool collapse_selection)
@ -714,6 +711,7 @@ void Selection::move_offset_to_previous_line(bool collapse_selection)
} else {
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *new_offset));
}
scroll_focus_into_view();
}
void Selection::scroll_focus_into_view()
@ -722,7 +720,7 @@ void Selection::scroll_focus_into_view()
if (!focus)
return;
m_document->update_layout(DOM::UpdateLayoutReason::ScrollFocusIntoView);
m_document->update_layout(DOM::UpdateLayoutReason::ScrollCursorIntoView);
auto* paintable = focus->paintable();
if (!paintable)

View file

@ -65,6 +65,7 @@ public:
GC::Ptr<DOM::Position> cursor_position() const;
// Non-standard
void scroll_focus_into_view();
void move_offset_to_next_character(bool collapse_selection);
void move_offset_to_previous_character(bool collapse_selection);
void move_offset_to_next_word(bool collapse_selection);
@ -81,7 +82,6 @@ private:
virtual void visit_edges(Cell::Visitor&) override;
void set_range(GC::Ptr<DOM::Range>);
void scroll_focus_into_view();
// https://w3c.github.io/selection-api/#dfn-empty
GC::Ptr<DOM::Range> m_range;

View file

@ -0,0 +1 @@
PASS

View file

@ -0,0 +1,2 @@
textarea scrollLeft: 249.28125
div scrollLeft: 251.28125

View file

@ -1,2 +0,0 @@
vertical scrollTop: 80
horizontal scrollLeft: 603.171875

View file

@ -22,9 +22,8 @@
internals.click(x, y);
println(`before: ${input.selectionStart}`);
// Move cursor to the end, which should scroll the input.
input.setSelectionRange(39, 39);
input.offsetWidth;
// Move cursor to the end via keyboard, which should scroll the input.
internals.sendKey(input, "End");
// Click in the same spot after scrolling.
internals.click(x, y);

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<style>
.row {
display: flex;
align-items: center;
padding: 4px;
}
</style>
<input id="inp" />
<div id="app"></div>
<script src="include.js"></script>
<script>
test(() => {
const input = document.querySelector("#inp");
const app = document.querySelector("#app");
for (let i = 0; i < 2000; i++) {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `<input type="checkbox"><span>${i}</span><button>x</button>`;
app.appendChild(row);
}
input.focus();
input.offsetWidth;
// Performance test: setting input.value must not synchronously trigger a full layout.
const values = ["a", "ab"];
const start = performance.now();
for (let i = 0; i < 2000; i++) {
app.children[i % 2000].style.padding = (4 + (i % 2)) + "px";
input.value = values[i % 2];
}
const elapsed = performance.now() - start;
println(elapsed < 5000 ? "PASS" : `FAIL: took ${elapsed.toFixed(0)}ms`);
});
</script>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<style>
textarea {
font-size: 16px;
width: 100px;
height: 1.5em;
padding: 2px;
border: 1px solid;
resize: none;
white-space: nowrap;
}
div[contenteditable] {
font-size: 16px;
width: 100px;
height: 1.5em;
overflow: auto;
white-space: nowrap;
}
</style>
<textarea>foobarbazloremipsum foobarbazloremipsum</textarea>
<div contenteditable>foobarbazloremipsum foobarbazloremipsum</div>
<script src="include.js"></script>
<script>
test(() => {
const textarea = document.querySelector("textarea");
const div = document.querySelector("div[contenteditable]");
textarea.offsetWidth;
textarea.focus();
internals.sendKey(textarea, "End");
println(`textarea scrollLeft: ${textarea.scrollLeft}`);
div.focus();
internals.sendKey(div, "End");
println(`div scrollLeft: ${div.scrollLeft}`);
});
</script>

View file

@ -1,29 +0,0 @@
<!DOCTYPE html>
<style>
div {
overflow: auto;
width: 200px;
height: 100px;
font-size: 16px;
}
</style>
<div id="vertical">line1<br>line2<br>line3<br>line4<br>line5<br>line6<br>line7<br>line8<br>line9<br>line10</div>
<div id="horizontal" style="white-space: nowrap">lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore</div>
<script src="include.js"></script>
<script>
test(() => {
document.body.offsetWidth; // force layout
const sel = window.getSelection();
// Vertical: set selection focus to end of content.
const lastTextV = vertical.lastChild;
sel.setBaseAndExtent(lastTextV, 0, lastTextV, lastTextV.length);
println(`vertical scrollTop: ${vertical.scrollTop}`);
// Horizontal: set selection focus to end of content.
const textH = horizontal.firstChild;
sel.setBaseAndExtent(textH, 0, textH, textH.length);
println(`horizontal scrollLeft: ${horizontal.scrollLeft}`);
});
</script>