fix(script): queue dynamic preparation errors at insertion

This commit is contained in:
ldm0
2026-09-23 00:11:56 +08:00
parent c6e8d34cf2
commit 8f3e307976
8 changed files with 169 additions and 49 deletions
@@ -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
@@ -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
@@ -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,
+11 -1
View File
@@ -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<PreparedScript>,
@@ -59,6 +62,7 @@ pub(crate) struct HostScriptScheduler {
native_module_owner_event_turn_queued: bool,
module_owner: ModuleOwnerState,
script_handles: HashMap<String, ScriptHandleState>,
#[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;
+22 -39
View File
@@ -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<MainDocumentScriptLoadDelayKind> {
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)
}
}
}
}
+12 -3
View File
@@ -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 { .. } => {}
}
}
}
@@ -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);
@@ -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 {