diff --git a/moli-dom/src/native/host/mutation/tree.rs b/moli-dom/src/native/host/mutation/tree.rs index da501fdded..c27fc922ed 100644 --- a/moli-dom/src/native/host/mutation/tree.rs +++ b/moli-dom/src/native/host/mutation/tree.rs @@ -564,6 +564,46 @@ impl DomHost { self.set_text_content_effects(handle, value).did_change() } + /// Appends parser character tokens while retaining the text node's capacity. + /// Old text is copied only when mutation records need its previous value. + pub fn append_text_to_text_node_effects( + &mut self, + handle: DomHandle, + value: &str, + ) -> DomMutationEffects { + if value.is_empty() { + return DomMutationEffects::default(); + } + let records_enabled = self.mutation_records_enabled(); + let Some(text) = self + .node_mut(handle) + .and_then(|node| node.data_mut().as_text_mut()) + else { + return DomMutationEffects::default(); + }; + let old_value = records_enabled.then(|| text.data().to_owned()); + text.append_data(value); + self.text_data_mutation_effects(handle, records_enabled, old_value) + } + + fn text_data_mutation_effects( + &mut self, + handle: DomHandle, + records_enabled: bool, + old_value: Option, + ) -> DomMutationEffects { + self.record_mutation(MutationScope::QueryState); + let mut effects = self.node_update_effects(handle); + effects.mark_style_character_data_mutation(handle); + if let Some(parent) = self.parent_node(handle) { + self.mark_stylesheet_owner_contents_change_for_parent(&mut effects, parent); + } + if records_enabled { + effects.mark_character_data_mutation(handle, old_value); + } + effects + } + pub fn set_text_content_effects( &mut self, handle: DomHandle, @@ -617,16 +657,7 @@ impl DomHost { if !changed { return DomMutationEffects::default(); } - self.record_mutation(MutationScope::QueryState); - let mut effects = self.node_update_effects(handle); - effects.mark_style_character_data_mutation(handle); - if let Some(parent) = self.parent_node(handle) { - self.mark_stylesheet_owner_contents_change_for_parent(&mut effects, parent); - } - if records_enabled { - effects.mark_character_data_mutation(handle, old_value); - } - effects + self.text_data_mutation_effects(handle, records_enabled, old_value) } NodeType::Element | NodeType::DocumentFragment => { let children = self.child_handles(handle).collect::>(); diff --git a/moli-dom/src/native/mod.rs b/moli-dom/src/native/mod.rs index d09a735f9e..cfa2bb851a 100644 --- a/moli-dom/src/native/mod.rs +++ b/moli-dom/src/native/mod.rs @@ -915,6 +915,74 @@ mod tests { assert!(effects.observer_records().records().is_empty()); } + #[test] + fn appending_text_preserves_replacement_mutation_effects() { + for parent_name in ["p", "style", "script"] { + for recording in [false, true] { + let mut host = DomHost::from_dom(NativeDom::new_html(test_url())); + host.reset_html_document_shell(); + let body = host.document_body_handle().expect("document body"); + let parent = host.create_element(parent_name); + let text = host.create_text_node("before🙂"); + assert!(host.append_child(body, parent)); + assert!(host.append_child(parent, text)); + host.set_mutation_observer_records_enabled(recording); + let mut replacement = host.clone(); + + let effects = host.append_text_to_text_node_effects(text, "追加\n"); + let expected = replacement.set_text_content_effects(text, "before🙂追加\n"); + + assert_eq!( + effects, expected, + "parent={parent_name}, recording={recording}" + ); + assert_eq!(host.text_content(text).as_deref(), Some("before🙂追加\n")); + assert_eq!(host.query_version(), replacement.query_version()); + } + } + } + + #[test] + fn appending_text_keeps_existing_dom_snapshots_unchanged() { + let mut host = DomHost::from_dom(NativeDom::new_html(test_url())); + let text = host.create_text_node("before🙂"); + let snapshot = host.snapshot_document(); + + assert!( + host.append_text_to_text_node_effects(text, "追加") + .did_change() + ); + + assert_eq!(host.text_content(text).as_deref(), Some("before🙂追加")); + assert_eq!(snapshot.text_content(text).as_deref(), Some("before🙂")); + } + + #[test] + fn appending_empty_text_or_non_text_nodes_has_no_mutation_effects() { + let mut host = DomHost::from_dom(NativeDom::new_html(test_url())); + let text = host.create_text_node("original"); + let element = host.create_element("div"); + let document = host.document_handle(); + let cdata = host.create_cdata_section_for_document(document, "cdata"); + host.set_mutation_observer_records_enabled(true); + let version = host.query_version(); + + assert_eq!( + host.append_text_to_text_node_effects(text, ""), + DomMutationEffects::default() + ); + for handle in [element, cdata, NativeNodeId::new(99_999)] { + assert_eq!( + host.append_text_to_text_node_effects(handle, "ignored"), + DomMutationEffects::default() + ); + } + assert_eq!(host.query_version(), version); + assert_eq!(host.text_content(text).as_deref(), Some("original")); + assert_eq!(host.text_content(element).as_deref(), Some("")); + assert_eq!(host.text_content(cdata).as_deref(), Some("cdata")); + } + #[test] fn insert_before_self_reference_reports_remove_and_reinsert() { let mut host = DomHost::from_dom(NativeDom::new_html(test_url())); @@ -957,7 +1025,8 @@ mod tests { "DocumentType grew to {} bytes", size_of::() ); - assert_eq!(size_of::(), 16); + // Growing text fits within the existing NodeData/Node size limits below. + assert_eq!(size_of::(), size_of::()); assert_eq!(size_of::(), 16); assert_eq!(size_of::(), 16); assert_eq!(size_of::(), 32); diff --git a/moli-dom/src/native/node/types.rs b/moli-dom/src/native/node/types.rs index 9be7c40af9..f2ddc4b8e5 100644 --- a/moli-dom/src/native/node/types.rs +++ b/moli-dom/src/native/node/types.rs @@ -1,13 +1,14 @@ #[derive(Debug, Clone)] pub struct Text { - data: Box, + // Text shares the larger NodeData enum storage with elements, so retaining + // String capacity does not enlarge a Node. Parser character tokens can then + // append without reallocating and copying the full text for every token. + data: String, } impl Text { pub fn new(data: String) -> Self { - Self { - data: data.into_boxed_str(), - } + Self { data } } pub fn data(&self) -> &str { @@ -15,7 +16,11 @@ impl Text { } pub fn set_data(&mut self, data: impl Into) { - self.data = data.into().into_boxed_str(); + self.data = data.into(); + } + + pub(crate) fn append_data(&mut self, data: &str) { + self.data.push_str(data); } } diff --git a/moli-parser/src/live_target.rs b/moli-parser/src/live_target.rs index 189f821756..ad728632b9 100644 --- a/moli-parser/src/live_target.rs +++ b/moli-parser/src/live_target.rs @@ -1637,12 +1637,7 @@ fn append_text_to_text_node_in_host( node_id: NativeNodeId, text: String, ) -> DomMutationEffects { - let Some(previous) = host.node(node_id).and_then(Node::as_text) else { - return DomMutationEffects::default(); - }; - let mut merged = previous.data().to_owned(); - merged.push_str(&text); - host.set_text_content_effects(node_id, &merged) + host.append_text_to_text_node_effects(node_id, &text) } #[cfg(test)] diff --git a/moli-parser/src/stream.rs b/moli-parser/src/stream.rs index 47fdb85787..87e6f3add7 100644 --- a/moli-parser/src/stream.rs +++ b/moli-parser/src/stream.rs @@ -1158,13 +1158,7 @@ mod tests { text: String, ) -> DomMutationEffects { // SAFETY: the test keeps the DomHost alive for this parser pump step. - let host = unsafe { &mut *self.host }; - let Some(previous) = host.node(node_id).and_then(Node::as_text) else { - return DomMutationEffects::default(); - }; - let mut merged = previous.data().to_owned(); - merged.push_str(&text); - host.set_text_content_effects(node_id, &merged) + unsafe { &mut *self.host }.append_text_to_text_node_effects(node_id, &text) } fn push_parse_error(&mut self, error: String) { @@ -1328,6 +1322,44 @@ mod tests { } } + #[test] + fn parser_stream_preserves_large_script_text_across_input_chunks() { + let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing( + Url::parse("https://example.test/page.html").expect("test url"), + ); + let block = "{\"text\":\"中文🙂 & tail\"}\n".repeat(256); + let source = block.repeat(128); + assert!(source.len() > 1_000_000); + + stream.feed("

