diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 00aece7268..fc3857967e 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2892,7 +2892,6 @@ html/dom/elements/global-attributes/dir-slots-directionality.html html/dom/elements/global-attributes/document-dir.html html/dom/elements/the-innertext-and-outertext-properties/dynamic-getter.html html/dom/elements/the-innertext-and-outertext-properties/getter.html -html/dom/elements/the-innertext-and-outertext-properties/innertext-setter.html html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-spaces.html?white-space=normal html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-spaces.html?white-space=normal&contenteditable=plaintext-only html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-spaces.html?white-space=normal&contenteditable=true @@ -2905,7 +2904,6 @@ html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-sp html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-spaces.html?white-space=pre-wrap html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-spaces.html?white-space=pre-wrap&contenteditable=plaintext-only html/dom/elements/the-innertext-and-outertext-properties/innertext-with-white-spaces.html?white-space=pre-wrap&contenteditable=true -html/dom/elements/the-innertext-and-outertext-properties/outertext-setter.html html/dom/partial-updates/tentative/processing-instruction-attributes.html html/dom/partial-updates/tentative/stream-append-has-invalidation.html html/dom/partial-updates/tentative/stream-append-positional-pseudo-class-invalidation.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 3976a2be71..9fb0930832 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -5902,7 +5902,9 @@ html/dom/elements/global-attributes/translate-non-html-translation-mode.html html/dom/elements/name-content-attribute-and-property.html html/dom/elements/the-innertext-and-outertext-properties/getter-first-letter-marker-multicol.html html/dom/elements/the-innertext-and-outertext-properties/innertext-domnoderemoved-crash.html +html/dom/elements/the-innertext-and-outertext-properties/innertext-setter.html html/dom/elements/the-innertext-and-outertext-properties/innertext-whitespace-pre-line.html +html/dom/elements/the-innertext-and-outertext-properties/outertext-setter.html html/dom/historical.html html/dom/partial-updates/tentative/template-for-innerHTML-outside.html html/dom/partial-updates/tentative/template-for-invalid.html diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands/tree/replacement.rs b/moli-renderer-v8/src/document_runtime/mutation_commands/tree/replacement.rs index 68e89ca31c..6185d92da8 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands/tree/replacement.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands/tree/replacement.rs @@ -18,6 +18,43 @@ struct TreeReplacementPlan<'a> { } impl DocumentRuntime { + /// Replace every child of `parent` with the children of `fragment` while + /// preserving the single DOM "replace all" mutation record. + /// + /// `existing_children` must be captured before constructing `fragment`: + /// ParentNode.replaceChildren() is allowed to move an existing child into + /// that fragment before the final splice. + pub(crate) fn replace_all_children_with_fragment_appending_to_current_reaction_queue( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + host_ptr: *mut JsContextHost, + parent: DomHandle, + fragment: DomHandle, + existing_children: &[DomHandle], + ) -> bool { + let added_children = self.dom_host.child_handles(fragment).collect::>(); + let records_enabled = self.dom_host.mutation_records_enabled(); + let removes_existing_children = !existing_children.is_empty(); + for child in existing_children { + let _ = self + .remove_child_appending_to_current_reaction_queue(scope, host_ptr, parent, *child); + } + let changed = self + .append_child_appending_to_current_reaction_queue(scope, host_ptr, parent, fragment) + || removes_existing_children; + if changed && records_enabled { + crate::observer_runtime::coalesce_child_list_replacement_records( + host_ptr, + parent, + &added_children, + existing_children, + None, + None, + ); + } + changed + } + pub(crate) fn replace_child_appending_to_current_reaction_queue( &mut self, scope: &mut v8::PinScope<'_, '_>, diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 4d95390e01..0e19266de1 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -170,8 +170,8 @@ pub(super) use content::{ node_inner_html_setter_function, node_inner_text_getter_function, node_inner_text_setter_function, node_outer_html_getter_function, node_outer_html_setter_function, node_outer_text_getter_function, - node_outer_text_setter_function, node_set_html_unsafe_callback, title_text_getter_function, - title_text_setter_function, + node_outer_text_setter_function, node_set_html_unsafe_callback, + set_inner_text_in_reaction_scope, title_text_getter_function, title_text_setter_function, }; pub(super) use dataset::{build_dom_string_map_wrapper_template, node_dataset_getter_function}; use details_dialog::{ @@ -2951,7 +2951,11 @@ fn script_source_setter_function<'s>( let _ = unsafe { &mut *runtime_ptr } .dom_host_mut() .set_script_text_internal_slot(handle, &text); - let _ = set_text_content_in_reaction_scope(scope, runtime_ptr, handle, &text); + if sink == TrustedScriptElementSink::InnerText { + let _ = set_inner_text_in_reaction_scope(scope, runtime_ptr, handle, &text); + } else { + let _ = set_text_content_in_reaction_scope(scope, runtime_ptr, handle, &text); + } rv.set_undefined(); } diff --git a/moli-renderer-v8/src/native_bridge/element/content.rs b/moli-renderer-v8/src/native_bridge/element/content.rs index 15fceba827..873aeb981d 100644 --- a/moli-renderer-v8/src/native_bridge/element/content.rs +++ b/moli-renderer-v8/src/native_bridge/element/content.rs @@ -1,9 +1,9 @@ use std::collections::HashSet; use crate::{ - custom_elements, + context_bootstrap, custom_elements, document_runtime::DomHandle, - dom::native::{Element, Node}, + dom::native::{Element, Node, NodeType}, style_engine::{ComputedDisplayKind, ComputedTextWrapModeKind, ComputedWhiteSpaceCollapseKind}, util::v8_string, }; @@ -1115,6 +1115,122 @@ pub(in crate::native_bridge) fn node_inner_text_getter_function<'s>( rv.set(value.into()); } +fn rendered_text_setter_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, + member: &'static str, +) -> Option { + let options = crate::webidl::StringOptions { + treat_null_as_empty_string: true, + }; + match crate::webidl::convert_with_options::( + scope, + value, + crate::webidl::Context::member("HTMLElement", member), + &options, + ) { + Ok(value) => Some(value.0), + Err(error) => { + crate::webidl::throw_error(scope, &error); + None + } + } +} + +fn append_rendered_text_node( + runtime: &mut JsContextHost, + document: DomHandle, + fragment: DomHandle, + value: &str, +) -> bool { + let text = runtime.create_text_node_for_document(document, value); + runtime + .dom_host_mut() + .append_child_without_mutation_effects(fragment, text) +} + +fn append_rendered_break_node( + runtime: &mut JsContextHost, + document: DomHandle, + fragment: DomHandle, +) -> bool { + let line_break = runtime.create_element("br"); + if runtime.dom_host().owner_document_handle(line_break) != Some(document) + && runtime + .initialize_new_native_node_owner_document(document, line_break) + .is_none() + { + return false; + } + runtime + .dom_host_mut() + .append_child_without_mutation_effects(fragment, line_break) +} + +fn rendered_text_fragment( + runtime: &mut JsContextHost, + document: DomHandle, + value: &str, +) -> Option { + let fragment = runtime.create_document_fragment_for_document(document); + let mut text_start = 0; + let mut characters = value.char_indices().peekable(); + while let Some((position, character)) = characters.next() { + if !matches!(character, '\r' | '\n') { + continue; + } + if text_start < position + && !append_rendered_text_node(runtime, document, fragment, &value[text_start..position]) + { + return None; + } + if character == '\r' + && characters + .peek() + .is_some_and(|(_, character)| *character == '\n') + { + let (line_feed_position, _) = characters.next()?; + text_start = line_feed_position + '\n'.len_utf8(); + } else { + text_start = position + character.len_utf8(); + } + if !append_rendered_break_node(runtime, document, fragment) { + return None; + } + } + if text_start < value.len() + && !append_rendered_text_node(runtime, document, fragment, &value[text_start..]) + { + return None; + } + Some(fragment) +} + +pub(in crate::native_bridge) fn set_inner_text_in_reaction_scope( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, + value: &str, +) -> bool { + custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { + let runtime = unsafe { &mut *runtime_ptr }; + let Some(document) = runtime.dom_host().owner_document_handle(handle) else { + return false; + }; + let existing_children = runtime.dom_host().child_handles(handle).collect::>(); + let Some(fragment) = rendered_text_fragment(runtime, document, value) else { + return false; + }; + runtime.replace_all_children_with_fragment_appending_to_current_reaction_queue( + scope, + runtime_ptr, + handle, + fragment, + &existing_children, + ) + }) +} + pub(in crate::native_bridge) fn node_inner_text_setter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -1125,10 +1241,10 @@ pub(in crate::native_bridge) fn node_inner_text_setter_function<'s>( else { return; }; - let Some(value) = property_string_value(scope, args.get(0)) else { + let Some(value) = rendered_text_setter_value(scope, args.get(0), "innerText") else { return; }; - let _ = set_text_content_in_reaction_scope(scope, runtime_ptr, handle, &value); + let _ = set_inner_text_in_reaction_scope(scope, runtime_ptr, handle, &value); rv.set_undefined(); } @@ -1181,6 +1297,51 @@ pub(in crate::native_bridge) fn node_outer_text_getter_function<'s>( node_inner_text_getter_function(scope, args, rv); } +fn is_text_node(runtime: &JsContextHost, handle: DomHandle) -> bool { + runtime + .dom_host() + .node(handle) + .is_some_and(|node| node.node_type() == NodeType::Text) +} + +fn merge_with_next_text_node( + scope: &mut v8::PinScope<'_, '_>, + runtime: &mut JsContextHost, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, +) { + let Some(next) = runtime + .dom_host() + .node(handle) + .and_then(Node::next_sibling) + .filter(|next| is_text_node(runtime, *next)) + else { + return; + }; + let Some(parent) = runtime.dom_host().node(handle).and_then(Node::parent_node) else { + return; + }; + let Some(mut value) = runtime.character_data_utf16_units(handle) else { + return; + }; + let Some(next_value) = runtime.character_data_utf16_units(next) else { + return; + }; + let insertion_offset = value.len() as u32; + let inserted_count = next_value.len() as u32; + value.extend_from_slice(&next_value); + let _ = runtime.set_character_data_utf16_units_for_edit(scope, runtime_ptr, handle, &value); + context_bootstrap::live_ranges_character_data_edit( + scope, + handle, + insertion_offset, + 0, + inserted_count, + ); + let _ = + runtime.remove_child_appending_to_current_reaction_queue(scope, runtime_ptr, parent, next); +} + pub(in crate::native_bridge) fn node_outer_text_setter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -1191,14 +1352,11 @@ pub(in crate::native_bridge) fn node_outer_text_setter_function<'s>( else { return; }; - let Some(value) = property_string_value(scope, args.get(0)) else { + let Some(value) = rendered_text_setter_value(scope, args.get(0), "outerText") else { return; }; - let Some(parent) = unsafe { &*runtime_ptr } - .dom_host() - .node(handle) - .and_then(Node::parent_node) - else { + let runtime = unsafe { &*runtime_ptr }; + let Some(parent) = runtime.dom_host().node(handle).and_then(Node::parent_node) else { throw_dom_exception( scope, "NoModificationAllowedError", @@ -1207,28 +1365,42 @@ pub(in crate::native_bridge) fn node_outer_text_setter_function<'s>( ); return; }; + let previous = runtime.dom_host().node(handle).and_then(Node::prev_sibling); + let next = runtime.dom_host().node(handle).and_then(Node::next_sibling); custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { let runtime = unsafe { &mut *runtime_ptr }; - if value.is_empty() { - let _ = runtime.remove_child_appending_to_current_reaction_queue( - scope, - runtime_ptr, - parent, - handle, - ); + let Some(document) = runtime.dom_host().owner_document_handle(handle) else { + return; + }; + let Some(fragment) = rendered_text_fragment(runtime, document, &value) else { + return; + }; + if runtime.dom_host().child_handles(fragment).next().is_none() + && !append_rendered_text_node(runtime, document, fragment, "") + { return; } - let text = match runtime.dom_host().owner_document_handle(parent) { - Some(document_handle) => runtime.create_text_node_for_document(document_handle, &value), - None => runtime.create_text_node(&value), - }; - let _ = runtime.replace_child_appending_to_current_reaction_queue( + if !runtime.replace_child_appending_to_current_reaction_queue( scope, runtime_ptr, parent, - text, + fragment, handle, - ); + ) { + return; + } + if let Some(next) = next + && let Some(previous_to_next) = runtime + .dom_host() + .node(next) + .and_then(Node::prev_sibling) + .filter(|previous| is_text_node(runtime, *previous)) + { + merge_with_next_text_node(scope, runtime, runtime_ptr, previous_to_next); + } + if let Some(previous) = previous.filter(|previous| is_text_node(runtime, *previous)) { + merge_with_next_text_node(scope, runtime, runtime_ptr, previous); + } }); rv.set_undefined(); } diff --git a/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs b/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs index 0d34e773ac..40a668aef7 100644 --- a/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs +++ b/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs @@ -229,39 +229,14 @@ pub(in crate::native_bridge) fn node_replace_children_callback<'s>( document_handle, &inserted, )?; - let added_children = unsafe { &*runtime_ptr } - .dom_host() - .child_handles(fragment) - .collect::>(); - let records_enabled = unsafe { &*runtime_ptr } - .dom_host() - .mutation_records_enabled(); let runtime = unsafe { &mut *runtime_ptr }; - let removes_existing_children = !existing_children.is_empty(); - for &child in &existing_children { - let _ = runtime.remove_child_appending_to_current_reaction_queue( - scope, - runtime_ptr, - parent, - child, - ); - } - let changed = runtime.append_child_appending_to_current_reaction_queue( + runtime.replace_all_children_with_fragment_appending_to_current_reaction_queue( scope, runtime_ptr, parent, fragment, - ) || removes_existing_children; - if changed && records_enabled { - crate::observer_runtime::coalesce_child_list_replacement_records( - runtime_ptr, - parent, - &added_children, - &existing_children, - None, - None, - ); - } + &existing_children, + ); Some(()) }) else { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements/custom_state_and_reaction_order.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements/custom_state_and_reaction_order.rs index 498462b6bd..2fc7266979 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements/custom_state_and_reaction_order.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/custom_elements/custom_state_and_reaction_order.rs @@ -1111,7 +1111,7 @@ fn custom_element_inner_and_outer_text_replacements_enqueue_disconnected_callbac assert_eq!( result, - "0||0||inner:connected|inner:disconnected|outer:connected|outer:disconnected" + "0||1||inner:connected|inner:disconnected|outer:connected|outer:disconnected" ); } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/detached_document_and_element_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/detached_document_and_element_surfaces.rs index b20e909618..45e820f6c1 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/detached_document_and_element_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached/detached_document_and_element_surfaces.rs @@ -1008,7 +1008,9 @@ fn detached_inner_outer_text_use_html_element_prototype_accessors() { assert(inner.get.call(read) === "Alpha Beta", "innerText getter"); assert(outer.get.call(replace) === "Old", "outerText getter"); inner.set.call(read, "Line one\nLine two"); - assert(read.textContent === "Line one\nLine two", "innerText setter"); + assert(read.innerHTML === "Line one
Line two", "innerText setter fragment"); + assert(read.textContent === "Line oneLine two", "innerText setter text content"); + assert(read.querySelector("br").ownerDocument === doc, "innerText setter owner document"); outer.set.call(replace, "Done"); assert(doc.querySelector("#replace").textContent === "Done", "outerText setter"); assert(!own(read, "innerText"), "innerText should not be own after set"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs index 4e2264f6dc..96d132a462 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs @@ -990,6 +990,81 @@ fn htmlelement_standard_accessors_live_on_owner_prototypes() { assert_eq!(result, "ok"); } +#[test] +fn inner_and_outer_text_setters_build_rendered_fragments_and_merge_text_endpoints() { + let mut vm = new_parsed_test_vm( + "https://rendered-text-setters.test/", + "", + ); + + let result = vm + .eval( + r#" + (() => { + const assert = (condition, message) => { + if (!condition) throw new Error(message); + }; + + const inner = document.createElement("div"); + const oldChild = inner.appendChild(document.createElement("span")); + const observer = new MutationObserver(() => {}); + observer.observe(inner, { childList: true }); + inner.innerText = "alpha\r\nbeta\n\r"; + assert(inner.innerHTML === "alpha
beta

