fix(runtime): reconcile WPT changes with native state on main

Restore native History cursors from sparse serialized positions, preserve
shared Window history across isolated realms, and keep entry identity and
structured state through document.open and cross-realm settlement.

Move event subclass and precommit state access onto shared native backing.
Respect published iframe layout snapshots and align history, rendering,
and rejection tests with their owning task boundaries. Remove duplicate
upstream tests and obsolete helpers from the main rebase.

Validation: cargo fmt --all; workspace/all-targets/all-features Clippy with
-D warnings; full Nextest (20,319 passed, 16 skipped); fresh CLI HTTP/CDP
fetch resource timing fixture (36/36). Build and tests used 32-way parallelism.
This commit is contained in:
ldm0
2026-10-11 18:06:43 +08:00
parent 4b8101f335
commit bd7055dc02
57 changed files with 430 additions and 341 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ async fn assert_abort_signal_dependencies(scenario: &str, events: &[&str]) -> Re
for target in ["window", "child", "worker"] {
let observed = tokio::time::timeout(
Duration::from_secs(10),
super::pipe_disturbed::run_probe(&browser, &server, target, &source),
super::event_dispatch::run_probe(&browser, &server, target, &source),
)
.await??;
assert_eq!(
@@ -1042,8 +1042,6 @@ mod tests {
NavigationHistoryDocumentId::allocate(),
NavigationHistoryEntryId::allocate(),
NavigationHistoryEntryKey::allocate(),
None,
None,
)
})
.collect();
+4 -1
View File
@@ -18,7 +18,10 @@ use xmlparser::{
ElementEnd as XmlElementEnd, Token as XmlTokenizerToken, Tokenizer as XmlTokenizer,
};
use super::{html_chunks, xml_preprocess::prepare_xml_for_xml5ever, xml_tree_viewer::transform_document_to_xml_tree_view};
use super::{
html_chunks, xml_preprocess::prepare_xml_for_xml5ever,
xml_tree_viewer::transform_document_to_xml_tree_view,
};
use moli_dom::native::{
Attribute as NativeAttribute, DomHost, NativeDom, NativeNodeId, Node, ParserConstruction,
};
@@ -265,14 +265,20 @@ async fn check_interleaved_frames_after_parent_document_replacement() -> Result<
.collect::<Vec<_>>();
// The URL changes at commit, before the restored child document
// finishes parsing. Read its body only after that load completes.
wait(
let loaded = wait(
&mut page,
&format!(
"({list}).every(f=>f.contentDocument?.readyState === 'complete') && JSON.stringify(({list}).map(f=>f.contentWindow.location.href)) === {}",
serde_json::to_string(&serde_json::to_string(&urls)?)?
),
)
.await?;
.await;
if let Err(error) = loaded {
let observed = snapshot(&mut page, root).await;
return Err(error.context(format!(
"popup={popup}, {layout}: traversing {urls:?}; observed={observed:?}"
)));
}
let current = snapshot(&mut page, root).await.with_context(|| {
format!("popup={popup}, {layout}: traversed child entries {urls:?}")
})?;
+3 -2
View File
@@ -99,9 +99,9 @@ pub(crate) use navigation_window::{
mod navigator_runtime;
mod platform_object_worlds;
mod session_history;
mod session_history_traversal;
mod shared_event_targets;
mod world_wrappers;
mod session_history_traversal;
#[cfg(test)]
pub(crate) use navigator_runtime::{
materialized_navigator_subobject_keys, navigator_storage_wrapper_diagnostics,
@@ -157,7 +157,8 @@ pub(crate) use self::css_runtime::{
css_supports_condition_text, install_css_runtime_state_for_document,
};
pub(crate) use self::events::{
construct_original_event, construct_original_page_transition_event,
construct_original_event,
construct_original_page_transition_event,
construct_original_storage_event_utf16,
};
pub(crate) use crypto::{
@@ -58,7 +58,9 @@ pub(crate) fn begin_dispatch_with_original_target<'s>(
.unwrap_or(target);
let original_target = backing
.get_creation_context(scope)
.map(|context| super::shared_event_targets::target_in_realm(scope, original_target, context))
.map(|context| {
super::shared_event_targets::target_in_realm(scope, original_target, context)
})
.unwrap_or(original_target);
// HTML's legacy target override changes Event.target, not the dispatch
// path or the Window whose listeners are invoked.
@@ -112,14 +112,18 @@ fn new_before_unload_event<'s>(
if event.set_prototype(scope, prototype.into()) != Some(true) {
return None;
}
let wrapper = event;
let event = new_event_state(scope);
base::initialize_event_object(scope, event, event_type, false, cancelable);
set_private_value(
set_event_private_value(
scope,
event,
BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT,
v8str(scope, "").into(),
);
Some(event)
web_api_interfaces::initialize(scope, event, "BeforeUnloadEvent").ok()?;
initialize_event_wrapper(scope, wrapper, event)?;
Some(wrapper)
}
pub(crate) fn construct_original_before_unload_event<'s>(
@@ -142,7 +146,7 @@ fn before_unload_event_return_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::String>> {
get_private_value(scope, event, BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT)
event_private_value(scope, event, BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT)
.and_then(|value| v8::Local::<v8::String>::try_from(value).ok())
}
@@ -158,7 +162,7 @@ pub(crate) fn set_before_unload_event_return_value<'s>(
event: v8::Local<'s, v8::Object>,
value: v8::Local<'s, v8::String>,
) {
set_private_value(
set_event_private_value(
scope,
event,
BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT,
@@ -498,7 +502,7 @@ pub(super) fn clipboard_event_clipboard_data_getter_function<'s>(
throw_type_error(scope, "Illegal invocation");
return;
}
let value = get_private_value(scope, args.this(), CLIPBOARD_EVENT_CLIPBOARD_DATA_SLOT)
let value = event_private_value(scope, args.this(), CLIPBOARD_EVENT_CLIPBOARD_DATA_SLOT)
.unwrap_or_else(|| v8::null(scope).into());
rv.set(value);
}
@@ -512,7 +516,7 @@ pub(super) fn clipboard_change_event_types_getter_function<'s>(
throw_type_error(scope, "Illegal invocation");
return;
}
let Some(value) = get_private_value(scope, args.this(), CLIPBOARD_CHANGE_EVENT_TYPES_SLOT)
let Some(value) = event_private_value(scope, args.this(), CLIPBOARD_CHANGE_EVENT_TYPES_SLOT)
else {
throw_type_error(scope, "Illegal invocation");
return;
@@ -529,7 +533,8 @@ pub(super) fn clipboard_change_event_change_id_getter_function<'s>(
throw_type_error(scope, "Illegal invocation");
return;
}
let Some(value) = get_private_value(scope, args.this(), CLIPBOARD_CHANGE_EVENT_CHANGE_ID_SLOT)
let Some(value) =
event_private_value(scope, args.this(), CLIPBOARD_CHANGE_EVENT_CHANGE_ID_SLOT)
else {
throw_type_error(scope, "Illegal invocation");
return;
@@ -1042,6 +1042,12 @@ pub(in crate::context_bootstrap) fn run_navigate_event_precommit_handlers<'s>(
}
install_navigate_event_precommit_transition(scope, event);
let controller = create_precommit_controller(scope, event);
crate::context_bootstrap::set_event_private_value(
scope,
event,
NAVIGATE_EVENT_PRECOMMIT_CONTROLLER_SLOT,
controller.into(),
);
let arguments = [controller.into()];
run_navigation_handler_array(
scope,
@@ -1117,16 +1123,9 @@ fn create_precommit_controller<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Object> {
let controller = PrecommitControllerDeclaration::new(event, true)
PrecommitControllerDeclaration::new(event, true)
.bind(scope)
.expect("precommit controller declaration should bind");
set_private_value(
scope,
event,
NAVIGATE_EVENT_PRECOMMIT_CONTROLLER_SLOT,
controller.into(),
);
controller
.expect("precommit controller declaration should bind")
}
fn precommit_controller_event<'s>(
@@ -1145,7 +1144,7 @@ fn precommit_controller_event<'s>(
}
let event = get_private_value(scope, controller, PRECOMMIT_CONTROLLER_EVENT_SLOT)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())?;
let owner_is_active = get_private_value(
let owner_is_active = crate::context_bootstrap::event_private_value(
scope,
event,
NAVIGATE_EVENT_PRECOMMIT_TRANSITION_NAVIGATION_SLOT,
@@ -1157,7 +1156,7 @@ fn precommit_controller_event<'s>(
});
if !owner_is_active
|| !navigate_event_target_is_connected(scope, event)
|| object_bool_property(scope, event, "defaultPrevented").unwrap_or(false)
|| crate::context_bootstrap::event_bool_attribute(scope, event, "defaultPrevented")
{
navigate_event_throw_invalid_state(scope);
return None;
@@ -1239,7 +1238,7 @@ fn precommit_controller_redirect_callback<'s>(
navigate_event_throw_invalid_state(scope);
return;
};
let Some(navigation) = get_private_value(
let Some(navigation) = crate::context_bootstrap::event_private_value(
scope,
event,
NAVIGATE_EVENT_PRECOMMIT_TRANSITION_NAVIGATION_SLOT,
@@ -265,6 +265,19 @@ pub(crate) fn event_attribute<'s>(
property: &str,
) -> Option<v8::Local<'s, v8::Value>> {
let state = event_backing(scope, event);
// CommandEvent and ToggleEvent expose source through prototype accessors;
// their native state holds the value in a private slot, not an own field.
if property == "source" {
match event_subclass_kind(scope, state) {
Some(EventSubclassKind::CommandEvent) => {
return get_private_value(scope, state, COMMAND_EVENT_SOURCE_SLOT);
}
Some(EventSubclassKind::ToggleEvent) => {
return get_private_value(scope, state, TOGGLE_EVENT_SOURCE_SLOT);
}
_ => {}
}
}
state.get(scope, v8_string(scope, property)?.into())
}
@@ -172,19 +172,6 @@ pub(crate) fn ensure_intrinsic_interface_prototype<'s>(
.ok_or_else(|| anyhow!("intrinsic prototype `{name}` is missing after materialization"))
}
/// Reads an already registered prototype without materializing an interface.
/// This is useful while creating an exception for a bootstrap failure.
pub(crate) fn materialized_intrinsic_interface_prototype<'s>(
scope: &mut v8::PinScope<'s, '_>,
name: &str,
) -> Option<v8::Local<'s, v8::Object>> {
let registry = ExposedInterfaceTemplateRegistry::current(scope)?;
let id = registry.id_by_name(name)?;
IntrinsicInterfaceRegistry::for_current_context(scope, registry.len())
.ok()?
.prototype(scope, id)
}
fn materialize_uninitialized_interface<'s>(
scope: &mut v8::PinScope<'s, '_>,
registry: &Rc<ExposedInterfaceTemplateRegistry>,
@@ -22,7 +22,7 @@ pub(crate) use install::{
};
pub(crate) use materialize::{
build_intrinsic_interface_instance, ensure_intrinsic_interface_constructor,
ensure_intrinsic_interface_prototype, materialized_intrinsic_interface_prototype,
ensure_intrinsic_interface_prototype,
};
pub(crate) use metadata::RealmKind;
pub(in crate::context_bootstrap) use metadata::TemplateBuildProfile;
@@ -46,38 +46,22 @@ pub(crate) fn update_history_for_document_open<'s>(
let Some(history) = window_history_for_holder(scope, window) else {
return;
};
let Some(entries) = history_entries(scope, history) else {
let Some(history_record) = super::history_runtime::native::history(scope, history) else {
return;
};
let index = history_index(scope, history);
let Some(previous) = entries
.get_index(scope, index)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
else {
let Some(previous_record) = history_record.borrow().current_entry().cloned() else {
return;
};
let navigation_index = navigation_current_entry_index(scope, window).unwrap_or(index);
let key = navigation_entry_key_value(scope, previous)
.unwrap_or_else(|| new_navigation_entry_key().as_str().to_owned());
let state =
clone_history_entry_state(scope, previous).unwrap_or_else(|| v8::null(scope).into());
let state_json = stringify_history_state(scope, state);
let entry = create_navigation_entry(
scope,
window,
url.as_str(),
state_json.as_deref(),
None,
None,
navigation_index,
&new_navigation_entry_id(),
&key,
);
copy_navigation_entry_document_id(scope, previous, entry);
bind_navigation_entry_runtime_owner(scope, entry, window);
set_history_entry_state(scope, entry, state);
let _ = entries.set_index(scope, index, entry.into());
set_history_entries(scope, history, entries);
let previous =
super::history_runtime::native::entry_wrapper(scope, window, previous_record.clone());
let mut record = previous_record.borrow().clone();
record.url = url.as_str().to_owned();
record.id =
super::navigation_entry::navigation_entry_public_token(new_navigation_entry_id().as_str());
let entry = super::history_runtime::native::entry_wrapper(scope, window, record.into_ref());
let state = super::history_runtime::state::history_state_value(scope, history);
replace_history_entry(scope, history, entry);
cache_current_history_state(scope, history, state);
sync_navigation_current_entry_from_history_entry(scope, window, entry);
super::session_history::commit(
scope,
@@ -165,7 +165,7 @@ pub(in crate::context_bootstrap) fn find_entry_wrapper<'s>(
};
for candidate in candidates {
if let Some(wrapper) = candidate.to_local(scope)
&& wrapper.get_creation_context(scope) == Some(context)
&& super::super::world_wrappers::belongs_to_world(scope, wrapper, context)
{
return Some(wrapper);
}
@@ -2,7 +2,7 @@ use super::super::navigation_activation::{
install_navigation_transition, precommit_transition_resolver_from_event,
resolve_navigation_transition_committed,
};
use super::super::navigation_entry::{history_entries};
use super::super::navigation_entry::history_entries;
use super::super::navigation_events::{
NavigationDispatchOutcome, dispatch_navigation_success,
run_navigation_precommit_deferred_handlers,
@@ -36,7 +36,8 @@ pub(in crate::context_bootstrap::indexed_db) fn dispatch_idb_named_event<'s>(
event_type == "error",
);
events::mark_event_trusted(scope, event);
extras(scope, events::event_backing(scope, event));
let backing = events::event_backing(scope, event);
extras(scope, backing);
dispatch_idb_event_object(scope, target, event, event_type)
}
@@ -242,21 +242,27 @@ pub(crate) fn invoke_simple_event_target_listeners<'s>(
continue;
};
let callback_target = crate::context_bootstrap::shared_event_targets::target_in_realm(
scope, target, listener.relevant_context(),
scope,
target,
listener.relevant_context(),
);
let Some(callback_event) = crate::context_bootstrap::navigation_event_worlds::event_in_realm(
scope, target, event, listener.relevant_context(),
) else {
let Some(callback_event) =
crate::context_bootstrap::navigation_event_worlds::event_in_realm(
scope,
target,
event,
listener.relevant_context(),
)
else {
continue;
};
let ordinary_arguments = [callback_event.into()];
let handler_arguments = if handler_arguments.len() == 1
&& handler_arguments[0].strict_equals(event.into())
{
ordinary_arguments.as_slice()
} else {
handler_arguments
};
let handler_arguments =
if handler_arguments.len() == 1 && handler_arguments[0].strict_equals(event.into()) {
ordinary_arguments.as_slice()
} else {
handler_arguments
};
set_event_internal_flag(scope, event, EVENT_PASSIVE_SLOT, listener.passive);
let outcome = invoke_simple_event_listener_collecting_errors(
scope,
@@ -113,6 +113,11 @@ pub(crate) fn reset_window_location_history_navigation_runtime_state<'s>(
window: v8::Local<'s, v8::Object>,
href: &str,
) -> Result<()> {
// A child isolated realm shares the default Window's native records.
// Guard the entire reset, including History and Navigation ownership.
if window_has_shared_history(scope, window) {
return Ok(());
}
reset_window_location_runtime_state(scope, window, href)?;
let initial_seed = initial_navigation_history_seed(scope, window, href);
@@ -123,9 +128,9 @@ pub(crate) fn reset_window_location_history_navigation_runtime_state<'s>(
set_runtime_window_owner(scope, history, window);
install_history_scroll_restoration_runtime_state(scope, history, "auto");
install_history_state_runtime_state(scope, history, v8::null(scope).into());
let entries = build_history_entries_from_seed(scope, window, &initial_seed);
let (entries, current_index) = build_history_entries_from_seed(scope, window, &initial_seed);
let current_entry = entries
.get(initial_seed.current_index as usize)
.get(current_index as usize)
.map(|entry| entry_wrappers::for_window(scope, window, entry.clone()))
.unwrap_or_else(|| {
build_current_navigation_entry_from_seed(
@@ -136,7 +141,7 @@ pub(crate) fn reset_window_location_history_navigation_runtime_state<'s>(
)
});
set_history_entries(scope, history, entries);
set_history_index(scope, history, initial_seed.current_index);
set_history_index(scope, history, current_index);
set_private_value(scope, window, WINDOW_HISTORY_SLOT, history.into());
let navigation = match window_runtime_object(scope, window, WINDOW_NAVIGATION_SLOT) {
@@ -1190,9 +1190,8 @@ fn precommit_commit_fulfilled_callback<'s>(
Some("reload"),
);
}
let receiver = v8::undefined(scope).into();
if let Some(resolve) = data.committed_resolve {
let _ = resolve.call(scope, receiver, &[resolved_value]);
settle_navigation_finished_resolved_immediately(scope, resolve, resolved_value);
}
resolve_navigation_transition_committed(scope, data.navigation);
let outcome = NavigationDispatchOutcome {
@@ -184,7 +184,11 @@ pub(super) fn navigation_document_origin<'s>(
crate::util::context_host_ptr_from_global_bridge(scope)
.and_then(|host| unsafe { &*host }.window_document_origin(dispatch_scope))
})
.or_else(|| url::Url::parse(url).ok().map(|url| url.origin().ascii_serialization()))
.or_else(|| {
url::Url::parse(url)
.ok()
.map(|url| url.origin().ascii_serialization())
})
.unwrap_or_else(|| "null".to_owned())
}
@@ -195,16 +199,6 @@ pub(super) fn navigation_entry_origin<'s>(
navigation_entry_stored_string(scope, entry, EntryStringField::Origin)
}
pub(super) fn set_navigation_entry_origin<'s>(
scope: &mut v8::PinScope<'s, '_>,
entry: v8::Local<'s, v8::Object>,
origin: &str,
) {
if let Some(entry) = native::entry(scope, entry) {
entry.borrow_mut().document_origin = origin.to_owned();
}
}
pub(super) fn navigation_entries_share_origin<'s>(
scope: &mut v8::PinScope<'s, '_>,
left: v8::Local<'s, v8::Object>,
@@ -1099,9 +1099,12 @@ pub(super) fn finish_navigation_precommit<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
) {
if let Some(controller) =
get_private_value(scope, event, NAVIGATE_EVENT_PRECOMMIT_CONTROLLER_SLOT)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
if let Some(controller) = crate::context_bootstrap::event_private_value(
scope,
event,
NAVIGATE_EVENT_PRECOMMIT_CONTROLLER_SLOT,
)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
{
set_private_value(
scope,
@@ -1109,7 +1112,7 @@ pub(super) fn finish_navigation_precommit<'s>(
"__lmPrecommitControllerActive",
v8::Boolean::new(scope, false).into(),
);
set_private_value(
crate::context_bootstrap::set_event_private_value(
scope,
event,
NAVIGATE_EVENT_PRECOMMIT_CONTROLLER_SLOT,
@@ -1441,7 +1444,12 @@ pub(super) fn navigation_destination_for_realm<'s>(
return Some(wrapper);
}
let wrapper = create_navigation_destination(scope, "", false, None);
set_private_value(scope, wrapper, NAVIGATION_DESTINATION_BACKING_SLOT, destination.into());
set_private_value(
scope,
wrapper,
NAVIGATION_DESTINATION_BACKING_SLOT,
destination.into(),
);
super::world_wrappers::insert(scope, destination, wrapper);
Some(wrapper)
}
@@ -1459,8 +1467,9 @@ fn create_navigation_destination_for_entry<'s>(
.and_then(|history| history_entries(scope, history))
.and_then(|entries| {
let current = navigation_current_entry(scope, owner);
let record = super::history_runtime::native::entry(scope, entry)?;
super::navigation_projection::visible_navigation_index_for_entry(
scope, entries, current, entry,
scope, &entries, current, &record,
)
})
.map(|_| entry);
@@ -10,7 +10,9 @@ use super::navigation_entry::{
new_navigation_entry_key, set_navigation_entry_document_id,
sync_navigation_current_entry_from_history_entry,
};
use super::navigation_entry_state::{copy_navigation_entry_serialized_state, set_navigation_entry_state};
use super::navigation_entry_state::{
copy_navigation_entry_serialized_state, set_navigation_entry_state,
};
use super::navigation_events::{
dispatch_navigation_currententrychange, dispatch_navigation_entry_dispose,
};
@@ -74,7 +74,9 @@ fn visible_navigation_entries<'s>(
let same_origin = |entry: &HistoryEntryRef| {
let current = current_entry.borrow();
let candidate = entry.borrow();
current.document == candidate.document || (current.document_origin != "null" && current.document_origin == candidate.document_origin)
current.document == candidate.document
|| (current.document_origin != "null"
&& current.document_origin == candidate.document_origin)
};
let mut start = current_index;
while start > 0 {
@@ -139,9 +139,9 @@ pub(crate) fn install_navigation_entry_view_for_holder<'s>(
let Some(navigation) = window_navigation_for_holder(scope, owner) else {
return;
};
let entries = build_history_entries_from_seed(scope, owner, entry_seed);
let (entries, current_index) = build_history_entries_from_seed(scope, owner, entry_seed);
let current_entry = entries
.get(entry_seed.current_index as usize)
.get(current_index as usize)
.map(|entry| entry_wrappers::for_window(scope, owner, entry.clone()))
.unwrap_or_else(|| {
build_current_navigation_entry_from_seed(
@@ -154,7 +154,7 @@ pub(crate) fn install_navigation_entry_view_for_holder<'s>(
let current_state =
clone_history_entry_state(scope, current_entry).unwrap_or_else(|| v8::null(scope).into());
set_history_entries(scope, history, entries);
set_history_index(scope, history, entry_seed.current_index);
set_history_index(scope, history, current_index);
cache_current_history_state(scope, history, current_state);
set_navigation_current_entry(scope, navigation, current_entry);
if let Some(snapshot) = entry_seed
@@ -56,21 +56,34 @@ pub(super) fn build_history_entries_from_seed<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
seed: &NavigationHistoryEntrySeed,
) -> Vec<HistoryEntryRef> {
) -> (Vec<HistoryEntryRef>, u32) {
let mut snapshots: Vec<_> = seed.entries.iter().collect();
snapshots.sort_by_key(|snapshot| snapshot.history_index);
snapshots
.into_iter()
// Serialized history positions can have gaps after pruning. Native History
// stores a dense vector, so restore its cursor from the sorted position.
let current_index = snapshots
.iter()
.position(|entry| entry.history_index == seed.current_index)
.map_or(seed.current_index, |index| index as u32);
let current_document = snapshots
.get(current_index as usize)
.map(|entry| &entry.document_id);
let entries = snapshots
.iter()
.map(|snapshot| {
let entry = history_entry_from_snapshot(snapshot);
if seed.entries.iter().find(|entry| entry.history_index == seed.current_index)
.is_some_and(|current| current.document_id == snapshot.document_id)
{
entry.borrow_mut().document_origin = super::navigation_entry::navigation_document_origin(scope, owner, &snapshot.url);
if current_document.is_some_and(|document| document == &snapshot.document_id) {
entry.borrow_mut().document_origin =
super::navigation_entry::navigation_document_origin(
scope,
owner,
&snapshot.url,
);
}
entry
})
.collect()
.collect();
(entries, current_index)
}
pub(super) fn build_current_navigation_entry_from_seed<'s>(
@@ -85,7 +98,8 @@ pub(super) fn build_current_navigation_entry_from_seed<'s>(
.find(|entry| entry.history_index == seed.current_index)
.map(|snapshot| {
let entry = history_entry_from_snapshot(snapshot);
entry.borrow_mut().document_origin = super::navigation_entry::navigation_document_origin(scope, owner, &snapshot.url);
entry.borrow_mut().document_origin =
super::navigation_entry::navigation_document_origin(scope, owner, &snapshot.url);
entry
})
.unwrap_or_else(|| {
@@ -1,6 +1,7 @@
use super::navigation_activation::navigation_activation_value;
use super::navigation_entry::{
history_index, navigation_current_entry, navigation_entry_referrer_policy_value,
history_index, navigation_current_entry, navigation_entry_document_id,
navigation_entry_referrer_policy_value,
};
use super::navigation_window::{
child_browsing_context_handle_for_runtime_owner, runtime_window_is_global,
@@ -54,10 +54,10 @@ pub(in crate::context_bootstrap) fn build_history_runtime_state<'s>(
anyhow::anyhow!("isolated History is missing its native Window history")
})?
} else {
let records = build_history_entries_from_seed(scope, window, initial_seed);
let (records, current_index) = build_history_entries_from_seed(scope, window, initial_seed);
std::rc::Rc::new(std::cell::RefCell::new(moli_history::WindowHistory::new(
records,
initial_seed.current_index,
current_index,
)))
};
let history = HistoryRuntimeObjectDeclaration::new()
@@ -16,8 +16,8 @@ use super::navigation_traversal_execution::TraversalTarget;
use super::navigation_traversal_plan::JointTraversalPlan;
use super::navigation_window::{
child_browsing_context_handle_for_runtime_owner, navigation_document_has_opaque_origin,
navigation_document_is_active,
window_navigation_for_holder, window_task_target_for_runtime_owner,
navigation_document_is_active, window_navigation_for_holder,
window_task_target_for_runtime_owner,
};
use crate::document_runtime::DomHandle;
use crate::native_bridge::history_traversal::{
@@ -380,7 +380,6 @@ fn settle_aborted_admission<'s>(
admission: &PendingHistoryTraversalAdmission,
error: Option<v8::Local<'s, v8::Value>>,
) {
let canceled = error.is_none();
let _execution = crate::script_cleanup::ScriptExecutionScope::enter(scope);
let error = error.unwrap_or_else(|| {
navigation_dom_exception(scope, "History traversal was canceled", "AbortError")
@@ -1,8 +1,6 @@
use super::history_runtime::results::reject_pending_navigation_results;
use super::history_runtime::{
history_traversal_target_window, route_history_traversal_task,
};
use super::navigation_entry::{history_entries};
use super::history_runtime::{history_traversal_target_window, route_history_traversal_task};
use super::navigation_entry::history_entries;
use super::navigation_events::{
NavigationDispatchOutcome, dispatch_beforeunload_for_runtime_owner,
dispatch_navigation_traverse_event_with_outcome,
@@ -218,7 +216,8 @@ fn traversal_target_key_still_available<'s>(
return true;
};
history_entries(scope, history).is_some_and(|entries| {
entries.get(target_index as usize)
entries
.get(target_index as usize)
.is_some_and(|entry| entry.borrow().key.as_str() == expected_key)
})
}
@@ -99,18 +99,26 @@ pub(super) fn apply_step(
.and_then(|entries| entries.get(target.target_index as usize).cloned())
{
let key = entry.borrow().key.as_str().to_owned();
host.top_level_navigation_history().select_joint_traversal(key, step);
host.top_level_navigation_history()
.select_joint_traversal(key, step);
}
host.record_pending_top_level_history_traversal(delta);
return;
}
}
let targets = plan.targets.iter().map(|target| {
let entries = history_entries(scope, target.history)?;
let entry = entries.get(target.target_index as usize)?;
let key = entry.borrow().key.clone();
Some((session_history::binding(scope, host, target.owner).context, key))
}).collect::<Option<Vec<_>>>();
let targets = plan
.targets
.iter()
.map(|target| {
let entries = history_entries(scope, target.history)?;
let entry = entries.get(target.target_index as usize)?;
let key = entry.borrow().key.clone();
Some((
session_history::binding(scope, host, target.owner).context,
key,
))
})
.collect::<Option<Vec<_>>>();
let Some(targets) = targets else {
if let Some(method) = method {
reject_canceled_history_traversal_results(scope, &method.results);
@@ -135,15 +143,22 @@ pub(super) fn apply_step(
let key = history_entries(scope, target.history)
.and_then(|entries| entries.get(target.target_index as usize).cloned())
.map(|entry| entry.borrow().key.as_str().to_owned());
let (info, results) = method.map_or_else(|| (None, Vec::new()), |method| (method.info, method.results));
apply_pending_cross_document_traversal(scope, host, PendingCrossDocumentTraversal {
target: exact,
target_index: target.target_index,
target_key: key,
seed,
info,
results,
})
let (info, results) = method.map_or_else(
|| (None, Vec::new()),
|method| (method.info, method.results),
);
apply_pending_cross_document_traversal(
scope,
host,
PendingCrossDocumentTraversal {
target: exact,
target_index: target.target_index,
target_key: key,
seed,
info,
results,
},
)
} else {
false
}
@@ -20,7 +20,6 @@ struct ErrorEventInitDeclaration<'scope> {
error: v8::Local<'scope, v8::Value>,
}
pub(super) fn ensure_window_reflecting_body_onerror_handler(scope: &mut v8::PinScope<'_, '_>) {
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
return;
@@ -53,20 +53,10 @@ struct DocumentWriteParserMutationOwner<'a, 'scope, 'pin> {
enum DocumentWriteParserMutationTarget {
LiveDocument,
WindowlessDocument { owner_document: DomHandle },
DetachedFragment { owner_document: DomHandle },
DetachedFragment,
}
impl DocumentWriteParserMutationOwner<'_, '_, '_> {
fn owner_document_handle(&self) -> DomHandle {
match self.target {
DocumentWriteParserMutationTarget::LiveDocument => self.runtime.document_handle(),
DocumentWriteParserMutationTarget::WindowlessDocument { owner_document }
| DocumentWriteParserMutationTarget::DetachedFragment { owner_document } => {
owner_document
}
}
}
fn targets_live_document(&self) -> bool {
matches!(self.target, DocumentWriteParserMutationTarget::LiveDocument)
}
@@ -78,7 +68,7 @@ impl ParserMutationEffectConsumer for DocumentWriteParserMutationOwner<'_, '_, '
fn consume_parser_mutation_effects(&mut self, effects: DomMutationEffects) {
if matches!(
self.target,
DocumentWriteParserMutationTarget::DetachedFragment { .. }
DocumentWriteParserMutationTarget::DetachedFragment
) {
return;
}
@@ -193,7 +183,7 @@ impl ParserDomMutationConsumer for DocumentWriteParserMutationOwner<'_, '_, '_>
fn apply_parser_dom_mutation(&mut self, mutation: ParserDomMutation) {
if matches!(
self.target,
DocumentWriteParserMutationTarget::DetachedFragment { .. }
DocumentWriteParserMutationTarget::DetachedFragment
) {
let _ = mutation
.apply_to_detached_dom_host(self.runtime.dom_host_mut_for_active_parser_step());
@@ -250,7 +240,7 @@ impl ParserDomMutationConsumer for DocumentWriteParserMutationOwner<'_, '_, '_>
// attributes also need registration without waiting for connection.
let window_handlers = if matches!(
self.target,
DocumentWriteParserMutationTarget::DetachedFragment { .. }
DocumentWriteParserMutationTarget::DetachedFragment
) || self.runtime.dom_host().is_connected(node_id)
{
crate::native_bridge::element::ParserAddedBodyWindowHandlers::capture(
@@ -356,6 +356,12 @@ impl DocumentRuntime {
self.timeouts.has_ready_timer()
}
// Rendering wakes are independent of callbacks queued by an API.
#[cfg(test)]
pub(crate) fn has_ready_callback_timer(&self) -> bool {
self.timeouts.has_ready_callback_timer()
}
pub(crate) fn next_ready_timeout_deadline(
&self,
selection: crate::page_task_queue::RendererPageTimerSelection,
+1 -2
View File
@@ -2476,8 +2476,7 @@ fn event_source_target_handle(
host_ptr: *mut JsContextHost,
event: v8::Local<'_, v8::Object>,
) -> Option<EventTargetHandle> {
let value = crate::context_bootstrap::event_backing(scope, event)
.get(scope, v8str(scope, "source").into())?;
let value = crate::context_bootstrap::event_attribute(scope, event, "source")?;
if value.is_null_or_undefined() || !value.is_object() {
return None;
}
+12
View File
@@ -835,6 +835,18 @@ impl HostTimeoutScheduler {
self.scheduler.has_ready_timer(Instant::now())
}
#[cfg(test)]
pub(crate) fn has_ready_callback_timer(&self) -> bool {
self.scheduler
.next_ready_deadline_matching(Instant::now(), min_delay_ready_allowance(), |task| {
!matches!(
task.callback,
ScheduledTimerCallback::AnimationFrameWake { .. }
)
})
.is_some()
}
pub(crate) fn has_ready_from_schedule_ranges(&self, ranges: &[TimerScheduleRange]) -> bool {
self.scheduler
.has_ready_from_schedule_ranges(ranges, Instant::now())
@@ -580,8 +580,16 @@ impl JsContextHost {
} else {
window.into()
};
let _ = crate::context_bootstrap::event_backing(scope, event).set(scope, v8str(scope, "target").into(), target);
let _ = crate::context_bootstrap::event_backing(scope, event).set(scope, v8str(scope, "currentTarget").into(), window.into());
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "target").into(),
target,
);
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "currentTarget").into(),
window.into(),
);
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "eventPhase").into(),
@@ -566,7 +566,11 @@ impl ParserElementCreationConsumer for ContextDocumentParserOwner<'_, '_, '_> {
request.intended_parent,
|document_handle, local_name, namespace, prefix| {
request.construction.create_element(
self.host.dom_host_mut(), document_handle, local_name, namespace, prefix,
self.host.dom_host_mut(),
document_handle,
local_name,
namespace,
prefix,
)
},
)
@@ -2338,15 +2338,8 @@ impl JsContextHost {
if style_mutation_effects_affect_layout_metric(effects) {
self.clear_layout_rect_cache();
}
let changed_iframe_attributes = effects.iter().filter_map(|effect| {
let StyleMutationEffect::Attribute { element, name, .. } = effect else {
return None;
};
(StyleAttributeImpact::for_attribute_name(name).affects_layout_metric()
&& self.dom_host().is_html_element_named(*element, "iframe"))
.then_some(*element)
});
self.invalidate_published_frame_viewports(changed_iframe_attributes);
// Frame viewports belong to the last published layout. Style mutations
// invalidate style inputs; the next layout publishes their new sizes.
let validity_containers = self.validity_containers_for_child_list_mutations(effects);
let dom_host = self.dom_host() as *const _;
let emulated_media = self.emulated_media().clone();
@@ -185,42 +185,6 @@ impl JsContextHost {
.frame_viewport(frame)
}
pub(crate) fn invalidate_published_frame_viewports(
&self,
frames: impl IntoIterator<Item = DomHandle>,
) {
let frames = frames.into_iter().collect::<Vec<_>>();
if frames.is_empty() {
return;
}
let documents = frames
.iter()
.filter_map(|frame| {
let document = self.dom_host().owner_document_handle(*frame)?;
self.top_level_document_for_document(document)
})
.collect::<std::collections::HashSet<_>>();
let changed = {
let mut state = self.document_layout_state.borrow_mut();
let changed =
state.update_frame_viewports(frames.into_iter().map(|frame| (frame, None)));
if documents.contains(&self.document_handle()) {
state.clear_latest_layout();
}
changed
};
for document in documents {
if let Some(cache) = self.lightweight_popup_layout_cache(document) {
cache.borrow_mut().clear();
}
}
if changed {
self.style_viewport_generation
.set(self.style_viewport_generation.get().saturating_add(1));
}
}
#[cfg(debug_assertions)]
pub(crate) fn style_viewport_generation(&self) -> u64 {
self.style_viewport_generation.get()
}
@@ -8,9 +8,7 @@ use crate::runtime::{
};
use crate::service_worker_runtime::{ServiceWorkerClientId, ServiceWorkerClientNavigateError};
use moli_fetch::BrowserNavigationRequestKind;
use moli_page_types::{
NavigationHistoryEntrySeed, NavigationHistoryMutation,
};
use moli_page_types::{NavigationHistoryEntrySeed, NavigationHistoryMutation};
use url::Url;
pub(crate) struct PendingReservedServiceWorkerClient {
@@ -425,13 +425,14 @@ pub use geometry::ClientRect;
#[cfg(test)]
pub(crate) use geometry::observable_scrollbar_hit_test;
pub(crate) use geometry::{
element_is_inert_for_hit_testing, InputSurfaceHit, apply_scroll_observable_effects, input_surface_hit_test,
observable_caret_position, observable_deep_hit_test, observable_document_metrics,
observable_event_offset, observable_geometry_batch, observable_geometry_query,
observable_hit_test, observable_hit_test_all, observable_input_hit_test,
observable_sources_with_fragments, perform_scrollbar_scroll_default_action,
perform_wheel_scroll_default_action, published_geometry_batch, queue_scroll_observable_effects,
scroll_node_into_view_at_center, scroll_node_into_view_at_start,
InputSurfaceHit, apply_scroll_observable_effects, element_is_inert_for_hit_testing,
input_surface_hit_test, observable_caret_position, observable_deep_hit_test,
observable_document_metrics, observable_event_offset, observable_geometry_batch,
observable_geometry_query, observable_hit_test, observable_hit_test_all,
observable_input_hit_test, observable_sources_with_fragments,
perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action,
published_geometry_batch, queue_scroll_observable_effects, scroll_node_into_view_at_center,
scroll_node_into_view_at_start,
};
pub(super) use geometry::{
node_client_height_getter_function, node_client_left_getter_function,
@@ -545,8 +545,8 @@ getSelection().collapse(text, 1);
)?;
assert!(
!page_vm.vm().has_ready_timeout(),
"user-interaction tasks must not acquire PageTimer descriptors"
!page_vm.vm().has_ready_callback_timer(),
"user-interaction callbacks must stay on their task source; rendering wakes are separate"
);
// The value setter already queued selectionchange before the range
// setter queues select; later selection changes coalesce with it.
@@ -426,7 +426,9 @@ document.body.appendChild(frame);
}
let (observed, _) = page
.run_async_command(RendererPageCommand::EvaluateExpression {
expression: "globalThis.__childSelfNavigateEvents.join('|')".to_owned(),
// Each owner-driven completion is required exactly once. Posted
// messages and load events use separate task sources.
expression: "globalThis.__childSelfNavigateEvents.slice().sort().join('|')".to_owned(),
await_promise: false,
})
.await
@@ -319,9 +319,16 @@ async fn per_page_isolate_policy_routes_unhandled_rejections_to_originating_page
expression: r#"(() => {
globalThis.__lm_shared_isolate_unhandled_rejections = [];
globalThis.__lm_shared_isolate_rejectionhandled = [];
globalThis.__lm_shared_isolate_unhandled_done = new Promise(resolve => {
globalThis.__lm_shared_isolate_resolve_unhandled = resolve;
});
globalThis.__lm_shared_isolate_handled_done = new Promise(resolve => {
globalThis.__lm_shared_isolate_resolve_handled = resolve;
});
addEventListener("unhandledrejection", event => {
event.preventDefault();
globalThis.__lm_shared_isolate_unhandled_rejections.push(String(event.reason));
globalThis.__lm_shared_isolate_resolve_unhandled();
});
addEventListener("rejectionhandled", event => {
globalThis.__lm_shared_isolate_rejectionhandled.push(
@@ -329,6 +336,7 @@ async fn per_page_isolate_policy_routes_unhandled_rejections_to_originating_page
? "same-promise"
: "wrong-promise"
);
globalThis.__lm_shared_isolate_resolve_handled();
});
return "installed";
})()"#
@@ -359,13 +367,18 @@ async fn per_page_isolate_policy_routes_unhandled_rejections_to_originating_page
Some(serde_json::json!("scheduled"))
);
let (second_rejections, _) = second_page
.run_async_command(RendererPageCommand::EvaluateExpression {
expression: r#"JSON.stringify(globalThis.__lm_shared_isolate_unhandled_rejections)"#
// Rejection notifications are queued tasks. A later Evaluate command is
// not a barrier for their delivery; await the originating page's event.
let (second_rejections, _) = tokio::time::timeout(
Duration::from_secs(2),
second_page.run_async_command(RendererPageCommand::EvaluateExpression {
expression: r#"__lm_shared_isolate_unhandled_done.then(() => JSON.stringify(__lm_shared_isolate_unhandled_rejections))"#
.to_owned(),
await_promise: false,
})
await_promise: true,
}),
)
.await
.expect("second page must receive its unhandled rejection task")
.expect("second page rejection list should evaluate");
assert_eq!(
renderer_json_value(second_rejections),
@@ -403,13 +416,16 @@ async fn per_page_isolate_policy_routes_unhandled_rejections_to_originating_page
Some(serde_json::json!("handler-added"))
);
let (second_rejectionhandled, _) = second_page
.run_async_command(RendererPageCommand::EvaluateExpression {
expression: r#"JSON.stringify(globalThis.__lm_shared_isolate_rejectionhandled)"#
let (second_rejectionhandled, _) = tokio::time::timeout(
Duration::from_secs(2),
second_page.run_async_command(RendererPageCommand::EvaluateExpression {
expression: r#"__lm_shared_isolate_handled_done.then(() => JSON.stringify(__lm_shared_isolate_rejectionhandled))"#
.to_owned(),
await_promise: false,
})
await_promise: true,
}),
)
.await
.expect("second page must receive its rejectionhandled task")
.expect("second page rejectionhandled list should evaluate");
assert_eq!(
renderer_json_value(second_rejectionhandled),
@@ -857,6 +857,12 @@ impl ScriptVm {
self.document_runtime.has_ready_timeout()
}
// Rendering wakes are independent of callbacks queued by an API.
#[cfg(test)]
pub(crate) fn has_ready_callback_timer(&self) -> bool {
self.document_runtime.has_ready_callback_timer()
}
pub(crate) fn next_ready_timeout_deadline(
&self,
selection: crate::page_task_queue::RendererPageTimerSelection,
@@ -1210,6 +1210,7 @@ async fn history_worlds_child_mutations_share_only_the_child_window_history() {
r##"
history.replaceState({owner: 'top'}, '', '#top');
const frame = document.createElement('iframe');
frame.srcdoc = '<p>History owner</p>';
(document.body || document.documentElement || document).appendChild(frame);
void frame.contentWindow;
'created'
@@ -1224,6 +1225,11 @@ async fn history_worlds_child_mutations_share_only_the_child_window_history() {
.await
.unwrap()
);
// Traversal needs a committed Document: pushState replaces the entry of
// the initial empty Document rather than appending one.
vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128)
.await
.unwrap();
let child = vm
.live_child_default_runtime_realm_inventory()
.into_iter()
@@ -1239,7 +1245,7 @@ async fn history_worlds_child_mutations_share_only_the_child_window_history() {
vm.eval_in_child_default_context(
child,
r##"
history.replaceState({owner: 'child'}, '', '#child');
history.replaceState({owner: 'child'}, '', 'about:srcdoc#child');
history.scrollRestoration = 'manual';
'ready'
"##,
@@ -1259,7 +1265,7 @@ async fn history_worlds_child_mutations_share_only_the_child_window_history() {
vm.eval_in_isolated_context(
isolated,
r##"
history.pushState({owner: 'isolated-child'}, '', '#next');
history.pushState({owner: 'isolated-child'}, '', 'about:srcdoc#next');
history.scrollRestoration = 'auto';
'pushed'
"##,
@@ -1353,6 +1359,8 @@ async fn history_worlds_preserve_the_shared_backing_when_initial_child_window_is
vm.eval(
r#"
const frame = document.createElement('iframe');
// Keep the initial empty Window pending until the srcdoc commit.
frame.srcdoc = '<p>pending initial load</p>';
(document.body || document.documentElement || document).appendChild(frame);
void frame.contentWindow;
"#,
@@ -1376,7 +1384,7 @@ async fn history_worlds_preserve_the_shared_backing_when_initial_child_window_is
vm.eval_in_isolated_context(
isolated,
r##"
originalHistory.replaceState({rebound: true}, '', '#rebound');
originalHistory.replaceState({rebound: true}, '', 'about:srcdoc#rebound');
'replaced'
"##,
)
@@ -1432,7 +1440,7 @@ fn history_native_snapshot_survives_isolate_replacement_with_structured_values()
history.scrollRestoration = 'manual';
navigation.updateCurrentEntry({state: new Set([7n])});
history.state.map.set('answer', 99n);
location.href = '/next';
navigation.navigate('/next', {history: 'push'});
'queued'
"##).unwrap();
let pending = vm.take_pending_location_navigation_with_seed().unwrap();
@@ -1476,7 +1484,7 @@ fn history_native_snapshot_survives_isolate_replacement_with_structured_values()
}
#[test]
fn history_restored_entries_keep_world_identity_and_refresh_reused_ids() {
fn history_restored_sparse_entries_keep_world_identity_and_refresh_reused_ids() {
const ENTRIES: &str = r#"JSON.stringify(navigation.entries()
.filter(entry => entry.url.includes('#'))
.map(entry => [entry.url, entry.id, entry.key, entry.index, entry.sameDocument,
@@ -1496,7 +1504,8 @@ fn history_restored_entries_keep_world_identity_and_refresh_reused_ids() {
)
.unwrap();
let expected = vm.eval(ENTRIES).unwrap();
vm.eval("location.href = '/next'; 'queued'").unwrap();
vm.eval("navigation.navigate('/next', {history: 'push'}); 'queued'")
.unwrap();
let mut seed = vm
.take_pending_location_navigation_with_seed()
.unwrap()
@@ -1509,7 +1518,13 @@ fn history_restored_entries_keep_world_identity_and_refresh_reused_ids() {
.unwrap()
.history_index;
seed.activation = None;
// Snapshot order is not the history position stored in each snapshot.
// Snapshot positions may be sparse and do not follow serialization order.
// The current serialized position still fits in the dense vector, where
// using it as an offset would silently select the wrong entry.
seed.current_index *= 3;
for entry in &mut seed.entries {
entry.history_index *= 3;
}
seed.entries.reverse();
(seed, expected)
};
@@ -1595,5 +1610,84 @@ fn history_native_fragment_navigation_preserves_structured_navigation_state() {
"##,
)
.unwrap();
assert_eq!(vm.eval("String(history.state.value === 5n && navigation.currentEntry.getState().get('value') === 9n && retained.getState().get('value') === 9n)").unwrap(), "true");
assert_eq!(vm.eval("String(history.state === null && navigation.currentEntry.getState().get('value') === 9n && retained.getState().get('value') === 9n)").unwrap(), "true");
}
#[test]
fn event_worlds_beforeunload_return_value_uses_shared_backing() {
let mut vm = new_storage_test_vm("https://example.com/base");
vm.eval(
r#"
globalThis.event = document.createEvent('BeforeUnloadEvent');
event.initEvent('beforeunload', false, true);
event.returnValue = 'original';
event.pageOnly = true;
'ready'
"#,
)
.unwrap();
let isolated = vm
.create_isolated_world("beforeunload-state", false)
.unwrap();
vm.eval_in_isolated_context(
isolated,
r#"
globalThis.facts = [];
navigation.addEventListener('beforeunload', event => {
facts.push(event instanceof BeforeUnloadEvent, event.returnValue,
event.pageOnly === undefined);
event.returnValue = 'updated';
event.preventDefault();
}, {once: true});
'ready'
"#,
)
.unwrap();
assert_eq!(vm.eval("JSON.stringify([navigation.dispatchEvent(event), event.returnValue, event.defaultPrevented])").unwrap(),
r#"[false,"updated",true]"#);
assert_eq!(
vm.eval_in_isolated_context(isolated, "JSON.stringify(facts)")
.unwrap(),
r#"[true,"original",true]"#
);
}
#[tokio::test]
async fn event_worlds_precommit_controllers_expire_after_navigation_settles() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
let mut vm = new_storage_test_vm_with_loader("https://example.com/base", &loader);
let isolated = vm.create_isolated_world("precommit-state", false).unwrap();
vm.eval_in_isolated_context(
isolated,
r##"
globalThis.handlers = 0;
navigation.addEventListener('navigate', event => {
event.intercept({precommitHandler(controller) {
globalThis.savedController = controller;
controller.addHandler(() => { handlers++; });
}});
}, {once: true});
'ready'
"##,
)
.unwrap();
vm.eval("navigation.navigate('#next'); 'started'").unwrap();
vm.advance_timers_until_deadline_for_test(&loader)
.await
.unwrap();
assert_eq!(
vm.eval_in_isolated_context(
isolated,
r#"
(() => {
let errorName = '';
try { savedController.addHandler(() => {}); }
catch (error) { errorName = error.name; }
return JSON.stringify([location.hash, handlers, errorName]);
})()
"#
)
.unwrap(),
r##"["#next",1,"InvalidStateError"]"##
);
}
@@ -1246,83 +1246,6 @@ async fn navigation_destination_uses_native_webidl_receivers_and_live_entry_stat
}
}
#[tokio::test]
async fn named_element_navigation_prefers_its_source_frame_over_duplicate_names() {
for action in ["anchor", "submit", "requestSubmit"] {
for name in ["initial", "renamed", "shadowed"] {
let server =
StaticHttpServer::spawn_with_bodies(vec!["<!doctype html><body>selected".into()])
.await;
let base = server.base_url().origin().ascii_serialization();
let loader = static_http_loader([]);
let mut vm = new_storage_page_task_executor_test_vm_with_loader(
&format!("{base}/parent"),
&loader,
);
vm.eval(&format!(
r#"
const earlier = document.createElement('iframe'); earlier.name = 'same';
const source = document.createElement('iframe');
source.name = {name:?} === 'renamed' ? 'old-name' : 'same';
document.body.append(earlier, source);
const childDocument = source.contentDocument;
const nested = childDocument.createElement('iframe'); nested.name = 'same';
childDocument.body.append(nested);
globalThis.originalDocument = childDocument;
globalThis.nameReads = 0;
if ({name:?} === 'renamed') source.contentWindow.name = 'same';
if ({name:?} === 'shadowed') Object.defineProperty(source.contentWindow, 'name', {{
get() {{ ++nameReads; throw new Error('script-visible name was read'); }}
}});
globalThis.namedAccessIsChild = source.contentWindow.same === nested.contentWindow;
"#
))
.unwrap();
vm.drain_ready_page_task_executor_turns_for_setup(&loader, 100)
.await
.unwrap();
vm.eval(&format!(
r#"
const element = childDocument.createElement({action:?} === 'anchor' ? 'a' : 'form');
element.target = 'same';
if ({action:?} === 'anchor') element.href = '/selected?from=anchor';
else {{
element.action = '/selected';
element.innerHTML = '<input name=from value="{action}">';
}}
childDocument.body.append(element);
if ({action:?} === 'anchor') element.click();
else element[{action:?}]();
"#
))
.unwrap();
advance_page_task_executor_until_eval_equals(
&mut vm,
&loader,
"String(source.contentDocument.body?.textContent === 'selected')",
"true",
&format!("{action}, {name}"),
)
.await;
assert_eq!(
vm.eval(
"JSON.stringify([source.contentDocument !== originalDocument, \
source.contentWindow.length, earlier.contentWindow.location.href, \
namedAccessIsChild, nameReads])",
)
.unwrap(),
r#"[true,0,"about:blank",true,0]"#,
"{action}, {name}",
);
assert_eq!(
server.finish_targets().await,
[format!("/selected?from={action}")],
"{action}, {name}",
);
}
}
}
#[tokio::test]
async fn document_open_preserves_navigation_initialization() {
for kind in ["iframe", "popup"] {
@@ -1362,7 +1285,7 @@ async fn document_open_preserves_navigation_initialization() {
"snapshots": snapshots,
"navigated": { "current": "about:blank", "state": 4 },
"loadedOpen": {
"sameNavigation": true, "sameEntryKey": true, "state": 5,
"sameNavigation": true, "sameEntryKey": true, "newPublicId": true, "state": 5,
},
}),
"{kind}"
@@ -386,12 +386,11 @@ else frame.setAttribute('sandbox', {changed});
"history.replaceState(0, ''); history.pushState(1, ''); frame.contentWindow.eval('history.back()');",
)
.unwrap();
assert_eq!(
assert!(
vm.run_one_history_traversal_executor_turn(&loader)
.await
.unwrap(),
allowed,
"initial={initial}, changed={changed}, navigated={navigate}"
"the traversal task evaluates the active sandbox: initial={initial}, changed={changed}, navigated={navigate}"
);
assert_eq!(
vm.eval("String(history.state)").unwrap(),
@@ -346,6 +346,11 @@ frame.contentWindow;
] {
vm.eval(&format!("frame.contentWindow.history.{call}"))
.unwrap();
assert!(
vm.run_one_history_traversal_executor_turn(&loader)
.await
.unwrap()
);
let pending = vm
.take_pending_top_level_history_traversal()
.map(|pending| pending.delta);
@@ -358,6 +363,11 @@ frame.contentWindow;
// A method borrowed from the child still uses its top-level History receiver.
vm.eval("frame.contentWindow.history.back.call(history)")
.unwrap();
assert!(
vm.run_one_history_traversal_executor_turn(&loader)
.await
.unwrap()
);
assert_eq!(
vm.take_pending_top_level_history_traversal().unwrap().delta,
-1
@@ -1075,10 +1075,10 @@ fn computed_used_size_clamps_infinite_negative_math_to_zero() {
fn computed_style_child_document_media_queries_use_iframe_viewport() {
let mut vm = new_storage_test_vm("https://child-document-media-query.test/");
let result = vm
.eval(
r#"
(() => {
let result = eval_with_layout_publications(
&mut vm,
r#"
(function* () {
if (!document.documentElement) {
document.appendChild(document.createElement('html'));
}
@@ -1096,12 +1096,13 @@ fn computed_style_child_document_media_queries_use_iframe_viewport() {
document.body.offsetTop;
const before = getComputedStyle(childDocument.body).color;
frame.style.width = '200px';
yield; // Publish the new viewport before evaluating the child's media query.
const after = getComputedStyle(childDocument.body).color;
return `${before}|${after}`;
})()
"#,
)
.expect("child document media queries should use iframe viewport");
)
.expect("child document media queries should use iframe viewport");
assert_eq!(result, "rgb(255, 0, 0)|rgb(0, 128, 0)");
}
@@ -424,5 +424,5 @@ async fn cors_preflight_permissions_reach_window_fetch_and_xhr() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cors_preflight_permissions_reach_worker_fetch_and_xhr() {
check_preflight_permissions(true, &["fetch", "xhr"]).await;
check_preflight_permissions(true, &["fetch", "xhr", "sync-xhr"]).await;
}
@@ -558,8 +558,8 @@ async fn dialog_form_submission_closes_with_submitter_result_and_queues_reentran
);
assert!(
!vm.has_ready_timeout(),
"dialog close must not create a synthetic Page timer"
!vm.has_ready_callback_timer(),
"dialog close must not create a callback timer; focus rendering wakes are separate"
);
assert!(
vm.run_one_dom_manipulation_task_executor_turn(
+1 -1
View File
@@ -2246,6 +2246,7 @@ mod geometry_point_conversion_order;
mod headers_list;
mod history_document_identity;
mod history_referrer;
mod history_replace_forward;
mod http_fixture;
mod media_owner_playback_interfaces;
mod media_recorder_shell;
@@ -2310,7 +2311,6 @@ mod webidl_collections;
mod webidl_fetch;
mod webidl_receivers;
mod webidl_trusted_types;
mod history_replace_forward;
mod websocket;
mod window_cross_origin_getters;
mod window_event_receivers;
@@ -151,7 +151,7 @@ observer.observe(popupTarget);
}
#[tokio::test(flavor = "current_thread")]
async fn popup_iframe_invalidation_preserves_the_opener_layout() {
async fn popup_iframe_resize_preserves_the_opener_layout() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
let mut vm = new_storage_page_task_executor_test_vm("https://popup-frame-layout.test/");
vm.eval(
@@ -162,6 +162,8 @@ mainTarget.style.cssText = 'width:80px;height:20px';
globalThis.popupTarget = popup.document.body.appendChild(popup.document.createElement('div'));
popupTarget.style.cssText = 'width:40px;height:20px';
globalThis.frame = popup.document.body.appendChild(popup.document.createElement('iframe'));
frame.style.border = '0';
frame.setAttribute('width', '300');
frame.contentWindow.document.body.innerHTML = '<div style="width:20px;height:10px">child</div>';
globalThis.observer = new ResizeObserver(() => {});
observer.observe(mainTarget);
@@ -173,7 +175,15 @@ observer.observe(popupTarget);
.await
.unwrap();
assert_eq!(vm.eval("mainTarget.getBoundingClientRect().width + ':' + popupTarget.getBoundingClientRect().width").unwrap(), "80:40");
assert_eq!(
vm.eval("frame.getBoundingClientRect().width").unwrap(),
"300"
);
vm.eval("frame.setAttribute('width', '200');").unwrap();
assert_eq!(
vm.eval("frame.getBoundingClientRect().width").unwrap(),
"300"
);
vm.with_default_context_scope_and_checkpoint_for_test(|scope, host_ptr| {
let global = scope.get_current_context().global(scope);
let key = v8::String::new(scope, "popupTarget").unwrap();
@@ -188,8 +198,8 @@ observer.observe(popupTarget);
let document = host.dom_host().owner_document_handle(handle).unwrap();
assert!(
host.with_latest_layout_tree_for_document(document, |_| ())
.is_none(),
"changing iframe width must invalidate its popup snapshot"
.is_some(),
"changing iframe width must retain the published popup snapshot until rendering"
);
assert!(
host.with_latest_layout_tree_for_document(host.document_handle(), |_| ())
@@ -203,6 +213,10 @@ observer.observe(popupTarget);
.await
.unwrap();
assert_eq!(vm.eval("mainTarget.getBoundingClientRect().width + ':' + popupTarget.getBoundingClientRect().width").unwrap(), "80:40");
assert_eq!(
vm.eval("frame.getBoundingClientRect().width").unwrap(),
"200"
);
vm.eval("frame.remove();").unwrap();
vm.with_default_context_scope_and_checkpoint_for_test(|_scope, host_ptr| {
let host = unsafe { &*host_ptr };
+1 -1
View File
@@ -9,7 +9,7 @@ pub use moli_v8_util::{
global_constructor_object, global_constructor_prototype,
initialize_ecmascript_intrinsic_registry, new_null_prototype_object, object_bool_property,
object_chain_contains, object_defined_string_property, object_non_empty_string_property,
object_number_property, object_own_static_bool_property, object_own_static_property_as_array,
object_number_property, object_own_static_bool_property,
object_own_static_string_property, object_property_as_array, object_property_as_object,
object_string_property, private_key, register_ecmascript_intrinsic,
registered_ecmascript_constructor, registered_ecmascript_prototype, set_null_prototype,
@@ -109,8 +109,8 @@ const DIRECT_V8_CALL_ALLOWLIST: &[AllowedDirectCallFile] = &[
),
allowed(
"context_bootstrap/navigation_callbacks/navigation.rs",
// Three settlement calls now go through the existing shared helpers.
8,
// Fulfillment paths share the realm-aware lifecycle settlement helper.
7,
DirectCallOwner::BrowserAlgorithm,
),
allowed(
+5 -1
View File
@@ -476,7 +476,11 @@ pub(super) fn event_target_dispatch_event_callback<'s>(
event_type.as_deref().unwrap_or_default(),
event,
);
Ok(!crate::context_bootstrap::event_bool_attribute(scope, event, "defaultPrevented"))
Ok(!crate::context_bootstrap::event_bool_attribute(
scope,
event,
"defaultPrevented",
))
} else {
host.dispatch_public_event(scope, host_ptr, EventTargetHandle::Window, event)
.map(|dispatch| {
@@ -48,6 +48,7 @@ globalThis.navigationDocumentOpenProbe = async function(kind) {
state: loadedNav.currentEntry.getState().value
};
const key = loadedNav.currentEntry.key;
const id = loadedNav.currentEntry.id;
child.document.open();
child.document.write('<p>loaded stream</p>');
child.document.close();
@@ -55,6 +56,7 @@ globalThis.navigationDocumentOpenProbe = async function(kind) {
const loadedOpen = {
sameNavigation: child.navigation === loadedNav,
sameEntryKey: loadedNav.currentEntry.key === key,
newPublicId: loadedNav.currentEntry.id !== id && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(loadedNav.currentEntry.id),
state: loadedNav.currentEntry.getState().value
};
return {snapshots, navigated, loadedOpen};