diff --git a/moli-layout/src/overflow.rs b/moli-layout/src/overflow.rs index 9ed1897b95..1a3c6d6a25 100644 --- a/moli-layout/src/overflow.rs +++ b/moli-layout/src/overflow.rs @@ -433,7 +433,7 @@ where } else if world.is_viewport_defining_body(id) { false } else { - world.boxes[id.index()].style.establishes_scroll_container() + world.boxes[id.index()].establishes_scroll_container() } } diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index ded04acbe5..8c190fa46c 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -326,9 +326,7 @@ where } else if self.world.is_viewport_defining_body(id) { false } else { - self.world.boxes[id.index()] - .style - .establishes_scroll_container() + self.world.boxes[id.index()].establishes_scroll_container() } } diff --git a/moli-layout/src/source.rs b/moli-layout/src/source.rs index 53f24adb47..ee154996a9 100644 --- a/moli-layout/src/source.rs +++ b/moli-layout/src/source.rs @@ -202,6 +202,13 @@ impl LayoutInputControlKind { Self::Week => "form-input-week", } } + + pub(crate) const fn has_single_line_text_editor(self) -> bool { + matches!( + self, + Self::Email | Self::Password | Self::Search | Self::Telephone | Self::Text | Self::Url + ) + } } /// Replaced-content family. Pixel resources are deliberately not part of this type. @@ -355,6 +362,14 @@ impl LayoutElementSemantics { ) } + pub(crate) const fn has_internal_text_control_scroller(&self) -> bool { + matches!( + self.category, + LayoutElementCategory::FormControl(LayoutFormControlKind::Input(kind)) + if kind.has_single_line_text_editor() + ) + } + /// Whether `display: contents` has a used value of `display: none`. /// /// This is the unusual-elements list from CSS Display's HTML appendix: diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 971a32a02a..64a580d219 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -803,8 +803,7 @@ where let mut ancestor = world.boxes[id.index()].parent; while let Some(candidate) = ancestor { let layout_box = &world.boxes[candidate.index()]; - if !world.is_viewport_defining_body(candidate) - && layout_box.style.establishes_scroll_container() + if !world.is_viewport_defining_body(candidate) && layout_box.establishes_scroll_container() { let layout = layout_box.unrounded_layout; let origin = unrounded_global_origin(world, candidate); diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index 24a37f24a6..5cdcd988d0 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -572,6 +572,14 @@ impl LayoutBox { .is_some_and(LayoutElementSemantics::is_replaced) } + pub(crate) fn establishes_scroll_container(&self) -> bool { + self.style.establishes_scroll_container() + || self + .element_semantics + .as_ref() + .is_some_and(LayoutElementSemantics::has_internal_text_control_scroller) + } + /// Resolve the used ratio at the layout-node boundary, after both authored /// style and natural replaced-element sizing are available. pub(crate) fn resolved_aspect_ratio(&self) -> Option { diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs b/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs index b72e1545c0..727708defd 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs @@ -62,25 +62,38 @@ fn node_scroll_position_setter_for_object<'s>( } let runtime = unsafe { &mut *runtime_ptr }; let (minimum, maximum) = if runtime.layout_policy().uses_real_layout() { - observable_element_metrics( + let mut metrics = observable_element_metrics( runtime, handle, moli_layout::LayoutFlushReason::SynchronousGeometry, - )? - .map(|metrics| { - if horizontal { - ( - f64::from(metrics.minimum_scroll_offset.x), - f64::from(metrics.maximum_scroll_offset.x), - ) - } else { - ( - f64::from(metrics.minimum_scroll_offset.y), - f64::from(metrics.maximum_scroll_offset.y), - ) - } - }) - .unwrap_or((0.0, 0.0)) + )?; + // Ordinary geometry getters intentionally share the latest frozen + // tree across same-task DOM mutations. A newly connected receiver can + // therefore be absent from that tree, but a scroll setter must clamp + // against its current range instead of treating it as non-scrollable. + if metrics.is_none() && runtime.dom_host().is_connected(handle) { + runtime.invalidate_layout_after_interaction_state_change(); + metrics = observable_element_metrics( + runtime, + handle, + moli_layout::LayoutFlushReason::SynchronousGeometry, + )?; + } + metrics + .map(|metrics| { + if horizontal { + ( + f64::from(metrics.minimum_scroll_offset.x), + f64::from(metrics.maximum_scroll_offset.x), + ) + } else { + ( + f64::from(metrics.minimum_scroll_offset.y), + f64::from(metrics.maximum_scroll_offset.y), + ) + } + }) + .unwrap_or((0.0, 0.0)) } else { // Mock intentionally preserves the old synthetic geometry behavior: // non-negative scroll values are stored even without real overflow. diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs index 9ce6559089..021465e111 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs @@ -1285,6 +1285,69 @@ fn classic_scrollbar_metrics_and_thumb_drag_match_chromium_without_dom_mouse_eve assert_eq!(result["events"], serde_json::json!([])); } +#[test] +fn single_line_text_input_preserves_programmatic_scroll_across_select() { + let mut vm = new_storage_test_vm("https://text-input-scroll.test/"); + vm.eval( + r#" + (() => { + if (!document.documentElement) { + document.appendChild(document.createElement("html")); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement("body")); + } + const previous = document.createElement("input"); + previous.value = "0123456789".repeat(100); + document.body.append(previous); + globalThis.__previousScrollInput = previous; + return "installed"; + })() + "#, + ) + .expect("text input scroll fixture should initialize"); + refresh_layout_for_test(&mut vm); + vm.eval("__previousScrollInput.scrollLeft") + .expect("the previous input should retain the frozen layout"); + + vm.eval( + r#" + (() => { + __previousScrollInput.remove(); + const input = document.createElement("input"); + input.value = "0123456789".repeat(100); + document.body.append(input); + globalThis.__scrollInput = input; + return "replaced"; + })() + "#, + ) + .expect("the previous text input should be replaced"); + + vm.eval("__scrollInput.scrollLeft = 33") + .expect("text input should accept a programmatic scroll"); + refresh_layout_for_test(&mut vm); + assert_eq!( + vm.eval("__scrollInput.scrollWidth > __scrollInput.clientWidth") + .expect("text input overflow should be observable"), + "true" + ); + assert_eq!( + vm.eval("__scrollInput.scrollLeft") + .expect("text input scroll should remain observable"), + "33" + ); + + vm.eval("__scrollInput.select()") + .expect("selecting the input contents should succeed"); + refresh_layout_for_test(&mut vm); + assert_eq!( + vm.eval("__scrollInput.scrollLeft") + .expect("selection should preserve the text input scroll"), + "33" + ); +} + #[test] fn painted_overlay_wins_over_scrollbar_and_corner_consumes_input() { let mut vm = new_storage_test_vm("https://painted-scrollbar-surface.test/");