fix(html): schedule refresh mutations in loaded child documents

Route native meta insertion and attribute mutation candidates by their owning
document, instead of limiting mutation refresh handling to the top document.
Loaded child documents now register adopted or inserted refresh elements using
their own URL, sandbox policy and exact document timer owner. Ignore shadow-tree
candidates and retain the existing deadline for identical refresh candidates.

Use the active runtime borrow directly while registering child timers and create
the native callback in the child Window realm. Initial load capture remains the
fallback for child documents that are still loading.

Add deterministic source-first/source-last sandbox adoption tests and post-load
insertion and shadow-tree controls. The old frozen Release also reproduces the
related allow-scripts-flag-changing-1 WPT flake (7 pass/3 timeout in10 rounds);
the new source-last adoption and post-load insertion tests fail before this fix.

Validation:
- 13 related child meta refresh tests pass
- cargo fmt --all
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo nextest run --no-fail-fast
This commit is contained in:
ldm0
2026-10-18 01:07:41 +08:00
parent bec88e11cf
commit 6faf8d3fed
4 changed files with 196 additions and 24 deletions
@@ -7,6 +7,11 @@ pub(crate) struct MetaRefreshNavigation {
pub(crate) url: Url,
}
pub(super) struct MetaRefreshMutationCandidate {
pub(super) document: DomHandle,
pub(super) navigation: MetaRefreshNavigation,
}
#[derive(Debug)]
pub(crate) struct ScheduledMetaRefreshNavigation {
pub(crate) owner: FrameDocumentTaskOwner,
@@ -231,7 +236,7 @@ pub(super) fn meta_refresh_navigations_from_mutation(
dom_host: &DomHost,
effects: &DomMutationEffects,
document_url: &Url,
) -> Vec<MetaRefreshNavigation> {
) -> Vec<MetaRefreshMutationCandidate> {
let document_handle = dom_host.document_handle();
let mut handles = Vec::new();
for &root in effects.tree().connected_roots() {
@@ -264,17 +269,31 @@ pub(super) fn meta_refresh_navigations_from_mutation(
if handles.is_empty() {
return Vec::new();
}
let document_base_url = dom_host
.document_base_url()
.unwrap_or_else(|| document_url.clone());
handles
.into_iter()
.filter(|&handle| {
dom_host.owner_document_handle(handle) == Some(document_handle)
&& dom_host.is_connected(handle)
})
.filter_map(|handle| {
meta_refresh_navigation_for_element(dom_host, handle, document_url, &document_base_url)
let document = dom_host.owner_document_handle(handle)?;
if !dom_host.is_connected(handle) || dom_host.root_node_handle(handle) != Some(document)
{
return None;
}
let (owner_url, owner_base_url) = if document == document_handle {
(document_url, dom_host.document_base_url())
} else {
(
dom_host
.document_url_for_handle(document)
.unwrap_or(document_url),
dom_host.document_base_url_for_handle(document),
)
};
let owner_base_url = owner_base_url.unwrap_or_else(|| owner_url.clone());
let navigation =
meta_refresh_navigation_for_element(dom_host, handle, owner_url, &owner_base_url)?;
Some(MetaRefreshMutationCandidate {
document,
navigation,
})
})
.collect()
}
@@ -1720,7 +1720,7 @@ impl DocumentRuntime {
pub(crate) struct RuntimeMutationApplyResult {
changed: bool,
meta_refresh_candidates: Vec<MetaRefreshNavigation>,
meta_refresh_candidates: Vec<super::meta_refresh::MetaRefreshMutationCandidate>,
devtools_dom_mutations: Vec<super::devtools_mutations::DevToolsDomMutationFact>,
runtime_script_start_candidates: Vec<crate::mutation_coordinator::RuntimeScriptStartCandidate>,
removed_open_popovers: Vec<DomHandle>,
@@ -1768,8 +1768,16 @@ pub(super) fn finish_runtime_mutation_effects(
runtime.queue_devtools_dom_mutations(devtools_dom_mutations);
if let Some(scheduled) = runtime.note_top_level_meta_refresh_candidates(meta_refresh_candidates)
{
let mut top_level_refreshes = Vec::new();
let mut child_refreshes = Vec::new();
for candidate in meta_refresh_candidates {
if candidate.document == runtime.document_handle() {
top_level_refreshes.push(candidate.navigation);
} else {
child_refreshes.push(candidate);
}
}
if let Some(scheduled) = runtime.note_top_level_meta_refresh_candidates(top_level_refreshes) {
let delay_ms = scheduled.navigation.delay_ms;
let url = scheduled.navigation.url.clone();
let owner = scheduled.owner;
@@ -1790,6 +1798,15 @@ pub(super) fn finish_runtime_mutation_effects(
}
}
for candidate in child_refreshes {
unsafe { &mut *host_ptr }.note_child_meta_refresh_after_mutation(
scope,
runtime,
candidate.document,
candidate.navigation,
);
}
for candidate in runtime_script_start_candidates {
finish_runtime_script_start_candidate(runtime, scope, host_ptr, candidate);
}
@@ -4,7 +4,8 @@ use crate::{
LocationNavigationKind, construct_original_event, construct_original_page_transition_event,
meta_refresh_navigation_kind, navigate_location_object_with_child_navigate_event,
},
document_runtime::{DomHandle, EventTargetHandle, MetaRefreshNavigation},
document_runtime::{DocumentRuntime, DomHandle, EventTargetHandle, MetaRefreshNavigation},
dom::native::DocumentReadyState,
frame_owner_model::{
FrameDocumentLoadDeliveryAction, FrameDocumentLoadDeliveryPhase,
FrameDocumentLoadDeliveryProgress,
@@ -20,6 +21,7 @@ pub(in crate::native_bridge::context_host) struct ChildMetaRefreshNavigationTask
owner: crate::frame_owner_model::FrameDocumentTaskOwner,
target_url: url::Url,
navigation_kind: LocationNavigationKind,
delay_ms: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -616,7 +618,6 @@ impl JsContextHost {
scope: &mut v8::PinScope<'_, '_>,
handle: DomHandle,
) {
self.cancel_child_meta_refresh_navigation(handle);
if self
.child_browsing_contexts
.get(&handle)
@@ -641,11 +642,71 @@ impl JsContextHost {
) else {
return;
};
let runtime = unsafe { &mut *self.runtime };
self.schedule_child_meta_refresh_navigation(scope, runtime, handle, navigation);
}
pub(crate) fn note_child_meta_refresh_after_mutation(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
runtime: &mut DocumentRuntime,
document: DomHandle,
navigation: MetaRefreshNavigation,
) {
if runtime.dom_host().document_ready_state_for_handle(document)
!= Some(DocumentReadyState::Complete)
{
return;
}
let Some(handle) = self.child_browsing_context_host_for_document_handle(document) else {
return;
};
// Mutation application already owns the runtime borrow; use it directly
// rather than borrowing it again through the host's runtime pointer.
self.schedule_child_meta_refresh_navigation(scope, runtime, handle, navigation);
}
fn schedule_child_meta_refresh_navigation(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
runtime: &mut DocumentRuntime,
handle: DomHandle,
navigation: MetaRefreshNavigation,
) {
if self
.child_browsing_contexts
.get(&handle)
.is_none_or(|entry| !entry.document_sandbox_allows_scripts())
{
return;
}
let Some(document_url) = self.child_browsing_context_current_url(handle) else {
return;
};
let Some(owner) = self.current_child_document_task_owner(handle) else {
return;
};
let navigation_kind =
meta_refresh_navigation_kind(&document_url, &navigation.url, navigation.delay_ms);
if self
.child_meta_refresh_navigations
.get(&handle)
.is_some_and(|task| {
task.owner == owner
&& task.target_url == navigation.url
&& task.navigation_kind == navigation_kind
&& task.delay_ms == navigation.delay_ms
})
{
return;
}
let Some(window) = self.child_window_proxy_records.live_window(scope, handle) else {
return;
};
let Some(context) = window.get_creation_context(scope) else {
return;
};
let scope = &mut v8::ContextScope::new(scope, context);
let data = v8::BigInt::new_from_u64(scope, handle.index() as u64);
let Some(callback) = v8::Function::builder(child_meta_refresh_callback)
.data(data.into())
@@ -653,7 +714,7 @@ impl JsContextHost {
else {
return;
};
let timer_id = self.queue_timeout(
let timer_id = runtime.queue_timeout(
scope,
callback,
u64::from(navigation.delay_ms),
@@ -663,15 +724,18 @@ impl JsContextHost {
if timer_id == 0 {
return;
}
self.child_meta_refresh_navigations.insert(
if let Some(previous) = self.child_meta_refresh_navigations.insert(
handle,
ChildMetaRefreshNavigationTask {
timer_id,
owner,
target_url: navigation.url,
navigation_kind,
delay_ms: navigation.delay_ms,
},
);
) {
runtime.cancel_timer(previous.timer_id);
}
}
pub(in crate::native_bridge::context_host) fn cancel_child_meta_refresh_navigation(
@@ -1413,9 +1413,14 @@ async fn child_meta_refresh_can_be_inserted_in_owner_load_callback() {
assert_child_meta_refresh_can_be_inserted_during_load(false).await;
}
async fn assert_child_meta_refresh_survives_adoption_during_load(source_loads_first: bool) {
async fn assert_child_meta_refresh_survives_adoption_during_load(
source_loads_first: bool,
source_sandboxed: bool,
) {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_storage_test_vm_with_loader("https://child-meta-refresh.test/", &loader);
vm.eval(&format!("globalThis.sourceSandboxed = {source_sandboxed};"))
.unwrap();
vm.eval(
r#"
globalThis.sourceLoads = 0;
@@ -1423,7 +1428,7 @@ globalThis.destinationLoads = 0;
const root = document.documentElement || document.appendChild(document.createElement('html'));
const source = document.createElement('iframe');
const destination = document.createElement('iframe');
destination.setAttribute('sandbox', 'allow-same-origin');
(sourceSandboxed ? source : destination).setAttribute('sandbox', 'allow-same-origin');
function adoptMetaWhenBothFramesHaveLoaded() {
if (sourceLoads === 1 && destinationLoads === 1) {
destination.contentDocument.body.appendChild(source.contentDocument.querySelector('meta'));
@@ -1452,7 +1457,7 @@ destination.srcdoc = '<p>destination</p>';
vm.eval("String(destination.contentDocument.querySelectorAll('meta').length)")
.unwrap(),
"1",
"the load callback should adopt the refresh element into the sandboxed document",
"the load callback should adopt the refresh element into the destination document",
);
vm.advance_timers_until_deadline_for_test(&loader)
.await
@@ -1460,19 +1465,86 @@ destination.srcdoc = '<p>destination</p>';
assert_eq!(
vm.eval("JSON.stringify([source.contentWindow.location.href, destination.contentWindow.location.href, sourceLoads, destinationLoads])")
.unwrap(),
r#"["about:srcdoc#refreshed","about:srcdoc",1,1]"#,
"adoption must preserve the original refresh and must not refresh the sandboxed destination",
if source_sandboxed {
r#"["about:srcdoc","about:srcdoc#refreshed",1,1]"#
} else {
r#"["about:srcdoc#refreshed","about:srcdoc",1,1]"#
},
"refreshes belong to each document's sandbox policy when the meta is processed",
);
}
#[tokio::test]
async fn child_meta_refresh_survives_adoption_when_source_loads_last() {
assert_child_meta_refresh_survives_adoption_during_load(false).await;
assert_child_meta_refresh_survives_adoption_during_load(false, false).await;
}
#[tokio::test]
async fn child_meta_refresh_survives_adoption_when_destination_loads_last() {
assert_child_meta_refresh_survives_adoption_during_load(true).await;
assert_child_meta_refresh_survives_adoption_during_load(true, false).await;
}
#[tokio::test]
async fn child_meta_refresh_adoption_into_loaded_unsandboxed_document_when_source_loads_last() {
assert_child_meta_refresh_survives_adoption_during_load(false, true).await;
}
#[tokio::test]
async fn child_meta_refresh_adoption_into_unsandboxed_document_when_destination_loads_last() {
assert_child_meta_refresh_survives_adoption_during_load(true, true).await;
}
async fn assert_child_meta_refresh_insertion_after_load(in_shadow_tree: bool) {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_storage_test_vm_with_loader("https://child-meta-refresh.test/", &loader);
vm.eval(
r#"
const root = document.documentElement || document.appendChild(document.createElement('html'));
const frame = document.createElement('iframe');
frame.srcdoc = '<p>loaded</p>';
root.appendChild(frame);
'queued'
"#,
)
.unwrap();
vm.drain_pending_child_frame_work_for_test();
vm.eval(&format!(
"globalThis.insertInShadowTree = {in_shadow_tree};"
))
.unwrap();
vm.eval(
r#"
const container = frame.contentDocument.createElement('div');
frame.contentDocument.body.appendChild(container);
const target = insertInShadowTree ? container.attachShadow({mode:'open'}) : container;
target.innerHTML = '<meta http-equiv="refresh" content="0;url=about:srcdoc#inserted">';
String(target.querySelectorAll('meta').length)
"#,
)
.map(|count| assert_eq!(count, "1"))
.unwrap();
vm.advance_timers_until_deadline_for_test(&loader)
.await
.unwrap();
assert_eq!(
vm.eval("frame.contentWindow.location.href").unwrap(),
if in_shadow_tree {
"about:srcdoc"
} else {
"about:srcdoc#inserted"
},
"a post-load refresh must apply only in the document's light tree",
);
}
#[tokio::test]
async fn child_meta_refresh_applies_to_insertion_after_load() {
assert_child_meta_refresh_insertion_after_load(false).await;
}
#[tokio::test]
async fn child_meta_refresh_ignores_insertion_into_shadow_tree_after_load() {
assert_child_meta_refresh_insertion_after_load(true).await;
}
#[tokio::test]