From 4f79a50468f97388bb0e5c2ef197f4dd2b4db29a Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 31 Aug 2026 07:46:07 +0800 Subject: [PATCH] fix(dom): bind DOMParser to its relevant document --- .../mutation_commands/tree/detached.rs | 13 +- moli-renderer-v8/src/dom_parser.rs | 110 ++++++++++-- .../document/detached_objects/mutation.rs | 4 +- .../script_vm/tests/dom_xhr/dom/dom_parser.rs | 156 ++++++++++++++++++ 4 files changed, 263 insertions(+), 20 deletions(-) diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs b/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs index f94323bd2c..5945986ebb 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs @@ -111,6 +111,14 @@ impl DocumentRuntime { host_ptr: *mut JsContextHost, roots: &[DomHandle], ) { + let iframe_roots = roots + .iter() + .copied() + .filter(|root| self.dom_host.is_html_element_named(*root, "iframe")) + .collect::>(); + if iframe_roots.is_empty() { + return; + } let Some(event_ctor) = scope .get_current_context() .global(scope) @@ -119,10 +127,7 @@ impl DocumentRuntime { else { return; }; - for &root in roots { - if !self.dom_host.is_html_element_named(root, "iframe") { - continue; - } + for root in iframe_roots { let Some(event) = event_ctor.new_instance(scope, &[v8str(scope, "load").into()]) else { continue; }; diff --git a/moli-renderer-v8/src/dom_parser.rs b/moli-renderer-v8/src/dom_parser.rs index d764b479d0..f7cb36959b 100644 --- a/moli-renderer-v8/src/dom_parser.rs +++ b/moli-renderer-v8/src/dom_parser.rs @@ -4,20 +4,28 @@ use moli_webapi_declare::WebApiFunctionTemplate; use url::Url; use crate::{ + document_runtime::DomHandle, dom::native::{DomHost, NativeDom, NativeNodeId}, parser::{HtmlParser, XmlParser}, webidl, }; use super::{ - native_bridge::document::{ - build_detached_document_object_from_dom_host, - build_detached_document_object_from_dom_host_with_content_type, + native_bridge::{ + OwnerDispatchScope, + document::{ + build_detached_document_object_from_dom_host, + build_detached_document_object_from_dom_host_with_content_type, + }, + }, + util::{ + context_host_ptr_from_global_bridge, get_private_object, get_private_value, + set_private_value, throw_type_error, }, - util::{context_host_ptr_from_global_bridge, get_private_object, throw_type_error}, }; pub(crate) const DOM_PARSER_FOREIGN_NODE_SLOT: &str = "__moliDomParserForeignNode"; +const DOM_PARSER_DOCUMENT_HANDLE_SLOT: &str = "__moliDomParserDocumentHandle"; const HTML_NAMESPACE: &str = "http://www.w3.org/1999/xhtml"; const PARSER_ERROR_STYLE: &str = "display: block; white-space: pre; border: 2px solid #c77; padding: 0 1em 0 1em; margin: 1em; background-color: #fdd; color: black"; const PARSER_ERROR_DETAIL_STYLE: &str = "font-family:monospace;font-size:12px"; @@ -118,7 +126,21 @@ pub(super) fn dom_parser_constructor_callback( throw_type_error(scope, "DOMParser constructor must be called with new"); return; } - rv.set(args.this().into()); + let parser = args.this(); + let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { + throw_type_error(scope, "DOMParser constructor has no associated Document"); + return; + }; + let runtime = unsafe { &*host_ptr }; + let document_handle = dom_parser_constructor_document_handle(scope, runtime); + let handle_value = v8::BigInt::new_from_u64(scope, document_handle.index() as u64); + set_private_value( + scope, + parser, + DOM_PARSER_DOCUMENT_HANDLE_SLOT, + handle_value.into(), + ); + rv.set(parser.into()); } pub(super) fn dom_parser_parse_from_string_callback<'s>( @@ -126,6 +148,13 @@ pub(super) fn dom_parser_parse_from_string_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + let Some(document_handle) = dom_parser_document_handle(scope, args.this()) else { + throw_type_error( + scope, + "Failed to execute 'parseFromString' on 'DOMParser': Illegal invocation.", + ); + return; + }; let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; @@ -150,14 +179,53 @@ pub(super) fn dom_parser_parse_from_string_callback<'s>( source } }; - let Some(obj) = parse_detached_document_from_string(scope, &source, parsed.mime.as_mime()) - else { + let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { + rv.set(v8::null(scope).into()); + return; + }; + let document_url = unsafe { &*host_ptr }.document_url_for_handle(document_handle); + let Some(obj) = parse_detached_document_from_string_with_url( + scope, + document_url, + &source, + parsed.mime.as_mime(), + ) else { rv.set(v8::null(scope).into()); return; }; rv.set(obj.into()); } +fn dom_parser_constructor_document_handle( + scope: &mut v8::PinScope<'_, '_>, + runtime: &super::native_bridge::JsContextHost, +) -> DomHandle { + let context = scope.get_current_context(); + let Some(identity) = runtime.window_execution_context_identity_for_v8_context(scope, context) + else { + return runtime.document_handle(); + }; + match identity.dispatch_scope() { + OwnerDispatchScope::Top => runtime.document_handle(), + OwnerDispatchScope::Child(handle) => runtime + .child_browsing_context_document_handle(handle) + .unwrap_or_else(|| runtime.document_handle()), + OwnerDispatchScope::LightweightPopup(popup_id) => runtime + .lightweight_popup_document_handle(popup_id) + .unwrap_or_else(|| runtime.document_handle()), + } +} + +fn dom_parser_document_handle<'s>( + scope: &mut v8::PinScope<'s, '_>, + parser: v8::Local<'s, v8::Object>, +) -> Option { + let value = get_private_value(scope, parser, DOM_PARSER_DOCUMENT_HANDLE_SLOT)?; + let value = v8::Local::::try_from(value).ok()?; + let (index, lossless) = value.u64_value(); + lossless.then(|| DomHandle::new(index as usize)) +} + pub(crate) fn install_dom_parser_template_bindings<'s>( scope: &mut v8::PinScope<'s, '_, ()>, template: v8::Local<'s, v8::FunctionTemplate>, @@ -185,16 +253,32 @@ pub(super) fn parse_detached_document_from_string<'s>( let host_ptr = context_host_ptr_from_global_bridge(scope)?; let runtime = unsafe { &*host_ptr }; + parse_detached_document_from_string_with_url( + scope, + runtime.document_url().clone(), + source, + mime, + ) +} + +fn parse_detached_document_from_string_with_url<'s>( + scope: &mut v8::PinScope<'s, '_>, + document_url: Url, + source: &str, + mime: &str, +) -> Option> { + let is_html = is_html_document_mime(mime); + let is_xml = is_dom_parser_xml_mime(mime); + if !is_html && !is_xml { + return None; + } + if is_html { - return parse_detached_html_document_from_source( - scope, - runtime.document_url().clone(), - source, - ); + return parse_detached_html_document_from_source(scope, document_url, source); } let parser = XmlParser; - let parsed = parser.parse(runtime.document_url().clone(), source.to_owned()); + let parsed = parser.parse(document_url, source.to_owned()); let parsed = if parsed.parse_errors().is_empty() && native_document_has_element_child(&parsed) { parsed } else { diff --git a/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs b/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs index 378cd1708a..7f1662b292 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs @@ -141,9 +141,7 @@ fn define_detached_attribute_maps_for_live_element<'s>( namespace_attributes.into(), ); - if let Some(snapshot) = - live_native_attribute_snapshot(scope, element).filter(|snapshot| !snapshot.is_empty()) - { + if let Some(snapshot) = live_native_attribute_snapshot(scope, element) { for attribute in snapshot { detached_map_set(scope, attributes, &attribute.name, &attribute.value); if attribute.namespace_uri.is_some() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/dom_parser.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/dom_parser.rs index 8feca89026..df148ca68e 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/dom_parser.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/dom_parser.rs @@ -96,6 +96,162 @@ fn dom_parser_prototype_parse_from_string_is_declared_operation() { ); } +#[test] +fn dom_parser_uses_its_constructor_realm_associated_document() { + let mut vm = new_storage_test_vm("https://dom-parser-realm.test/top.html"); + + let result = vm + .eval( + r#" +(() => { + const frame = document.createElement('iframe'); + (document.body || document.documentElement || document).appendChild(frame); + const child = frame.contentWindow; + child.history.replaceState(null, '', '/child-v1.html'); + + const topParser = new DOMParser(); + const childParser = new child.DOMParser(); + const childParserWithTopNewTarget = Reflect.construct( + child.DOMParser, + [], + DOMParser + ); + const topParserWithChildNewTarget = Reflect.construct( + DOMParser, + [], + child.DOMParser + ); + const parseUrl = parser => + DOMParser.prototype.parseFromString.call( + parser, + '', + 'text/html' + ).URL; + const beforeSameDocumentUpdate = parseUrl(childParser); + child.history.replaceState(null, '', '/child-v2.html'); + + let invalidReceiver; + try { + DOMParser.prototype.parseFromString.call({}, '', 'text/html'); + invalidReceiver = 'no-throw'; + } catch (error) { + invalidReceiver = error.name; + } + + return JSON.stringify({ + top: parseUrl(topParser), + child: parseUrl(childParser), + childWithTopNewTarget: parseUrl(childParserWithTopNewTarget), + topWithChildNewTarget: child.DOMParser.prototype.parseFromString.call( + topParserWithChildNewTarget, + '', + 'text/html' + ).URL, + beforeSameDocumentUpdate, + invalidReceiver + }); +})() +"#, + ) + .expect("DOMParser relevant realm probe should evaluate"); + + assert_eq!( + result, + r#"{"top":"https://dom-parser-realm.test/top.html","child":"https://dom-parser-realm.test/child-v2.html","childWithTopNewTarget":"https://dom-parser-realm.test/child-v2.html","topWithChildNewTarget":"https://dom-parser-realm.test/top.html","beforeSameDocumentUpdate":"https://dom-parser-realm.test/child-v1.html","invalidReceiver":"TypeError"}"# + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn dom_parser_retained_across_child_navigation_uses_original_document() { + const HOST: &str = "dom-parser-retained.test"; + + let server = StaticHttpServer::spawn(2).await; + let top_url = server.url_for_host(HOST, "/top.html"); + let loader = static_http_loader([server.resolve_entry(HOST)]); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(top_url.as_str(), &loader); + + vm.eval( + r#" +(() => { + const frame = document.createElement('iframe'); + globalThis.__domParserRetainedLoadCount = 0; + frame.onload = () => { globalThis.__domParserRetainedLoadCount += 1; }; + frame.src = '/child.html?1'; + (document.body || document.documentElement || document).appendChild(frame); + globalThis.__domParserRetainedFrame = frame; +})() +"#, + ) + .expect("retained DOMParser initial navigation should evaluate"); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(globalThis.__domParserRetainedLoadCount)", + "1", + "initial same-origin DOMParser child should load", + ) + .await; + + vm.eval( + r#" +(() => { + const frame = globalThis.__domParserRetainedFrame; + const child = frame.contentWindow; + const parser = new child.DOMParser(); + globalThis.__domParserRetainedParser = parser; + globalThis.__domParserRetainedMethod = child.DOMParser.prototype.parseFromString; + globalThis.__domParserRetainedBoundMethod = parser.parseFromString.bind(parser); + globalThis.__domParserRetainedUrlBeforeNavigation = child.document.URL; + frame.src = '/child.html?2'; +})() +"#, + ) + .expect("retained DOMParser navigation setup should evaluate"); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(globalThis.__domParserRetainedLoadCount)", + "2", + "replacement same-origin DOMParser child should load", + ) + .await; + + let result = vm + .eval( + r#" +(() => { + const probe = callback => { + try { + return callback(); + } catch (error) { + return `error:${error.name}:${error.message}`; + } + }; + const parser = globalThis.__domParserRetainedParser; + const source = ''; + const oldUrl = globalThis.__domParserRetainedUrlBeforeNavigation; + return JSON.stringify({ + property: probe(() => typeof parser.parseFromString), + topCallUsesOldUrl: probe(() => DOMParser.prototype.parseFromString.call(parser, source, 'text/html').URL === oldUrl), + savedCallUsesOldUrl: probe(() => __domParserRetainedMethod.call(parser, source, 'text/html').URL === oldUrl), + boundCallUsesOldUrl: probe(() => __domParserRetainedBoundMethod(source, 'text/html').URL === oldUrl), + usesReplacementPrototype: probe(() => Object.getPrototypeOf(parser) === DOMParser.prototype) + }); +})() +"#, + ) + .expect("retained DOMParser probe should evaluate"); + + assert_eq!( + result, + r#"{"property":"function","topCallUsesOldUrl":true,"savedCallUsesOldUrl":true,"boundCallUsesOldUrl":true,"usesReplacementPrototype":false}"# + ); + assert_eq!( + server.finish_targets().await, + vec!["/child.html?1", "/child.html?2"] + ); +} + #[test] fn dom_parser_xml_text_content_excludes_processing_instruction_descendants() { let mut vm = new_storage_test_vm("https://dom-parser-xml-text-content.test/");