From 9606fd8ed2836d14b959a7f61ba1d44455429570 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 31 Aug 2026 00:26:36 +0800 Subject: [PATCH] fix(forms): wrap textarea submission values --- moli-dom/src/forms/mod.rs | 8 +- moli-dom/src/forms/tests.rs | 23 ++++++ moli-dom/src/forms/text.rs | 41 ++++++++++- .../form_data_runtime/serialize.rs | 14 +++- .../instance_properties/element_form.rs | 10 ++- .../src/script_vm/tests/dom_xhr/forms.rs | 73 +++++++++++++++++++ 6 files changed, 159 insertions(+), 10 deletions(-) diff --git a/moli-dom/src/forms/mod.rs b/moli-dom/src/forms/mod.rs index 2c26ab096c..a5f460d9ec 100644 --- a/moli-dom/src/forms/mod.rs +++ b/moli-dom/src/forms/mod.rs @@ -39,10 +39,10 @@ pub use option::{ option_disabled_ancestor_step, }; pub use text::{ - normalize_custom_validation_message, normalize_form_submission_newlines, - parse_non_negative_integer_prefix, parse_non_negative_length_attribute, - parse_positive_integer_prefix, text_control_suffers_too_long, text_control_suffers_too_short, - text_control_value_length, + apply_textarea_wrapping_transformation, normalize_custom_validation_message, + normalize_form_submission_newlines, parse_non_negative_integer_prefix, + parse_non_negative_length_attribute, parse_positive_integer_prefix, + text_control_suffers_too_long, text_control_suffers_too_short, text_control_value_length, }; pub use validity::FormControlValidity; diff --git a/moli-dom/src/forms/tests.rs b/moli-dom/src/forms/tests.rs index b456a5e69f..9709645ad3 100644 --- a/moli-dom/src/forms/tests.rs +++ b/moli-dom/src/forms/tests.rs @@ -59,6 +59,8 @@ fn integer_prefix_parsers_follow_html_attribute_rules() { assert_eq!(parse_non_negative_integer_prefix("999999999999"), 0); assert_eq!(parse_positive_integer_prefix(" 12px"), Some(12)); + assert_eq!(parse_positive_integer_prefix(" +12px"), Some(12)); + assert_eq!(parse_positive_integer_prefix("\u{a0}12px"), None); assert_eq!(parse_positive_integer_prefix("0"), None); assert_eq!(parse_positive_integer_prefix("-1"), None); assert_eq!(parse_positive_integer_prefix("abc"), None); @@ -71,6 +73,27 @@ fn integer_prefix_parsers_follow_html_attribute_rules() { assert_eq!(parse_non_negative_length_attribute("2147483648"), None); } +#[test] +fn textarea_wrapping_transformation_respects_hard_state_and_character_width() { + let wrap = |value: &str, wrap, cols| { + apply_textarea_wrapping_transformation(value.to_owned(), wrap, cols) + }; + + assert_eq!(wrap("hello world", Some("soft"), Some("7")), "hello world"); + assert_eq!(wrap("hello world", Some("ſoft"), Some("7")), "hello world"); + assert_eq!(wrap("1234567", Some("hard"), Some("7")), "1234567"); + assert_eq!( + wrap("hello world", Some("HaRd"), Some("7")), + "hello w\norld" + ); + assert_eq!(wrap("ab\ncdef", Some("hard"), Some("3")), "ab\ncde\nf"); + assert_eq!(wrap("é🙂x", Some("hard"), Some("2")), "é🙂\nx"); + assert_eq!( + wrap("123456789012345678901", Some("hard"), None), + "12345678901234567890\n1" + ); +} + #[test] fn input_type_tokens_and_support_matrices_are_shared() { assert!(InputType::Week.supports_value_as_number()); diff --git a/moli-dom/src/forms/text.rs b/moli-dom/src/forms/text.rs index 6df6272f66..ee90573d18 100644 --- a/moli-dom/src/forms/text.rs +++ b/moli-dom/src/forms/text.rs @@ -34,7 +34,12 @@ pub fn parse_non_negative_integer_prefix(value: &str) -> i32 { } pub fn parse_positive_integer_prefix(value: &str) -> Option { - let digits = integer_prefix_digits(value); + let value = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()); + let value = value.strip_prefix('+').unwrap_or(value); + let digits = value + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); digits .parse::() .ok() @@ -42,6 +47,40 @@ pub fn parse_positive_integer_prefix(value: &str) -> Option { .map(|value| value as u32) } +pub fn apply_textarea_wrapping_transformation( + value: String, + wrap_attribute: Option<&str>, + cols_attribute: Option<&str>, +) -> String { + if !wrap_attribute.is_some_and(|wrap| wrap.eq_ignore_ascii_case("hard")) { + return value; + } + let character_width = cols_attribute + .and_then(parse_positive_integer_prefix) + .filter(|width| *width <= i32::MAX as u32) + .unwrap_or(20); + hard_wrap_textarea_value(&value, character_width) +} + +fn hard_wrap_textarea_value(value: &str, character_width: u32) -> String { + let mut output = String::with_capacity(value.len()); + let mut current_width = 0; + for character in value.chars() { + if character == '\n' { + output.push(character); + current_width = 0; + continue; + } + if current_width == character_width { + output.push('\n'); + current_width = 0; + } + output.push(character); + current_width += 1; + } + output +} + pub fn parse_non_negative_length_attribute(value: &str) -> Option { parse_html_integer_prefix(value) .filter(|value| *value >= 0) diff --git a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs index 994e76062b..86605b81ce 100644 --- a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs +++ b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs @@ -2,7 +2,10 @@ use super::storage::{form_data_entries, form_data_is_object, push_form_data_entr use super::*; use crate::custom_elements::is_form_associated_custom_element_handle; use crate::dom::{ - forms::{InputType, OptionDisabledAncestorStep, option_disabled_ancestor_step}, + forms::{ + InputType, OptionDisabledAncestorStep, apply_textarea_wrapping_transformation, + option_disabled_ancestor_step, + }, native::Node, }; use crate::native_bridge::{ @@ -399,9 +402,16 @@ fn native_form_control_value( let (runtime_ptr, handle) = node_runtime_and_handle_from_object(scope, control).ok()?; let runtime = unsafe { &*runtime_ptr }; let element = runtime.dom_host().node(handle).and_then(Node::as_element)?; - if element.is_html_input() || element.is_html_textarea() { + if element.is_html_input() { return Some(text_control_value(runtime, handle)); } + if element.is_html_textarea() { + return Some(apply_textarea_wrapping_transformation( + text_control_value(runtime, handle), + element.attribute_ns("", "wrap"), + element.attribute_ns("", "cols"), + )); + } if element.is_html_option() { return Some(element.option_value(runtime.dom_host().dom(), handle)); } diff --git a/moli-renderer-v8/src/native_bridge/document/detached_install/instance_properties/element_form.rs b/moli-renderer-v8/src/native_bridge/document/detached_install/instance_properties/element_form.rs index 82945efde7..b56c55813a 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_install/instance_properties/element_form.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_install/instance_properties/element_form.rs @@ -348,14 +348,18 @@ fn detached_control_submission_value<'s>( return crate::native_bridge::element::text_control_value(runtime, handle); } if element.is_html_textarea() { - let value = crate::native_bridge::element::text_control_value(runtime, handle); + let mut value = crate::native_bridge::element::text_control_value(runtime, handle); if !element.input_value_dirty() && value.is_empty() && let Some(attribute_value) = element.attribute_ns("", "value") { - return attribute_value.to_owned(); + value = attribute_value.to_owned(); } - return value; + return moli_dom::forms::apply_textarea_wrapping_transformation( + value, + element.attribute_ns("", "wrap"), + element.attribute_ns("", "cols"), + ); } if element.is_html_button() { return element diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index 3586a8e12d..0aa4f18a49 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -5593,6 +5593,79 @@ fn empty_get_form_submit_replaces_existing_action_query() { .expect("empty GET form submit should queue a pending location navigation"); assert_eq!(pending.url.as_str(), "https://form-empty-get.test/submit?"); } + +#[test] +fn textarea_hard_wrap_is_ascii_case_insensitive_in_form_entries() { + let mut vm = new_storage_test_vm("https://textarea-hard-wrap.test/path/index.html"); + + let form_data_values = vm + .eval( + r#" +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + const body = document.body || root.appendChild(document.createElement('body')); + const form = document.createElement('form'); + form.action = '/submit'; + const add = (name, wrap, cols, value) => { + const textarea = document.createElement('textarea'); + textarea.name = name; + textarea.setAttribute('wrap', wrap); + textarea.setAttribute('cols', cols); + textarea.value = value; + form.append(textarea); + return textarea; + }; + const lower = add('lower', 'hard', '7', 'hello world'); + add('mixed', 'HaRd', '7', 'hello world'); + add('soft', 'SoFt', '7', 'hello world'); + add('invalid', 'ſoft', '7', 'hello world'); + add('existing', 'HARD', '3', 'ab\ncdef'); + body.append(form); + const data = new FormData(form); + const visible = value => value.replaceAll('\n', ''); + const result = [ + lower.value.includes('\n'), + ...['lower', 'mixed', 'soft', 'invalid', 'existing'].map(name => visible(data.get(name))) + ].join('|'); + form.submit(); + return result; +})() +"#, + ) + .expect("textarea hard-wrap form submission should evaluate"); + + assert_eq!( + form_data_values, + "false|hello world|hello world|hello world|hello world|abcdef" + ); + + let pending = vm + .take_pending_location_navigation_with_seed() + .expect("textarea GET submission should queue a pending navigation"); + let values = pending + .url + .query_pairs() + .into_owned() + .collect::>(); + assert_eq!( + values.get("lower").map(String::as_str), + Some("hello w\r\norld") + ); + assert_eq!( + values.get("mixed").map(String::as_str), + Some("hello w\r\norld") + ); + assert_eq!(values.get("soft").map(String::as_str), Some("hello world")); + assert_eq!( + values.get("invalid").map(String::as_str), + Some("hello world") + ); + assert_eq!( + values.get("existing").map(String::as_str), + Some("ab\r\ncde\r\nf") + ); +} + #[test] fn get_form_submit_dispatches_cancelable_navigate_event_with_source_element() { let mut vm = new_storage_test_vm("https://form-get-navigate.test/path/index.html");