fix(dom): implement rendered text setters

This commit is contained in:
ldm0
2026-09-27 19:22:01 +08:00
parent 6092695f70
commit 74d4e31ca0
9 changed files with 324 additions and 59 deletions
@@ -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
@@ -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
@@ -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::<Vec<_>>();
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<'_, '_>,
@@ -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();
}
@@ -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<String> {
let options = crate::webidl::StringOptions {
treat_null_as_empty_string: true,
};
match crate::webidl::convert_with_options::<crate::webidl::DomString>(
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<DomHandle> {
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::<Vec<_>>();
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();
}
@@ -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::<Vec<_>>();
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 {
@@ -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"
);
}
@@ -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<br>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");
@@ -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/",
"<!doctype html><html><head></head><body></body></html>",
);
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<br>beta<br><br>", "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<br>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 === "<br><br><br>", "outerText newline conversion");
const script = document.createElement("script");
script.innerText = "one\ntwo";
assert(script.innerHTML === "one<br>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(