after script

"); + + let document = stream.finish(); + let script = document.elements_by_tag_name(document.document_node_id(), "script", false)[0]; + assert_eq!(document.child_ids(script).count(), 1); + assert_eq!( + document.text_content(script).as_deref(), + Some(source.as_str()) + ); + assert_eq!( + document + .node(script) + .and_then(Node::as_element) + .expect("script element") + .script_text_internal_slot(), + source + ); + assert_eq!( + document + .elements_by_tag_name(document.document_node_id(), "p", false) + .len(), + 1 + ); + } + #[test] fn parser_stream_feed_continues_past_definitive_encoding_indicator() { let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing( diff --git a/moli-renderer-v8/src/document_runtime/dom_facade.rs b/moli-renderer-v8/src/document_runtime/dom_facade.rs index ad9389366e..9c66fc9b16 100644 --- a/moli-renderer-v8/src/document_runtime/dom_facade.rs +++ b/moli-renderer-v8/src/document_runtime/dom_facade.rs @@ -382,13 +382,8 @@ impl DocumentRuntime { node_id: DomHandle, text: String, ) -> DomMutationEffects { - let host = self.dom_host_mut_for_active_parser_step(); - let Some(previous) = host.node(node_id).and_then(Node::as_text) else { - return DomMutationEffects::default(); - }; - let mut merged = previous.data().to_owned(); - merged.push_str(&text); - host.set_text_content_effects(node_id, &merged) + self.dom_host_mut_for_active_parser_step() + .append_text_to_text_node_effects(node_id, &text) } pub(crate) fn push_parse_error_in_live_dom_host(&mut self, error: String) {