diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 6b45c2a6f1..978cae5c7d 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2973,7 +2973,6 @@ html/webappapis/scripting/processing-model-2/integration-with-the-javascript-job html/webappapis/scripting/processing-model-2/integration-with-the-javascript-job-queue/promise-job-incumbent.html html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-rejection-event-during-parse.html html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-rejection-events.html -html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-resolution-order.html inert/inert-and-find-flat-tree.html inert/inert-and-find.html inert/inert-on-non-html.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 787cdde829..ca298ba25c 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -7654,6 +7654,7 @@ html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promis html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-rejection-events-onerror.html html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-rejection-events.dedicatedworker.html html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-rejection-events.sharedworker.html +html/webappapis/scripting/processing-model-2/unhandled-promise-rejections/promise-resolution-order.html html/webappapis/scripting/processing-model-2/window-onerror-parse-error.html html/webappapis/scripting/processing-model-2/window-onerror-runtime-error-throw.html html/webappapis/scripting/processing-model-2/window-onerror-runtime-error.html diff --git a/moli-dom/src/native/host/mutation/attributes.rs b/moli-dom/src/native/host/mutation/attributes.rs index 2978c38551..40756cc0fe 100644 --- a/moli-dom/src/native/host/mutation/attributes.rs +++ b/moli-dom/src/native/host/mutation/attributes.rs @@ -188,14 +188,17 @@ impl DomHost { let source_attribute = self.node(handle).and_then(Node::as_element).is_some_and( |element| match element.namespace() { "http://www.w3.org/1999/xhtml" => name.eq_ignore_ascii_case("src"), - "http://www.w3.org/2000/svg" => name.eq_ignore_ascii_case("href"), + "http://www.w3.org/2000/svg" => { + name.eq_ignore_ascii_case("href") + && prior_value.as_deref().is_none_or(str::is_empty) + && !value.is_empty() + } _ => false, }, ); - if source_attribute - && prior_value.as_deref().is_none_or(str::is_empty) - && !value.is_empty() - { + // Every HTML src assignment invokes preparation, including + // an empty value. The loader owns the already-started guard. + if source_attribute { effects.mark_script_prepare_trigger( handle, ScriptPrepareTriggerKind::SourceAttributeAdded, diff --git a/moli-renderer-v8/src/host/scripts.rs b/moli-renderer-v8/src/host/scripts.rs index 3e5475efc3..c54042d9be 100644 --- a/moli-renderer-v8/src/host/scripts.rs +++ b/moli-renderer-v8/src/host/scripts.rs @@ -16,11 +16,13 @@ use crate::page_task_queue::{ PageTaskSender, PostParseLifecycleWork, RendererPageMainDocumentRuntimeProducer, WindowScriptFailureReportTask, }; +#[cfg(test)] +use crate::planning::ScriptSource; use crate::{ dom::{NodeId, native::NativeNodeId}, { module_runtime::ModuleOwnerState, - planning::{PreparedScript, ScriptSource}, + planning::PreparedScript, types::{ScriptErrorValue, ScriptKind, ScriptMode, ScriptSourceKind}, }, }; @@ -41,6 +43,7 @@ pub(crate) use loader::*; pub(crate) use runtime::*; #[derive(Debug)] +#[cfg_attr(not(test), derive(Default))] pub(crate) struct HostScriptScheduler { #[cfg(test)] pending_dynamic_in_order_scripts: VecDeque, @@ -59,6 +62,7 @@ pub(crate) struct HostScriptScheduler { native_module_owner_event_turn_queued: bool, module_owner: ModuleOwnerState, script_handles: HashMap, + #[cfg(test)] next_virtual_script_node_index: usize, next_dynamic_script_position: usize, } @@ -285,6 +289,7 @@ pub(crate) enum QueuedScriptFailureKind { ModuleTopLevelLoad, } +#[cfg(test)] impl Default for HostScriptScheduler { fn default() -> Self { Self { @@ -305,6 +310,7 @@ impl Default for HostScriptScheduler { native_module_owner_event_turn_queued: false, module_owner: ModuleOwnerState::default(), script_handles: HashMap::new(), + #[cfg(test)] next_virtual_script_node_index: 1_000_000, next_dynamic_script_position: 0, } @@ -1324,6 +1330,7 @@ impl HostScriptScheduler { self.main_document_completion_recheck_turn_queued = false; } + #[cfg(test)] fn enqueue_script_event_lifecycle_work(&mut self, kind: ScriptEventKind, handle: &str) { if let Some(work) = self.plan_script_event_lifecycle_work(kind, handle) { self.enqueue_post_parse_lifecycle_work(work); @@ -1555,6 +1562,7 @@ impl HostScriptScheduler { Ok(()) } + #[cfg(test)] fn prepare_failed_dynamic_script( &mut self, preparation: &RuntimeScriptPreparationContext, @@ -1611,6 +1619,7 @@ impl HostScriptScheduler { }) } + #[cfg(test)] fn queued_script_failure_kind( kind: ScriptKind, source_kind: ScriptSourceKind, @@ -1639,6 +1648,7 @@ impl HostScriptScheduler { } } + #[cfg(test)] fn next_virtual_node_id(&mut self) -> NodeId { let node_id = NodeId::new(self.next_virtual_script_node_index); self.next_virtual_script_node_index += 1; diff --git a/moli-renderer-v8/src/host/scripts/runtime.rs b/moli-renderer-v8/src/host/scripts/runtime.rs index e3c4696e43..f87b584351 100644 --- a/moli-renderer-v8/src/host/scripts/runtime.rs +++ b/moli-renderer-v8/src/host/scripts/runtime.rs @@ -1,7 +1,9 @@ +#[cfg(test)] +use super::ScriptEventKind; use super::{ HostScriptScheduler, PreparedScriptElementStart, RuntimeScriptPreparationContext, - RuntimeScriptStartDecision, ScriptElementLoader, ScriptElementLoaderOptions, ScriptEventKind, - ScriptEventTask, ScriptHandleSource, ScriptStartCommitKind, + RuntimeScriptStartDecision, ScriptElementLoader, ScriptElementLoaderOptions, ScriptEventTask, + ScriptHandleSource, ScriptStartCommitKind, }; #[cfg(test)] use crate::types::ScriptSourceKind; @@ -37,14 +39,12 @@ impl RuntimeScriptStartPlan { matches!( self.prepared.decision, RuntimeScriptStartDecision::Queue { .. } - | RuntimeScriptStartDecision::QueueFailed { .. } ) } pub(crate) fn load_delay_kind(&self) -> Option { let kind = match &self.prepared.decision { - RuntimeScriptStartDecision::Queue { kind, .. } - | RuntimeScriptStartDecision::QueueFailed { kind, .. } => *kind, + RuntimeScriptStartDecision::Queue { kind, .. } => *kind, _ => return None, }; Some(if kind == crate::types::ScriptKind::Module { @@ -65,6 +65,9 @@ pub(crate) struct RuntimeScriptStartReservation { #[derive(Debug)] pub(crate) enum PreparedRuntimeScriptStartCommit { Noop, + PreparationError { + node: NativeNodeId, + }, InlineClassic { node: NativeNodeId, host_script_handle: String, @@ -441,10 +444,7 @@ pub(crate) fn prepare_runtime_script_start_commit( &host_script_handle, ScriptStartCommitKind::RejectImportMap, ) { - scripts.enqueue_script_event_lifecycle_work( - ScriptEventKind::Error, - &host_script_handle, - ); + return Ok(PreparedRuntimeScriptStartCommit::PreparationError { node }); } Ok(PreparedRuntimeScriptStartCommit::Noop) } @@ -478,38 +478,21 @@ pub(crate) fn prepare_runtime_script_start_commit( payload: Box::new(RuntimeScriptAdmissionPayload::Script(script)), }) } - RuntimeScriptStartDecision::QueueFailed { - source, - kind, - mode, - source_kind, - message, - } => { - let node_id = scripts.next_virtual_node_id(); - let failed = match scripts.prepare_failed_dynamic_script( - &preparation, - node_id, + RuntimeScriptStartDecision::QueueFailed { .. } => { + // URL preparation failed before fetching or load-delay admission. + // Commit already-started now; the caller queues the element error + // in the DOM-manipulation FIFO at this insertion boundary. + if finish_local_runtime_script_start( + dom_host, + scripts, + node, &host_script_handle, - &source, - source_kind, - kind, - mode, - &message, + ScriptStartCommitKind::QueueFailed, ) { - Ok(failed) => failed, - Err(error) => { - scripts.cancel_script_start(&host_script_handle, node); - return Err(error); - } - }; - Ok(PreparedRuntimeScriptStartCommit::Admission { - reservation: RuntimeScriptStartReservation { - node, - host_script_handle, - commit_kind: ScriptStartCommitKind::QueueFailed, - }, - payload: Box::new(RuntimeScriptAdmissionPayload::Failed(failed)), - }) + Ok(PreparedRuntimeScriptStartCommit::PreparationError { node }) + } else { + Ok(PreparedRuntimeScriptStartCommit::Noop) + } } } } diff --git a/moli-renderer-v8/src/mutation_coordinator.rs b/moli-renderer-v8/src/mutation_coordinator.rs index e96dbea315..e4a312a402 100644 --- a/moli-renderer-v8/src/mutation_coordinator.rs +++ b/moli-renderer-v8/src/mutation_coordinator.rs @@ -510,9 +510,18 @@ impl MutationCoordinator { ), } } - RuntimeScriptStartDecision::RegisterImportMap { .. } - | RuntimeScriptStartDecision::RejectExternalImportMap - | RuntimeScriptStartDecision::QueueFailed { .. } => {} + RuntimeScriptStartDecision::RejectExternalImportMap + | RuntimeScriptStartDecision::QueueFailed { .. } => { + let _ = dom_host.set_script_already_started(node, true); + if !unsafe { &mut *host_ptr }.queue_script_preparation_error(scope, node) { + tracing::debug!( + node = ?node, + owner_document_handle = ?owner_document_handle, + "child script preparation error route rejected the element task" + ); + } + } + RuntimeScriptStartDecision::RegisterImportMap { .. } => {} } } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/main_document_lifecycle.rs b/moli-renderer-v8/src/native_bridge/context_host/main_document_lifecycle.rs index 88ee60598b..0e0dc32a02 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/main_document_lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/main_document_lifecycle.rs @@ -89,6 +89,15 @@ impl JsContextHost { }; match prepared { + PreparedRuntimeScriptStartCommit::PreparationError { node } => { + debug_assert!(load_delay_binding.is_none()); + if !self.queue_script_preparation_error(scope, node) { + return Err( + "script preparation error route rejected the element task".to_owned() + ); + } + Ok(None) + } PreparedRuntimeScriptStartCommit::Noop => { if let Some(binding) = load_delay_binding { let _ = self.release_main_document_script_load_delay(binding); diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/script_preparation_error.rs b/moli-renderer-v8/src/runtime/page_vm/tests/script_preparation_error.rs index 9ea08b7b0f..a1acee39ca 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/script_preparation_error.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/script_preparation_error.rs @@ -24,6 +24,112 @@ fn queue_error(page_vm: &mut PageVm, element_id: &str) -> anyhow::Result<()> { page_vm.vm_mut().queue_script_preparation_error(element) } +#[tokio::test(flavor = "current_thread")] +async fn dynamic_script_preparation_errors_enter_dom_fifo_at_insertion() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; + for child in [false, true] { + let (mut page_vm, _, _) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, Url::parse("https://example.com/dynamic-script-errors")?, + ); + if child { + page_vm.vm_mut().eval(r#" + globalThis.errorFrame = document.createElement('iframe'); + document.body.append(errorFrame); + void errorFrame.contentWindow; + "#)?; + assert!(page_vm.run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::ChildRealmMaterialization, &loader, + ).await?); + } + page_vm.vm_mut().eval(r#" + globalThis.events = []; + globalThis.details = []; + globalThis.exceptions = []; + const target = globalThis.errorFrame?.contentWindow || window; + const targetDocument = target.document; + target.addEventListener('error', event => { + if (event.target === target) exceptions.push(event.message); + }); + onunhandledrejection = event => { + events.push('rejection'); + event.preventDefault(); + }; + globalThis.appendInvalid = (id, src, type = '', ordered = false, connected = false) => { + const element = targetDocument.createElement('script'); + element.id = id; + element.type = type; + if (ordered) element.async = false; + element.onerror = event => { + events.push(id); + details.push([event instanceof target.Event, event instanceof target.ErrorEvent, + event.isTrusted, event.bubbles, event.cancelable, event.composed, + event.target === element, event.currentTarget === element]); + Promise.resolve().then(() => events.push('microtask:' + id)); + // Preparation failure still consumes already-started, including in a child. + element.src = 'http://['; + element.remove(); + targetDocument.body.append(element); + }; + if (connected) targetDocument.body.append(element); + element.src = src; + if (!connected) targetDocument.body.append(element); + return element; + }; + appendInvalid('first', '').remove(); + Promise.reject('sentinel'); + "#)?; + page_vm.vm_mut().eval(r#" + appendInvalid('whitespace', ' '); + appendInvalid('invalid', 'http://['); + appendInvalid('module-empty', '', 'module'); + appendInvalid('module-invalid', 'http://[', 'module'); + appendInvalid('ordered', 'http://[', '', true); + appendInvalid('importmap', 'data:application/json,{}', 'importmap'); + appendInvalid('connected', '', '', false, true); + "#)?; + assert_eq!(page_vm.vm_mut().eval("JSON.stringify(events)")?, "[]", + "insertion queues errors without dispatching them: child={child}"); + let mut expected = Vec::new(); + for id in ["first", "rejection", "whitespace", "invalid", "module-empty", + "module-invalid", "ordered", "importmap", "connected"] { + let family = if id == "rejection" { + PageDomManipulationTestFamily::PromiseRejection + } else { + PageDomManipulationTestFamily::ScriptPreparationError + }; + assert!(page_vm.run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::DomManipulation(family), &loader, + ).await?, "expected DOM FIFO head {id}: child={child}"); + expected.push(id.to_owned()); + if id != "rejection" { + expected.push(format!("microtask:{id}")); + } + assert_eq!(page_vm.vm_mut().eval("events.join('|')")?, expected.join("|"), + "each task finishes its listeners and reactions: child={child}"); + } + let details: serde_json::Value = serde_json::from_str( + &page_vm.vm_mut().eval("JSON.stringify(details)")?, + )?; + assert_eq!(details, serde_json::json!(vec![ + [true, false, true, false, false, false, true, true]; 8 + ]), "trusted plain Events belong to the element's realm: child={child}"); + assert_eq!(page_vm.vm_mut().eval("JSON.stringify(exceptions)")?, "[]"); + assert!(!page_vm.has_ready_dom_manipulation_task_for_test(), + "changing src and reinserting already-started failures must not queue more errors"); + assert!(!page_vm.run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MainDocumentRuntime( + PageMainDocumentRuntimeActionKind::RuntimeScriptAdmission, + ), &loader, + ).await?, "preparation failures must not enter runtime script admission"); + assert!(!page_vm.run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::ChildClassicScriptSourceLoad, &loader, + ).await?, "child preparation failures must not start a script fetch"); + } + Ok::<_, anyhow::Error>(()) + }).await.expect("dynamic script preparation error admission"); +} + #[tokio::test(flavor = "current_thread")] async fn script_preparation_errors_share_dom_fifo_and_complete_listener_reactions() { run_page_vm_async_test(async move {