diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 7d25d61a8..a69726394 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -4421,6 +4421,7 @@ dom/nodes/Node-childNodes-cache-2.html dom/nodes/Node-childNodes-cache.html dom/nodes/Node-childNodes.html dom/nodes/Node-cloneNode-XMLDocument.html +dom/nodes/Node-cloneNode-document-allow-declarative-shadow-roots.window.js?moli-wpt-script=window dom/nodes/Node-cloneNode-document-with-doctype.html dom/nodes/Node-cloneNode-on-inactive-document-crash.html dom/nodes/Node-cloneNode-svg.html diff --git a/moli-dom/src/native/document.rs b/moli-dom/src/native/document.rs index aba0a9851..7a1c5e6ab 100644 --- a/moli-dom/src/native/document.rs +++ b/moli-dom/src/native/document.rs @@ -50,6 +50,7 @@ pub struct Document { url: Url, content_type: Box, character_set: Box, + allow_declarative_shadow_roots: bool, ready_state: DocumentReadyState, // Retained with the Document when its Window is replaced during navigation. visibility_hidden: bool, @@ -81,6 +82,7 @@ impl Document { url, content_type: "text/html".into(), character_set: "UTF-8".into(), + allow_declarative_shadow_roots: false, ready_state: DocumentReadyState::Complete, visibility_hidden: false, quirks_mode: QuirksMode::NoQuirks, @@ -99,6 +101,7 @@ impl Document { url, content_type: "application/xml".into(), character_set: "UTF-8".into(), + allow_declarative_shadow_roots: false, ready_state: DocumentReadyState::Complete, visibility_hidden: false, quirks_mode: QuirksMode::NoQuirks, @@ -123,6 +126,14 @@ impl Document { &self.character_set } + pub fn allow_declarative_shadow_roots(&self) -> bool { + self.allow_declarative_shadow_roots + } + + pub fn set_allow_declarative_shadow_roots(&mut self, allow: bool) { + self.allow_declarative_shadow_roots = allow; + } + pub fn set_character_set(&mut self, character_set: impl Into) { self.character_set = character_set.into().into_boxed_str(); } diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 5fe2b9dd4..6c5edd7c1 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -1003,6 +1003,19 @@ impl DomHost { true } + pub fn set_document_allow_declarative_shadow_roots_for_handle( + &mut self, + document_handle: DomHandle, + allow: bool, + ) { + if let Some(document) = self + .node_mut(document_handle) + .and_then(|node| node.data_mut().as_document_mut()) + { + document.set_allow_declarative_shadow_roots(allow); + } + } + pub fn document_scripting_enabled_for_handle( &self, document_handle: DomHandle, diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index 697732116..e4e442c35 100644 --- a/moli-parser/src/html.rs +++ b/moli-parser/src/html.rs @@ -488,7 +488,25 @@ impl HtmlParser { final_url: Url, document_handle: NativeNodeId, ) -> DocumentStream { - DocumentStream::new_live_document_root(final_url, document_handle, self.scripting_enabled) + self.start_live_document_root_with_declarative_shadow_roots( + final_url, + document_handle, + true, + ) + } + + pub fn start_live_document_root_with_declarative_shadow_roots( + &self, + final_url: Url, + document_handle: NativeNodeId, + allow_declarative_shadow_roots: bool, + ) -> DocumentStream { + DocumentStream::new_live_document_root( + final_url, + document_handle, + self.scripting_enabled, + allow_declarative_shadow_roots, + ) } pub fn parse_fragment_without_declarative_shadow_roots( @@ -609,12 +627,14 @@ impl DocumentStream { final_url: Url, document_handle: NativeNodeId, scripting_enabled: bool, + allow_declarative_shadow_roots: bool, ) -> Self { Self { inner: new_live_document_root_html_tree_sink_stream( final_url, document_handle, scripting_enabled, + allow_declarative_shadow_roots, ), input: RefCell::default(), } @@ -662,7 +682,7 @@ impl DocumentStream { final_url: Url, document_handle: NativeNodeId, ) -> Self { - Self::new_live_document_root(final_url, document_handle, true) + Self::new_live_document_root(final_url, document_handle, true, true) } #[cfg(any(test, feature = "test-support"))] @@ -733,6 +753,23 @@ impl DocumentStream { self.inner.feed(chunk) } + /// Feeds an inert document parser while retaining its tokenizer and tree + /// builder state between writes. Scripts are not handed off for execution. + pub fn feed_with_runtime_dom_consumer(&self, chunk: &str, consumer: &mut T) + where + T: ParserDomReadConsumer + + ParserDomMutationConsumer + + ParserMutationEffectConsumer + + ParserElementCreationConsumer, + { + // SAFETY: the parser-step guard clears the callbacks before the + // exclusively borrowed consumer leaves this call. + let sinks = unsafe { ParserRuntimeDomSinks::from_consumer(consumer) }; + self.inner.enter_runtime_dom_sinks_parse_step(sinks); + let step = RuntimeDomSinksParserStep { stream: self }; + step.feed(chunk); + } + fn feed_with_runtime_dom_consumer_without_element_creation( &self, chunk: &str, diff --git a/moli-parser/src/live_target.rs b/moli-parser/src/live_target.rs index 1e83f1464..c38ee058d 100644 --- a/moli-parser/src/live_target.rs +++ b/moli-parser/src/live_target.rs @@ -2108,11 +2108,15 @@ impl ParserStreamHtmlTreeSinkTarget { allow_declarative_shadow_roots: bool, scripting_enabled: bool, ) -> Self { - let dom_host = DomHost::from_dom(NativeDom::new_html_with_scripting( + let mut dom_host = DomHost::from_dom(NativeDom::new_html_with_scripting( final_url.clone(), scripting_enabled, )); let document_handle = dom_host.document_handle(); + dom_host.set_document_allow_declarative_shadow_roots_for_handle( + document_handle, + allow_declarative_shadow_roots, + ); Self { owned_dom_host: Some(dom_host), parser_root_handle: Some(document_handle), @@ -3690,9 +3694,18 @@ pub(super) fn new_live_document_root_html_tree_sink_stream( final_url: Url, document_handle: NativeNodeId, scripting_enabled: bool, + allow_declarative_shadow_roots: bool, ) -> HtmlTreeSinkStream { HtmlTreeSinkStream::from_target_with_scripting( - ParserStreamHtmlTreeSinkTarget::new_live_document_root(final_url, document_handle), + if allow_declarative_shadow_roots { + ParserStreamHtmlTreeSinkTarget::new_live_document_root(final_url, document_handle) + } else { + ParserStreamHtmlTreeSinkTarget::new_live_document_root_with_declarative_shadow_roots( + final_url, + document_handle, + false, + ) + }, scripting_enabled, ) } diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index 52dd83931..02fb79749 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -745,6 +745,7 @@ pub(super) struct DocumentRuntime { destructive_write_counters: destructive_writes::DocumentWriteCounters, document_unload_counters: destructive_writes::DocumentWriteCounters, root_document_parser: Option, + windowless_document_parsers: HashMap, post_parse_schedule_invalidated: bool, stylesheet_lifecycle: StylesheetLifecycleState, main_parser_continuation: main_parser_continuation::MainParserContinuationState, diff --git a/moli-renderer-v8/src/document_runtime/document_write.rs b/moli-renderer-v8/src/document_runtime/document_write.rs index 1954ba66e..ef408c1ab 100644 --- a/moli-renderer-v8/src/document_runtime/document_write.rs +++ b/moli-renderer-v8/src/document_runtime/document_write.rs @@ -22,6 +22,9 @@ use moli_parser::{ use std::collections::HashSet; use tracing::debug; +mod windowless; +pub(super) use windowless::WindowlessDocumentParserState; + struct DocumentWriteParserPumpStep { outcome: ParserPumpOutcome, } @@ -49,6 +52,7 @@ struct DocumentWriteParserMutationOwner<'a, 'scope, 'pin> { #[derive(Clone, Copy)] enum DocumentWriteParserMutationTarget { LiveDocument, + WindowlessDocument { owner_document: DomHandle }, DetachedFragment { owner_document: DomHandle }, } @@ -56,7 +60,8 @@ impl DocumentWriteParserMutationOwner<'_, '_, '_> { fn owner_document_handle(&self) -> DomHandle { match self.target { DocumentWriteParserMutationTarget::LiveDocument => self.runtime.document_handle(), - DocumentWriteParserMutationTarget::DetachedFragment { owner_document } => { + DocumentWriteParserMutationTarget::WindowlessDocument { owner_document } + | DocumentWriteParserMutationTarget::DetachedFragment { owner_document } => { owner_document } } @@ -71,7 +76,10 @@ impl LiveDocumentParserOwner for DocumentWriteParserMutationOwner<'_, '_, '_> {} impl ParserMutationEffectConsumer for DocumentWriteParserMutationOwner<'_, '_, '_> { fn consume_parser_mutation_effects(&mut self, effects: DomMutationEffects) { - if !self.targets_live_document() { + if matches!( + self.target, + DocumentWriteParserMutationTarget::DetachedFragment { .. } + ) { return; } self.runtime @@ -183,7 +191,10 @@ impl ParserDomReadConsumer for DocumentWriteParserMutationOwner<'_, '_, '_> { impl ParserDomMutationConsumer for DocumentWriteParserMutationOwner<'_, '_, '_> { fn apply_parser_dom_mutation(&mut self, mutation: ParserDomMutation) { - if !self.targets_live_document() { + if matches!( + self.target, + DocumentWriteParserMutationTarget::DetachedFragment { .. } + ) { let _ = mutation .apply_to_detached_dom_host(self.runtime.dom_host_mut_for_active_parser_step()); return; @@ -236,10 +247,12 @@ impl ParserDomMutationConsumer for DocumentWriteParserMutationOwner<'_, '_, '_> // new body/frameset Window attributes when they are added, even if the // fragment is never connected. Existing attributes must not reactivate // an event handler that script has cleared through its IDL property. - let window_handlers = if !self.targets_live_document() - && self.runtime.dom_host().node(node_id).is_some_and(|node| { - node.is_html_element_named("body") || node.is_html_element_named("frameset") - }) { + let window_handlers = if matches!( + self.target, + DocumentWriteParserMutationTarget::DetachedFragment { .. } + ) && self.runtime.dom_host().node(node_id).is_some_and(|node| { + node.is_html_element_named("body") || node.is_html_element_named("frameset") + }) { attrs .iter() .filter(|attr| { @@ -334,6 +347,17 @@ impl ParserDomMutationConsumer for DocumentWriteParserMutationOwner<'_, '_, '_> if self.targets_live_document() { self.runtime .set_html_quirks_mode_for_parser_in_live_dom_host(quirks_mode); + } else if let DocumentWriteParserMutationTarget::WindowlessDocument { owner_document } = + self.target + { + let quirks_mode = match quirks_mode { + QuirksMode::NoQuirks => selectors::matching::QuirksMode::NoQuirks, + QuirksMode::LimitedQuirks => selectors::matching::QuirksMode::LimitedQuirks, + QuirksMode::Quirks => selectors::matching::QuirksMode::Quirks, + }; + self.runtime + .dom_host_mut() + .set_document_quirks_mode_for_handle(owner_document, quirks_mode); } } diff --git a/moli-renderer-v8/src/document_runtime/document_write/windowless.rs b/moli-renderer-v8/src/document_runtime/document_write/windowless.rs new file mode 100644 index 000000000..426944966 --- /dev/null +++ b/moli-renderer-v8/src/document_runtime/document_write/windowless.rs @@ -0,0 +1,128 @@ +use super::*; +use std::rc::Rc; + +pub(in crate::document_runtime) struct WindowlessDocumentParserState { + stream: Option, + token: Rc<()>, +} + +impl std::fmt::Debug for WindowlessDocumentParserState { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WindowlessDocumentParserState") + .field("has_stream", &self.stream.is_some()) + .finish_non_exhaustive() + } +} + +impl DocumentRuntime { + pub(crate) fn has_windowless_document_parser(&self, document: DomHandle) -> bool { + self.windowless_document_parsers + .get(&document) + .is_some_and(|state| state.stream.is_some()) + } + + pub(crate) fn start_windowless_document_parser(&mut self, document: DomHandle) -> Option<()> { + let source = self.dom_host().node(document)?.as_document()?; + let stream = HtmlParser::with_scripting_enabled(false) + .start_live_document_root_with_declarative_shadow_roots( + source.url().clone(), + document, + source.allow_declarative_shadow_roots(), + ); + self.dom_host_mut().set_document_quirks_mode_for_handle( + document, + selectors::matching::QuirksMode::NoQuirks, + ); + self.dom_host_mut() + .set_document_scripting_enabled_for_handle(document, false); + self.dom_host_mut() + .set_document_ready_state_for_handle(document, DocumentReadyState::Loading); + self.windowless_document_parsers.insert( + document, + WindowlessDocumentParserState { + stream: Some(stream), + token: Rc::new(()), + }, + ); + Some(()) + } + + pub(crate) fn write_windowless_document( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + host_ptr: *mut JsContextHost, + document: DomHandle, + html: &str, + ) -> Option<()> { + let state = self.windowless_document_parsers.get_mut(&document)?; + let stream = state.stream.take()?; + let token = state.token.clone(); + self.with_dom_host_parse_step(|runtime| { + let mut owner = DocumentWriteParserMutationOwner { + runtime, + scope, + host_ptr, + target: DocumentWriteParserMutationTarget::WindowlessDocument { + owner_document: document, + }, + }; + stream.feed_with_runtime_dom_consumer(html, &mut owner); + }); + custom_elements::apply_parser_created_null_registry_associations( + host_ptr, + &stream.take_parser_stream_null_custom_element_registry_elements(), + ); + if self.windowless_document_parser_is_current(document, &token) { + self.windowless_document_parsers.get_mut(&document)?.stream = Some(stream); + } + Some(()) + } + + pub(crate) fn finish_windowless_document_parser( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + host_ptr: *mut JsContextHost, + document: DomHandle, + ) -> Option> { + let state = self.windowless_document_parsers.get_mut(&document)?; + let stream = state.stream.take()?; + let token = state.token.clone(); + let finish_signals = self.with_dom_host_parse_step(|runtime| { + let mut owner = DocumentWriteParserMutationOwner { + runtime, + scope, + host_ptr, + target: DocumentWriteParserMutationTarget::WindowlessDocument { + owner_document: document, + }, + }; + stream.finish_with_runtime_dom_consumer(&mut owner) + }); + custom_elements::apply_parser_created_null_registry_associations( + host_ptr, + &finish_signals.parser_created_null_registry_elements, + ); + Some(token) + } + + pub(crate) fn windowless_document_parser_is_current( + &self, + document: DomHandle, + token: &Rc<()>, + ) -> bool { + self.windowless_document_parsers + .get(&document) + .is_some_and(|state| Rc::ptr_eq(&state.token, token)) + } + + pub(crate) fn release_finished_windowless_document_parser( + &mut self, + document: DomHandle, + token: &Rc<()>, + ) { + if self.windowless_document_parser_is_current(document, token) { + self.windowless_document_parsers.remove(&document); + } + } +} diff --git a/moli-renderer-v8/src/document_runtime/runtime_core.rs b/moli-renderer-v8/src/document_runtime/runtime_core.rs index 1c3fea9df..8ba5718ed 100644 --- a/moli-renderer-v8/src/document_runtime/runtime_core.rs +++ b/moli-renderer-v8/src/document_runtime/runtime_core.rs @@ -63,6 +63,8 @@ impl DocumentRuntime { // initial "loading". Without this, the DomHost keeps its default "complete" // (from NativeDom Document::new) and scripts see the wrong readyState. let _ = dom_host.set_document_ready_state(document.ready_state()); + let document_handle = dom_host.document_handle(); + dom_host.set_document_allow_declarative_shadow_roots_for_handle(document_handle, true); let parser_boundary_lifecycle_tx = page_task_parser_boundary_injection_tx.clone(); Self { dom_host, @@ -86,6 +88,7 @@ impl DocumentRuntime { destructive_write_counters: Default::default(), document_unload_counters: Default::default(), root_document_parser: None, + windowless_document_parsers: HashMap::new(), post_parse_schedule_invalidated: false, stylesheet_lifecycle, main_parser_continuation: @@ -220,6 +223,7 @@ impl DocumentRuntime { destructive_write_counters: _, document_unload_counters: _, root_document_parser: _, + windowless_document_parsers: _, post_parse_schedule_invalidated: _, stylesheet_lifecycle: _, main_parser_continuation: _, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs index 5b41e9e1d..3c776f18f 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs @@ -207,6 +207,8 @@ impl JsContextHost { // observe them. Keep the generic detached/initial-empty default complete. self.dom_host_mut() .set_document_character_set_for_handle(document_handle, &snapshot.character_set); + self.dom_host_mut() + .set_document_allow_declarative_shadow_roots_for_handle(document_handle, true); let _ = self .set_dom_document_ready_state_for_handle(document_handle, DocumentReadyState::Loading); let document_url = self.document_url_for_handle(document_handle); diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs index 959d491c4..7dd7d91f1 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs @@ -1778,6 +1778,8 @@ impl JsContextHost { let document_handle = self .dom_host_mut() .create_detached_html_document_with_url(document_url); + self.dom_host_mut() + .set_document_allow_declarative_shadow_roots_for_handle(document_handle, true); if let Some(content_type) = content_type { let _ = self.set_dom_document_content_type_for_handle(document_handle, content_type); } diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index 2cca89ff1..9ed22c1c9 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -1172,10 +1172,6 @@ fn document_ready_state_getter_function<'s>( mut rv: v8::ReturnValue<'s, v8::Value>, ) { let receiver = args.this(); - if let Some(ready_state) = detached_state_string(scope, receiver, "readyState") { - set_document_string_return_value(scope, &mut rv, &ready_state); - return; - } let Some((runtime_ptr, handle)) = document_receiver_runtime_and_handle(scope, receiver) else { rv.set_undefined(); return; diff --git a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs index 8ffa0ad25..574efe200 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs @@ -137,6 +137,7 @@ fn set_detached_document_parse_metadata<'s>( document: v8::Local<'s, v8::Object>, quirks_mode: selectors::matching::QuirksMode, character_set: &str, + allow_declarative_shadow_roots: bool, ) -> Option<()> { let compat_mode = if quirks_mode == selectors::matching::QuirksMode::Quirks { "BackCompat" @@ -159,6 +160,10 @@ fn set_detached_document_parse_metadata<'s>( let dom_host = unsafe { &mut *runtime_ptr }.dom_host_mut(); dom_host.set_document_quirks_mode_for_handle(handle, quirks_mode); dom_host.set_document_character_set_for_handle(handle, character_set); + dom_host.set_document_allow_declarative_shadow_roots_for_handle( + handle, + allow_declarative_shadow_roots, + ); Some(()) } @@ -182,11 +187,18 @@ pub(in crate::native_bridge::document) fn build_detached_document_clone_shell<'s let content_type = document.content_type().to_owned(); let quirks_mode = document.quirks_mode(); let character_set = document.character_set().to_owned(); + let allow_declarative_shadow_roots = document.allow_declarative_shadow_roots(); // Start with an empty, inert Document. Only the metadata required by the // DOM cloning algorithm is inherited, before any cloned children are added. let cloned = new_detached_document_shell(scope, &kind, &content_type, url, false)?; - set_detached_document_parse_metadata(scope, cloned, quirks_mode, &character_set)?; + set_detached_document_parse_metadata( + scope, + cloned, + quirks_mode, + &character_set, + allow_declarative_shadow_roots, + )?; inherit_detached_document_origin(scope, cloned, source); Some(cloned) } @@ -271,8 +283,15 @@ pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s> let scripting_enabled = parsed.dom().document()?.scripting_enabled(); let content_type = content_type.unwrap_or(parsed.dom().document()?.content_type()); let character_set = character_set.unwrap_or(parsed.dom().document()?.character_set()); + let allow_declarative_shadow_roots = parsed.dom().document()?.allow_declarative_shadow_roots(); let document = new_detached_document_shell(scope, kind, content_type, url, scripting_enabled)?; - set_detached_document_parse_metadata(scope, document, quirks_mode, character_set)?; + set_detached_document_parse_metadata( + scope, + document, + quirks_mode, + character_set, + allow_declarative_shadow_roots, + )?; import_detached_document_children_from_host(scope, document, &parsed)?; Some(document) } diff --git a/moli-renderer-v8/src/native_bridge/document/detached_surface/document_state.rs b/moli-renderer-v8/src/native_bridge/document/detached_surface/document_state.rs index c28fc2b98..4aacfb556 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_surface/document_state.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_surface/document_state.rs @@ -168,7 +168,12 @@ pub(in crate::native_bridge) fn bridge_detached_document_ready_state_callback<'a rv.set_empty_string(); return; }; - let value = detached_document_state_string(scope, document, "readyState", "complete"); + let value = + crate::native_bridge::document::document_receiver_runtime_and_handle(scope, document) + .map(|(runtime_ptr, handle)| { + unsafe { &*runtime_ptr }.document_ready_state_for_handle(handle) + }) + .unwrap_or_else(|| "complete".to_owned()); set_string_return_value(scope, &mut rv, &value); } @@ -272,7 +277,20 @@ pub(in crate::native_bridge) fn bridge_detached_document_compat_mode_callback<'a ) { let compat_mode = v8::Local::::try_from(args.get(0)) .ok() - .map(|document| detached_document_state_string(scope, document, "compatMode", "CSS1Compat")) + .and_then(|document| { + crate::native_bridge::document::document_receiver_runtime_and_handle(scope, document) + }) + .map(|(runtime_ptr, handle)| { + let quirks = unsafe { &*runtime_ptr } + .dom_host() + .document_quirks_mode_for_handle(handle); + if quirks == Some(selectors::matching::QuirksMode::Quirks) { + "BackCompat" + } else { + "CSS1Compat" + } + .to_owned() + }) .unwrap_or_else(|| "CSS1Compat".to_owned()); set_string_return_value(scope, &mut rv, &compat_mode); } diff --git a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs index 28cb7be3b..bf04c568d 100644 --- a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs @@ -4,7 +4,8 @@ use super::super::node::{ remove_child_in_reaction_scope, }; use super::{ - JsContextHost, detached_native_handle_for_runtime, is_html_document, throw_dom_exception, + JsContextHost, document_has_browsing_context, + is_html_document, throw_dom_exception, }; use crate::native_bridge::element::{ TextEditInputType, contenteditable_editing_host, dispatch_text_control_event, document_copy_command_supported, @@ -15,20 +16,17 @@ use crate::native_bridge::element::{ use crate::{ context_bootstrap::WINDOW_EVENT_HANDLER_PROPERTIES, custom_elements, - document_runtime::DomHandle, - dom::native::{NativeDom, NodeData}, + document_runtime::{DomHandle, EventTargetHandle}, + dom::native::{DocumentReadyState, NativeDom, NodeData}, parser::HtmlParser, util::{ - call_object_method, get_private_value, node_wrapper_from_handle, set_private_value, - utf16_next_scalar_boundary, utf16_previous_scalar_boundary, - utf16_replace_units_range_lossy, utf16_scalar_boundary_at_or_after, utf16_units, v8_string, - v8str, + call_object_method, node_wrapper_from_handle, utf16_next_scalar_boundary, + utf16_previous_scalar_boundary, utf16_replace_units_range_lossy, + utf16_scalar_boundary_at_or_after, utf16_units, v8_string, v8str, }, webidl, }; -const DETACHED_DOCUMENT_WRITE_STREAM_OPEN_SLOT: &str = "__moliDetachedDocumentWriteStreamOpen"; - struct DocumentWriteInput { text: String, is_trusted: bool, @@ -121,24 +119,25 @@ fn node_document_write_or_writeln_callback<'s>( ); return; } - if detached_native_handle_for_runtime(scope, runtime_ptr, args.this()).is_some() { - let document = args.this(); - let stream_was_open = detached_document_write_stream_is_open(scope, document); + if !document_has_browsing_context(unsafe { &*runtime_ptr }, handle) { + let stream_was_open = unsafe { &*runtime_ptr }.has_windowless_document_parser(handle); if !stream_was_open && unsafe { &*runtime_ptr }.has_document_unload_counter(handle) { rv.set_undefined(); return; } if !stream_was_open { - set_detached_document_write_stream_open(scope, document, true); - } - let wrote = if stream_was_open { - append_detached_html_document_body_html(scope, runtime_ptr, handle, &html) - } else { - set_detached_html_document_body_html(scope, runtime_ptr, handle, &html) - }; - if !wrote && !stream_was_open { - set_detached_document_write_stream_open(scope, document, false); + unsafe { &mut *runtime_ptr }.prepare_windowless_document_replacement( + scope, + runtime_ptr, + handle, + ); } + let _ = unsafe { &mut *runtime_ptr }.write_windowless_document( + scope, + runtime_ptr, + handle, + &html, + ); rv.set_undefined(); return; } @@ -253,12 +252,12 @@ pub(in crate::native_bridge) fn node_document_open_callback<'s>( rv.set(args.this().into()); return; } - if detached_native_handle_for_runtime(scope, runtime_ptr, args.this()).is_some() { - let document = args.this(); - set_detached_document_write_stream_open(scope, document, true); - if !set_detached_html_document_body_html(scope, runtime_ptr, handle, "") { - set_detached_document_write_stream_open(scope, document, false); - } + if !document_has_browsing_context(unsafe { &*runtime_ptr }, handle) { + unsafe { &mut *runtime_ptr }.prepare_windowless_document_replacement( + scope, + runtime_ptr, + handle, + ); rv.set(args.this().into()); return; } @@ -280,6 +279,20 @@ fn clear_window_event_handlers(scope: &mut v8::PinScope<'_, '_>) { } impl JsContextHost { + fn prepare_windowless_document_replacement( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + host_ptr: *mut JsContextHost, + document: DomHandle, + ) { + self.clear_event_callbacks_for_document_replacement(document, false); + custom_elements::with_custom_element_reaction_scope(scope, host_ptr, |scope| { + let runtime = unsafe { &mut *host_ptr }; + runtime.remove_all_children_for_document_replacement(scope, host_ptr, document); + let _ = runtime.start_windowless_document_parser(document); + }); + } + fn prepare_root_document_replacement( &mut self, scope: &mut v8::PinScope<'_, '_>, @@ -376,8 +389,8 @@ pub(in crate::native_bridge) fn node_document_close_callback<'s>( ); return; } - if detached_native_handle_for_runtime(scope, runtime_ptr, args.this()).is_some() { - set_detached_document_write_stream_open(scope, args.this(), false); + if !document_has_browsing_context(unsafe { &*runtime_ptr }, handle) { + close_windowless_document(scope, runtime_ptr, handle, args.this()); rv.set_undefined(); return; } @@ -386,25 +399,55 @@ pub(in crate::native_bridge) fn node_document_close_callback<'s>( rv.set_undefined(); } -fn detached_document_write_stream_is_open<'s>( +fn close_windowless_document<'s>( scope: &mut v8::PinScope<'s, '_>, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, document: v8::Local<'s, v8::Object>, -) -> bool { - get_private_value(scope, document, DETACHED_DOCUMENT_WRITE_STREAM_OPEN_SLOT) - .is_some_and(|value| value.boolean_value(scope)) -} - -fn set_detached_document_write_stream_open( - scope: &mut v8::PinScope<'_, '_>, - document: v8::Local<'_, v8::Object>, - open: bool, ) { - set_private_value( - scope, - document, - DETACHED_DOCUMENT_WRITE_STREAM_OPEN_SLOT, - v8::Boolean::new(scope, open).into(), - ); + // Borrowing close() from another realm does not change the realm of the + // Document's lifecycle events. + let context = crate::native_bridge::node_relevant_context(scope, document) + .unwrap_or_else(|| scope.get_current_context()); + let scope = &mut v8::ContextScope::new(scope, context); + let Some(token) = + unsafe { &mut *runtime_ptr }.finish_windowless_document_parser(scope, runtime_ptr, handle) + else { + return; + }; + for (ready_state, event_type) in [ + (Some(DocumentReadyState::Interactive), "readystatechange"), + (None, "DOMContentLoaded"), + (Some(DocumentReadyState::Complete), "readystatechange"), + ] { + // A lifecycle listener can open a replacement stream on the same + // Document. The old parser must not complete that replacement. + if !unsafe { &*runtime_ptr }.windowless_document_parser_is_current(handle, &token) { + return; + } + if let Some(state) = ready_state { + unsafe { &mut *runtime_ptr } + .dom_host_mut() + .set_document_ready_state_for_handle(handle, state); + } + if let Ok(event) = crate::host::create_host_event( + scope, + event_type, + document.into(), + document.into(), + event_type == "DOMContentLoaded", + false, + ) { + let _ = unsafe { &mut *runtime_ptr }.dispatch_public_event_best_effort( + scope, + runtime_ptr, + EventTargetHandle::Node(handle), + event, + "windowless document lifecycle event", + ); + } + } + unsafe { &mut *runtime_ptr }.release_finished_windowless_document_parser(handle, &token); } fn detached_html_document_body_handle( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs index 7c982dd61..f9a642c81 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/detached.rs @@ -1,5 +1,21 @@ use super::*; +#[test] +fn windowless_documents_use_independent_incremental_parser_streams() { + let mut vm = new_storage_test_vm("https://windowless-document-stream.test/page.html"); + vm.eval( + "document.appendChild(document.createElement('html')).appendChild(document.createElement('body'));", + ) + .expect("the shared browser fixture needs an initial page body"); + let fixture = include_str!("../../../../tests/fixtures/windowless-document-stream.js"); + let result = vm + .eval(&format!("JSON.stringify({fixture})")) + .expect("windowless document stream regression probe"); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["failures"], serde_json::json!([]), "{result}"); + assert_eq!(result["checks"], 261); +} + #[test] fn document_clones_preserve_internal_metadata_and_url_resolution() { let mut vm = new_storage_test_vm("https://document-clone-metadata.test/path/page.html"); @@ -182,6 +198,7 @@ fn detached_document_write_preserves_existing_noscript_text() { (() => { const doc = document.implementation.createHTMLDocument(""); doc.open(); + doc.write(""); const noscript = doc.createElement("noscript"); noscript.textContent = "fallback&"; doc.body.append(noscript); diff --git a/moli-renderer-v8/tests/fixtures/windowless-document-stream.js b/moli-renderer-v8/tests/fixtures/windowless-document-stream.js new file mode 100644 index 000000000..f2a7d87dd --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/windowless-document-stream.js @@ -0,0 +1,159 @@ +(() => { + const EventConstructor = Event; + const mainBody = document.body; + const parser = new DOMParser(); + const parsed = () => parser.parseFromString('old

old

', 'text/html'); + const unsafe = () => Document.parseHTMLUnsafe('

old

'); + const sources = [ + ['implementation', document.implementation.createHTMLDocument('old'), false], + ['DOMParser', parsed(), false], + ['parseHTMLUnsafe', unsafe(), true], + ['live shallow clone', document.cloneNode(), true], + ['live deep clone', document.cloneNode(true), true], + ['DOMParser shallow clone', parsed().cloneNode(), false], + ['DOMParser deep clone', parsed().cloneNode(true), false], + ['unsafe shallow clone', unsafe().cloneNode(), true], + ['unsafe deep clone', unsafe().cloneNode(true), true], + ]; + const failures = []; + let checks = 0; + function equal(label, actual, expected) { + checks++; + if (actual !== expected) failures.push({label, actual, expected}); + } + for (const [name, doc, allowShadow] of sources) { + try { + const originalURL = doc.URL; + let erasedListenerCalls = 0; + doc.addEventListener('probe', () => erasedListenerCalls++); + equal(name + ' open receiver', doc.open() === doc, true); + equal(name + ' open clears all children', doc.childNodes.length, 0); + equal(name + ' open readyState', doc.readyState, 'loading'); + equal(name + ' open resets mode', doc.compatMode, 'CSS1Compat'); + equal(name + ' open keeps URL', doc.URL, originalURL); + doc.dispatchEvent(new EventConstructor('probe')); + equal(name + ' open erases listeners', erasedListenerCalls, 0); + const events = []; + for (const type of ['readystatechange', 'DOMContentLoaded', 'load']) { + doc.addEventListener(type, event => events.push([ + event.type, doc.readyState, event.bubbles, event.isTrusted, + event.target === doc, event.currentTarget === doc, + ])); + } + doc.write('written
light'); + equal(name + ' document title', doc.title, 'written'); + equal(name + ' document doctype', doc.doctype?.name, 'html'); + equal(name + ' write retains loading', doc.readyState, 'loading'); + equal(name + ' write has no lifecycle events yet', events.length, 0); + const host = doc.getElementById('host'); + const keep = doc.getElementById('keep'); + equal(name + ' declarative shadow root', host?.shadowRoot?.textContent ?? null, allowShadow ? 'shadow' : null); + equal(name + ' ordinary template when disabled', !!host?.querySelector('template'), !allowShadow); + let listenerCalls = 0; + keep.addEventListener('probe', () => listenerCalls++); + const observer = new MutationObserver(() => {}); + observer.observe(host, {childList: true}); + doc.write('tail
'); + const tail = doc.getElementById('tail'); + equal(name + ' streamed token', tail?.textContent, 'tail'); + equal(name + ' tree builder insertion point', tail?.parentNode === host, true); + equal(name + ' existing node identity', doc.getElementById('keep') === keep, true); + keep.dispatchEvent(new EventConstructor('probe')); + equal(name + ' existing listener identity', listenerCalls, 1); + equal(name + ' parser insert is observable', observer.takeRecords().some(record => Array.from(record.addedNodes).includes(tail)), true); + observer.disconnect(); + const priorEvent = globalThis.Event; + globalThis.Event = function() { throw new Error('author Event constructor called'); }; + try { doc.close(); } finally { globalThis.Event = priorEvent; } + equal(name + ' close readiness', doc.readyState, 'complete'); + equal(name + ' close lifecycle', JSON.stringify(events), JSON.stringify([ + ['readystatechange', 'interactive', false, true, true, true], + ['DOMContentLoaded', 'interactive', true, true, true, true], + ['readystatechange', 'complete', false, true, true, true], + ])); + const closedRoot = doc.documentElement; + doc.close(); + equal(name + ' repeated close keeps tree', doc.documentElement === closedRoot, true); + equal(name + ' repeated close has no events', events.length, 3); + doc.writeln('

new

'); + equal(name + ' implicit open replaces previous tree', doc.getElementById('keep'), null); + equal(name + ' implicit open creates body', doc.body?.textContent, 'new\n'); + equal(name + ' no doctype enters quirks mode', doc.compatMode, 'BackCompat'); + doc.close(); + equal(name + ' implicit open clears previous listeners', events.length, 3); + doc.open(); + doc.write('