", "innerText rendered fragment"); + assert(inner.childNodes.length === 5, "innerText child count"); + assert(inner.firstChild !== oldChild, "innerText replaces the old child"); + assert(Array.from(inner.childNodes).every(node => node.ownerDocument === document), "innerText owner document"); + const records = observer.takeRecords(); + assert(records.length === 1, "innerText replacement mutation count"); + assert(records[0].addedNodes.length === 5, "innerText replacement added nodes"); + assert(records[0].removedNodes.length === 1 && records[0].removedNodes[0] === oldChild, "innerText replacement removed node"); + + inner.innerText = null; + assert(inner.childNodes.length === 0, "innerText null is empty"); + inner.innerText = undefined; + assert(inner.textContent === "undefined", "innerText undefined stringifies"); + + const detachedDocument = document.implementation.createHTMLDocument(""); + const detached = detachedDocument.createElement("div"); + detached.innerText = "left\nright"; + assert(detached.innerHTML === "left
right", "detached innerText rendered fragment"); + assert(detached.querySelector("br").ownerDocument === detachedDocument, "detached break owner document"); + + const parent = document.createElement("div"); + const first = parent.appendChild(document.createTextNode("A")); + const previous = parent.appendChild(document.createTextNode("B")); + const replaced = parent.appendChild(document.createElement("span")); + const next = parent.appendChild(document.createTextNode("D")); + const last = parent.appendChild(document.createTextNode("E")); + replaced.outerText = "Replaced"; + assert(parent.childNodes.length === 3, "outerText only merges endpoints"); + assert(parent.childNodes[0] === first && parent.childNodes[2] === last, "outerText keeps distant text nodes separate"); + assert(parent.childNodes[1] === previous && previous.data === "BReplacedD", "outerText merges adjacent text nodes"); + assert(replaced.parentNode === null && next.parentNode === null, "outerText detaches replaced nodes"); + + const emptyParent = document.createElement("div"); + const emptyTarget = emptyParent.appendChild(document.createElement("span")); + emptyTarget.outerText = null; + assert(emptyParent.childNodes.length === 1, "outerText empty child count"); + assert(emptyParent.firstChild.nodeType === Node.TEXT_NODE && emptyParent.firstChild.data === "", "outerText keeps an empty Text node"); + + const breaksParent = document.createElement("div"); + const breaksTarget = breaksParent.appendChild(document.createElement("span")); + breaksTarget.outerText = "\n\r\n\r"; + assert(breaksParent.innerHTML === "


", "outerText newline conversion"); + + const script = document.createElement("script"); + script.innerText = "one\ntwo"; + assert(script.innerHTML === "one
two", "HTMLScriptElement innerText rendered fragment"); + return "ok"; + })() + "#, + ) + .expect("rendered text setter probe should evaluate"); + + assert_eq!(result, "ok"); +} + #[test] fn document_state_and_collection_accessors_live_on_document_prototype() { let mut vm = new_parsed_test_vm(