fix(dom): preserve windowless document parser streams

This commit is contained in:
ldm0
2026-09-24 20:01:12 +08:00
parent 32dfc83ff4
commit b8d2500ceb
17 changed files with 552 additions and 64 deletions
@@ -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
+11
View File
@@ -50,6 +50,7 @@ pub struct Document {
url: Url,
content_type: Box<str>,
character_set: Box<str>,
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<String>) {
self.character_set = character_set.into().into_boxed_str();
}
+13
View File
@@ -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,
+39 -2
View File
@@ -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<T>(&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<T>(
&self,
chunk: &str,
+15 -2
View File
@@ -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,
)
}
+1
View File
@@ -745,6 +745,7 @@ pub(super) struct DocumentRuntime {
destructive_write_counters: destructive_writes::DocumentWriteCounters,
document_unload_counters: destructive_writes::DocumentWriteCounters,
root_document_parser: Option<DocumentParserSession>,
windowless_document_parsers: HashMap<DomHandle, document_write::WindowlessDocumentParserState>,
post_parse_schedule_invalidated: bool,
stylesheet_lifecycle: StylesheetLifecycleState,
main_parser_continuation: main_parser_continuation::MainParserContinuationState,
@@ -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);
}
}
@@ -0,0 +1,128 @@
use super::*;
use std::rc::Rc;
pub(in crate::document_runtime) struct WindowlessDocumentParserState {
stream: Option<DocumentStream>,
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<Rc<()>> {
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);
}
}
}
@@ -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: _,
@@ -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);
@@ -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);
}
@@ -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;
@@ -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)
}
@@ -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::<v8::Object>::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);
}
@@ -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(
@@ -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("<body>");
const noscript = doc.createElement("noscript");
noscript.textContent = "<em>fallback&</em>";
doc.body.append(noscript);
@@ -0,0 +1,159 @@
(() => {
const EventConstructor = Event;
const mainBody = document.body;
const parser = new DOMParser();
const parsed = () => parser.parseFromString('<title>old</title><p>old</p>', 'text/html');
const unsafe = () => Document.parseHTMLUnsafe('<p>old</p>');
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('<!doctype html><html><head><title>written</title></head><body><section id="host"><template shadowrootmode="open"><span>shadow</span></template><b id="keep">light</b>');
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('<i id="ta');
doc.write('il">tail</i></section>');
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('<p id="replacement">new</p>');
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('<script>globalThis.__windowlessStreamScriptRan = true;</scr' + 'ipt>');
doc.close();
equal(name + ' scripts remain inert', globalThis.__windowlessStreamScriptRan, undefined);
equal(name + ' main Document unchanged', document.body === mainBody, true);
equal(name + ' no browsing context', doc.defaultView, null);
} catch (error) {
equal(name + ' unexpected exception', error.name + ': ' + error.message, null);
}
}
try {
const a = document.implementation.createHTMLDocument('');
const b = document.implementation.createHTMLDocument('');
a.write('<div id="a">');
b.write('<div id="b">');
const rootA = a.getElementById('a');
const rootB = b.getElementById('b');
a.write('<b>A</b>');
b.write('<i>B</i>');
a.close();
b.close();
equal('independent parser A', rootA?.firstChild?.textContent, 'A');
equal('independent parser B', rootB?.firstChild?.textContent, 'B');
equal('independent node identities', a.getElementById('a') === rootA && b.getElementById('b') === rootB, true);
const doc = document.implementation.createHTMLDocument('');
doc.open();
let oldDOMContentLoaded = 0;
doc.addEventListener('DOMContentLoaded', () => oldDOMContentLoaded++);
doc.addEventListener('readystatechange', () => {
if (doc.readyState === 'interactive') {
doc.open();
doc.write('<p id="new-stream">replacement</p>');
}
});
doc.write('<p>old stream</p>');
doc.close();
equal('reentrant open retains replacement parser', doc.readyState, 'loading');
equal('reentrant open retains replacement content', doc.getElementById('new-stream')?.textContent, 'replacement');
equal('old close does not dispatch after replacement', oldDOMContentLoaded, 0);
doc.close();
equal('replacement can close independently', doc.readyState, 'complete');
doc.open();
doc.close();
equal('empty stream produces document structure', doc.documentElement?.localName + '/' + doc.head?.localName + '/' + doc.body?.localName, 'html/head/body');
} catch (error) {
equal('stream lifecycle unexpected exception', error.name + ': ' + error.message, null);
}
try {
const frame = document.body.appendChild(document.createElement('iframe'));
try {
const other = frame.contentWindow;
const doc = other.document.implementation.createHTMLDocument('');
Document.prototype.open.call(doc);
const events = [];
for (const type of ['readystatechange', 'DOMContentLoaded']) {
doc.addEventListener(type, event => events.push([
event.type, event instanceof other.Event, event instanceof Event,
]));
}
Document.prototype.write.call(doc, '<p>content</p>');
Document.prototype.close.call(doc);
equal('lifecycle events use the Document realm', JSON.stringify(events), JSON.stringify([
['readystatechange', true, false],
['DOMContentLoaded', true, false],
['readystatechange', true, false],
]));
} finally {
frame.remove();
}
} catch (error) {
equal('cross-realm lifecycle unexpected exception', error.name + ': ' + error.message, null);
}
return {checks, failures};
})()