diff --git a/moli-core/tests/wpt_compat.rs b/moli-core/tests/wpt_compat.rs index 91f507949..9a43d9da1 100644 --- a/moli-core/tests/wpt_compat.rs +++ b/moli-core/tests/wpt_compat.rs @@ -102,6 +102,10 @@ wpt_compat_cases! { wpt_compat_case_upstream_navigation_api_precommit_handler_precommithandler_reload => "upstream-navigation-api-precommit-handler-precommithandler-reload", wpt_compat_case_upstream_navigation_api_precommit_handler_precommithandler_replace => "upstream-navigation-api-precommit-handler-precommithandler-replace", wpt_compat_case_upstream_navigation_api_precommit_handler_precommithandler_traverse => "upstream-navigation-api-precommit-handler-precommithandler-traverse", + wpt_compat_case_upstream_navigation_api_ordering_and_transition_back_same_document_intercept_reject_no_currententrychange => "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-reject-no-currententrychange", + wpt_compat_case_upstream_navigation_api_ordering_and_transition_back_same_document_intercept_reject_currententrychange => "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-reject-currententrychange", + wpt_compat_case_upstream_navigation_api_ordering_and_transition_back_same_document_intercept_no_currententrychange => "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-no-currententrychange", + wpt_compat_case_upstream_navigation_api_ordering_and_transition_back_same_document_intercept_currententrychange => "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-currententrychange", wpt_compat_case_navigation_currententrychange_ignores_page_tampered_dispatch_basic => "navigation-currententrychange-ignores-page-tampered-dispatch-basic", wpt_compat_case_navigation_update_current_entry_event_surface_basic => "navigation-update-current-entry-event-surface-basic", wpt_compat_case_navigation_cross_document_currententrychange_quietness_basic => "navigation-cross-document-currententrychange-quietness-basic", diff --git a/moli-protocol/src/domains/page/tests/same_document_history.rs b/moli-protocol/src/domains/page/tests/same_document_history.rs index 2f5e4274d..d45b5ad5b 100644 --- a/moli-protocol/src/domains/page/tests/same_document_history.rs +++ b/moli-protocol/src/domains/page/tests/same_document_history.rs @@ -182,6 +182,322 @@ impl SameDocumentPage { } } +#[tokio::test(flavor = "multi_thread")] +async fn intercepted_traversal_transitions_track_commit_and_completion() { + for precommit in [false, true] { + for mode in ["empty", "sync", "async", "reject", "undefined"] { + let mut page = SameDocumentPage::new().await; + page.run( + "history.pushState(null, '', '#back'); history.pushState(null, '', '#current'); void 0", + "#current", + ).await; + page.ctx.sent.clear(); + let script = r#" + (async () => { + const mode = MODE; + const precommit = PRECOMMIT; + const order = []; + const checks = []; + const from = navigation.currentEntry; + const error = mode === 'undefined' ? undefined : new Error('traversal failure'); + const fails = mode === 'reject' || mode === 'undefined'; + let transition; + let event; + let transitionCommitted; + let transitionFinished; + const capture = phase => { + order.push(phase); + const current = navigation.transition; + checks.push(current !== null && current.from === from && + current.navigationType === 'traverse' && current.to === event.destination); + if (!transition) { + transition = current; + transitionCommitted = transition?.committed.then(value => value === undefined); + transitionFinished = transition?.finished.then( + value => { order.push('transition finished'); return !fails && value === undefined; }, + reason => { order.push('transition finished'); return fails && reason === error; } + ); + } + checks.push(current === transition); + }; + navigation.addEventListener('navigate', e => { + event = e; + order.push('navigate'); + checks.push(navigation.transition === null); + const options = {}; + if (precommit) options.precommitHandler = () => { + capture('precommit'); + checks.push(location.hash === '#current'); + return new Promise(resolve => setTimeout(resolve, 0)); + }; + if (mode !== 'empty') options.handler = () => { + capture('handler'); + checks.push(location.hash === '#back'); + if (fails) return Promise.reject(error); + if (mode === 'async') return new Promise(resolve => setTimeout(resolve, 0)); + }; + e.intercept(options); + e.signal.addEventListener('abort', () => checks.push(fails && e.signal.reason === error)); + }, {once: true}); + navigation.addEventListener('currententrychange', () => capture('currententrychange'), {once: true}); + navigation.addEventListener('navigatesuccess', () => capture('success'), {once: true}); + navigation.addEventListener('navigateerror', e => { + capture('error'); + checks.push(fails && e.error === error); + }, {once: true}); + const result = navigation.back(); + const committed = result.committed.then(entry => { + capture('committed'); + return entry === navigation.currentEntry; + }); + const finished = result.finished.then( + entry => { order.push('finished'); return !fails && entry === navigation.currentEntry && navigation.transition === null; }, + reason => { order.push('finished'); return fails && reason === error && navigation.transition === null; } + ); + checks.push(await committed, await finished); + checks.push(await transitionCommitted, await transitionFinished); + return {order, checks}; + })() + "#.replace("MODE", &json!(mode).to_string()) + .replace("PRECOMMIT", if precommit { "true" } else { "false" }); + let result = page.evaluate(&script).await; + let mut expected = vec!["navigate"]; + if precommit { + expected.push("precommit"); + } + expected.push("currententrychange"); + if mode != "empty" { + expected.push("handler"); + } + expected.extend([ + "committed", + if matches!(mode, "reject" | "undefined") { + "error" + } else { + "success" + }, + "finished", + "transition finished", + ]); + assert_eq!( + result["order"], + json!(expected), + "{mode}, precommit={precommit}" + ); + assert!( + result["checks"] + .as_array() + .unwrap() + .iter() + .all(|value| value == true), + "{mode}, precommit={precommit}: {result}" + ); + page.assert_history(&["", "#back", "#current"], 1).await; + page.assert_commits(&[("#back", "other")]); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn intercepted_traversal_transitions_settle_when_canceled() { + for phase in ["precommit", "handler"] { + for action in ["stop", "navigate"] { + let mut page = SameDocumentPage::new().await; + let script = r#"(async () => { + const phase = PHASE; + const action = ACTION; + history.pushState(null, '', '#back'); + history.pushState(null, '', '#current'); + await new Promise(resolve=>setTimeout(resolve,0)); + const from = navigation.currentEntry; + let ready; + const started = new Promise(resolve => ready = resolve); + let transition, signal, release; + const observed = {committed: 'pending', finished: 'pending', transitionCommitted: 'pending', transitionFinished: 'pending'}; + navigation.addEventListener('navigate', event => { + signal = event.signal; + const handler = () => { + transition = navigation.transition; + observed.transitionPresent = transition !== null; + observed.from = transition?.from === from; + transition?.committed?.then(() => observed.transitionCommitted = 'resolved', reason => observed.transitionCommitted = reason.name); + transition?.finished.then(() => observed.transitionFinished = 'resolved', reason => observed.transitionFinished = reason.name); + ready(); + return new Promise(resolve => release = resolve); + }; + event.intercept(phase === 'precommit' ? {precommitHandler: handler} : {handler}); + }, {once: true}); + const result = navigation.back(); + result.committed.then(() => observed.committed = 'resolved', reason => observed.committed = reason.name); + result.finished.then(() => observed.finished = 'resolved', reason => observed.finished = reason.name); + await started; + if (action === 'stop') window.stop(); + else await navigation.navigate('#nested').finished; + await result.finished.catch(() => {}); + release(); + await new Promise(resolve => setTimeout(resolve, 0)); + observed.currentTransition = navigation.transition; + observed.hash = location.hash; + observed.aborted = signal.aborted; + return observed; +})() +"# + .replace("PHASE", &json!(phase).to_string()) + .replace("ACTION", &json!(action).to_string()); + let result = page.evaluate(&script).await; + let committed = if phase == "precommit" { + "AbortError" + } else { + "resolved" + }; + let hash = if action == "navigate" { + "#nested" + } else if phase == "precommit" { + "#current" + } else { + "#back" + }; + assert_eq!( + result, + json!({ + "committed": committed, + "finished": "AbortError", + "transitionCommitted": committed, + "transitionFinished": "AbortError", + "transitionPresent": true, + "from": true, + "currentTransition": null, + "hash": hash, + "aborted": true, + }), + "phase={phase}, action={action}" + ); + if action == "stop" { + page.assert_history( + &["", "#back", "#current"], + if phase == "precommit" { 2 } else { 1 }, + ) + .await; + } else if phase == "precommit" { + page.assert_history(&["", "#back", "#current", "#nested"], 3) + .await; + } else { + page.assert_history(&["", "#back", "#nested"], 2).await; + } + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn intercepted_traversal_transitions_reject_before_commit() { + for undefined in [false, true] { + let mut page = SameDocumentPage::new().await; + page.run( + "history.pushState(null, '', '#current'); void 0", + "#current", + ) + .await; + page.ctx.sent.clear(); + let script = r#"(async () => { + const expected = UNDEFINED ? undefined : new Error('precommit failure'); + const from = navigation.currentEntry; + const checks = []; + let transition; + let handlerRan = false; + navigation.addEventListener('navigate', event => { + event.signal.addEventListener('abort', () => checks.push(event.signal.reason === expected)); + event.intercept({ + precommitHandler() { + transition = navigation.transition; + return new Promise((_, reject) => setTimeout(() => reject(expected), 0)); + }, + handler() { handlerRan = true; } + }); + }, {once: true}); + const result = navigation.back(); + const rejectedWithExpected = promise => promise.then(() => false, reason => reason === expected); + checks.push(...await Promise.all([result.committed, result.finished].map(rejectedWithExpected))); + checks.push(...await Promise.all([transition.committed, transition.finished].map(rejectedWithExpected))); + checks.push(!handlerRan, navigation.currentEntry === from, navigation.transition === null); + return checks; +})() +"#.replace("UNDEFINED", if undefined { "true" } else { "false" }); + let result = page.evaluate(&script).await; + assert_eq!( + result, + json!([true, true, true, true, true, true, true, true]), + "undefined={undefined}" + ); + page.assert_history(&["", "#current"], 1).await; + page.assert_commits(&[]); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn intercepted_traversal_transitions_preserve_navigation_started_during_completion() { + for trigger in ["success", "error", "abort"] { + let mut page = SameDocumentPage::new().await; + page.run( + "history.pushState(null, '', '#back'); history.pushState(null, '', '#current'); void 0", + "#current", + ) + .await; + page.ctx.sent.clear(); + let script = r#"(async () => { + const trigger = TRIGGER; + const error = new Error('expected'); + const observed = {}; + let transition, nestedTransition, nested, release; + navigation.addEventListener('currententrychange', () => { + transition = navigation.transition; + transition?.finished.then(() => observed.oldFinished = 'resolved', reason => observed.oldFinished = reason === error ? 'expected' : reason.name); + }, {once: true}); + const startNested = () => { + navigation.addEventListener('navigate', e => e.intercept({handler() { + nestedTransition = navigation.transition; + return new Promise(resolve => release = resolve); + }}), {once: true}); + nested = navigation.navigate('#nested'); + nested.finished.then(() => observed.nestedFinished = true, reason => observed.nestedError = reason.name); + }; + navigation.addEventListener('navigate', e => { + if (trigger === 'abort') e.signal.addEventListener('abort', startNested, {once: true}); + e.intercept({handler() { + if (trigger !== 'success') return Promise.reject(error); + }}); + }, {once: true}); + if (trigger !== 'abort') navigation.addEventListener(trigger === 'success' ? 'navigatesuccess' : 'navigateerror', startNested, {once: true}); + const result = navigation.back(); + await result.finished.catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + observed.preservedNewTransition = navigation.transition === nestedTransition && nestedTransition !== null; + observed.distinct = nestedTransition !== transition; + observed.newPending = !observed.nestedFinished; + release(); + await nested.finished.catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + observed.cleared = navigation.transition === null; + return observed; +})() +"#.replace("TRIGGER", &json!(trigger).to_string()); + let result = page.evaluate(&script).await; + assert_eq!( + result, + json!({ + "oldFinished": if trigger == "success" { "resolved" } else { "expected" }, + "preservedNewTransition": true, + "distinct": true, + "newPending": true, + "nestedFinished": true, + "cleared": true, + }), + "trigger={trigger}" + ); + page.assert_history(&["", "#back", "#nested"], 2).await; + page.assert_commits(&[("#back", "other"), ("#nested", "other")]); + } +} + #[tokio::test(flavor = "multi_thread")] async fn same_document_commits_keep_navigation_api_and_browser_history_in_sync() { let mut page = SameDocumentPage::new().await; diff --git a/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs b/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs index 36ea15223..9bc878de3 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs @@ -1,6 +1,8 @@ use super::*; use crate::context_bootstrap::file_api::is_branded_data_transfer_object; -use crate::context_bootstrap::navigation_activation::install_navigation_transition; +use crate::context_bootstrap::navigation_activation::{ + NAVIGATE_EVENT_PRECOMMIT_TRANSITION_RESOLVER_SLOT, install_navigation_transition, +}; use crate::context_bootstrap::navigation_events::navigation_scroll_event_is_active; use crate::context_bootstrap::navigation_handler_callbacks::{ NAVIGATE_EVENT_ADDED_HANDLERS_SLOT, NAVIGATE_EVENT_DEFERRED_HANDLERS_SLOT, @@ -36,8 +38,6 @@ const NAVIGATE_EVENT_PRECOMMIT_TRANSITION_DESTINATION_SLOT: &str = "__lmNavigateEventPrecommitTransitionDestination"; const NAVIGATE_EVENT_PRECOMMIT_TRANSITION_TYPE_SLOT: &str = "__lmNavigateEventPrecommitTransitionType"; -const NAVIGATE_EVENT_PRECOMMIT_TRANSITION_RESOLVER_SLOT: &str = - "__lmNavigateEventPrecommitTransitionResolver"; const PRECOMMIT_CONTROLLER_EVENT_SLOT: &str = "__lmPrecommitControllerEvent"; const PRECOMMIT_CONTROLLER_ACTIVE_SLOT: &str = "__lmPrecommitControllerActive"; diff --git a/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs b/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs index 9abcd4217..5ee2be76d 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs @@ -1,3 +1,8 @@ +use super::super::navigation_activation::{ + install_navigation_transition, navigation_transition_matches_resolver, + precommit_transition_resolver_from_event, reject_navigation_transition_committed, + resolve_navigation_transition_committed, +}; use super::super::navigation_entry::{ history_entries, history_index, navigation_current_entry, navigation_entry_key_value, }; @@ -6,7 +11,9 @@ use super::super::navigation_events::{ dispatch_navigation_traverse_event_with_outcome, mark_navigation_outcome_default_prevented, run_navigation_precommit_deferred_handlers, }; -use super::super::navigation_lifecycle::finish_navigation_error_events; +use super::super::navigation_lifecycle::{ + finish_navigation_error_events, settle_navigation_transition_finished_local, +}; use super::super::navigation_result::{ navigation_dom_exception, perform_navigation_scroll_if_needed, suppress_unhandled_rejection, }; @@ -22,6 +29,7 @@ use super::apply::{ }; use super::results::{reject_pending_navigation_results, resolve_pending_navigation_results}; use crate::native_bridge::PendingHistoryTraversal; +use crate::script_cleanup::ScriptExecutionScope; use crate::util::{get_private_value, set_private_value}; use moli_webapi_declare::WebApiObject; @@ -46,6 +54,7 @@ const TRAVERSAL_INTERCEPT_VALUE_SLOT: &str = "__lmTraversalInterceptValue"; const TRAVERSAL_INTERCEPT_URL_SLOT: &str = "__lmTraversalInterceptUrl"; const TRAVERSAL_INTERCEPT_PROMISE_SLOT: &str = "__lmTraversalInterceptPromise"; const NAVIGATION_ACTIVE_TRAVERSAL_INTERCEPT_SLOT: &str = "__lmNavigationActiveTraversalIntercept"; +const TRAVERSAL_TRANSITION_RESOLVER_SLOT: &str = "__lmTraversalTransitionResolver"; #[derive(WebApiObject)] #[webapi(plain)] @@ -82,6 +91,9 @@ struct TraversalPrecommitDataDeclaration<'scope> { #[webapi(slot = TRAVERSAL_PRECOMMIT_FINISHED_RESOLVERS_SLOT)] finished_resolvers: v8::Local<'scope, v8::Array>, + + #[webapi(slot = TRAVERSAL_TRANSITION_RESOLVER_SLOT)] + transition_resolver: Option>, } #[derive(WebApiObject)] @@ -104,6 +116,33 @@ struct TraversalInterceptSettlementDataDeclaration<'scope> { #[webapi(slot = TRAVERSAL_INTERCEPT_URL_SLOT)] url: v8::Local<'scope, v8::String>, + + #[webapi(slot = TRAVERSAL_TRANSITION_RESOLVER_SLOT)] + transition_resolver: Option>, +} + +struct InterceptedHistoryTraversal<'s> { + joint_step: Option, + admission: Option, + navigation: v8::Local<'s, v8::Object>, + history: v8::Local<'s, v8::Object>, + target_index: u32, + event: Option>, + signal: Option>, + intercept_result: Option>, + committed_resolvers: v8::Local<'s, v8::Array>, + finished_resolvers: v8::Local<'s, v8::Array>, + transition_resolver: Option>, +} + +fn traversal_transition_resolver<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Value>, +) -> Option> { + let data = v8::Local::::try_from(data).ok()?; + get_private_value(scope, data, TRAVERSAL_TRANSITION_RESOLVER_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|object| unsafe { v8::Local::::cast_unchecked(object) }) } fn navigation_pending_traversal_precommit<'s>( @@ -321,6 +360,9 @@ pub(in crate::context_bootstrap) fn apply_pending_history_traversal( ) }) .unwrap_or_else(NavigationDispatchOutcome::proceed); + let transition_resolver = outcome + .precommit_event + .and_then(|event| precommit_transition_resolver_from_event(scope, event)); let owner_still_active = navigation_document_is_active(scope, owner); let target_still_available = target_entry_is_still_available( scope, @@ -330,18 +372,61 @@ pub(in crate::context_bootstrap) fn apply_pending_history_traversal( traversal.target_key.as_deref(), ); if let Some(error) = outcome.abort_error { + let _execution = ScriptExecutionScope::enter(scope); + if let Some(navigation) = navigation { + reject_traversal_transition_committed( + scope, + navigation, + transition_resolver, + error, + ); + settle_navigation_transition_finished_local( + scope, + navigation, + transition_resolver, + Some(error), + ); + } reject_pending_navigation_results(scope, &results, error); return; } if let Some(error) = outcome.precommit_error { + let _execution = ScriptExecutionScope::enter(scope); if let Some(navigation) = navigation { + reject_traversal_transition_committed( + scope, + navigation, + transition_resolver, + error, + ); finish_navigation_error_events(scope, navigation, error, ""); + settle_navigation_transition_finished_local( + scope, + navigation, + transition_resolver, + Some(error), + ); } reject_pending_navigation_results(scope, &results, error); return; } if !outcome.proceed || !owner_still_active || !target_still_available { + let _execution = ScriptExecutionScope::enter(scope); let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); + if let Some(navigation) = navigation { + reject_traversal_transition_committed( + scope, + navigation, + transition_resolver, + error, + ); + settle_navigation_transition_finished_local( + scope, + navigation, + transition_resolver, + Some(error), + ); + } mark_navigation_outcome_default_prevented(scope, &outcome); if let Some(signal) = outcome.signal && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) @@ -367,6 +452,17 @@ pub(in crate::context_bootstrap) fn apply_pending_history_traversal( if outcome.intercepted && let Some(navigation) = navigation { + let transition_resolver = transition_resolver.or_else(|| { + navigation_current_entry(scope, owner).and_then(|from| { + install_navigation_transition( + scope, + navigation, + from, + outcome.destination, + "traverse", + ) + }) + }); if outcome.precommit_result.is_some() { let queued = queue_pending_precommit_history_traversal( scope, @@ -376,85 +472,47 @@ pub(in crate::context_bootstrap) fn apply_pending_history_traversal( joint_plan.as_ref(), outcome, &results, + transition_resolver, ); if !queued { + let _execution = ScriptExecutionScope::enter(scope); let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); + reject_traversal_transition_committed( + scope, + navigation, + transition_resolver, + error, + ); finish_navigation_error_events(scope, navigation, error, ""); reject_pending_navigation_results(scope, &results, error); + settle_navigation_transition_finished_local( + scope, + navigation, + transition_resolver, + Some(error), + ); } return; } - let Some(applied) = apply_history_entry_commit( - scope, - history, - traversal.target_index, - joint_plan.as_ref(), - Some("other"), - ) else { - let error = - navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); - finish_navigation_error_events(scope, navigation, error, ""); - reject_pending_navigation_results(scope, &results, error); - return; - }; let (committed_resolvers, finished_resolvers) = pending_result_resolver_arrays(scope, &results); - resolve_resolver_array(scope, committed_resolvers, applied.resolved_entry); - let active_intercept = set_active_traversal_intercept_settlement( + commit_intercepted_history_traversal( scope, - navigation, - outcome.signal, - finished_resolvers, - applied.resolved_entry, - &applied.url, + InterceptedHistoryTraversal { + navigation, + history, + target_index: traversal.target_index, + joint_step: traversal.joint_step, + admission: joint_plan.as_ref().map(super::super::session_history::traversal_admission_signature), + event: outcome.precommit_event, + signal: outcome.signal, + intercept_result: outcome.intercept_result, + committed_resolvers, + finished_resolvers, + transition_resolver, + }, ); - dispatch_history_entry_currententrychange(scope, &applied); - let (intercept_error, intercept_result) = if let Some(event) = outcome.precommit_event { - run_navigation_precommit_deferred_handlers(scope, event) - } else { - (None, outcome.intercept_result) - }; - suppress_intercept_result_unhandled_rejection(scope, intercept_result); - dispatch_history_entry_post_commit_events(scope, &applied, true); - if !traversal_intercept_is_active(scope, active_intercept.into()) { - return; - } - if let Some(error) = intercept_error { - set_traversal_intercept_inactive(scope, navigation, active_intercept.into()); - finish_navigation_error_events(scope, navigation, error, &applied.url); - reject_resolver_array(scope, finished_resolvers, error, true); - if let Some(signal) = outcome.signal - && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) - { - unsafe { &mut *host_ptr }.abort_signal(scope, signal, error); - } - return; - } - let Some(result) = intercept_result else { - set_traversal_intercept_inactive(scope, navigation, active_intercept.into()); - perform_navigation_scroll_if_needed(scope, navigation, &applied.url, true); - dispatch_navigation_success(scope, navigation); - resolve_resolver_array(scope, finished_resolvers, applied.resolved_entry); - return; - }; - if !traversal_intercept_is_active(scope, active_intercept.into()) { - return; - } - set_traversal_intercept_inactive(scope, navigation, active_intercept.into()); - if !queue_pending_traversal_intercept_settlement( - scope, - navigation, - outcome.signal, - finished_resolvers, - applied.resolved_entry, - &applied.url, - result, - ) { - perform_navigation_scroll_if_needed(scope, navigation, &applied.url, true); - dispatch_navigation_success(scope, navigation); - resolve_resolver_array(scope, finished_resolvers, applied.resolved_entry); - } return; } let pending_results = (!results.is_empty()).then_some(results.as_slice()); @@ -629,6 +687,7 @@ fn queue_pending_precommit_history_traversal<'s>( plan: Option<&moli_session_history::SessionHistoryTraversalPlan>, outcome: NavigationDispatchOutcome<'s>, results: &[crate::native_bridge::PendingNavigationResult], + transition_resolver: Option>, ) -> bool { let Some(precommit_result) = outcome.precommit_result else { return false; @@ -665,6 +724,7 @@ fn queue_pending_precommit_history_traversal<'s>( promise, committed_resolvers, finished_resolvers, + transition_resolver, } .bind(scope) .expect("traversal precommit data should bind"); @@ -804,6 +864,9 @@ pub(in crate::context_bootstrap) fn cancel_pending_precommit_history_traversal<' set_traversal_precommit_inactive(scope, data); let error = navigation_dom_exception(scope, "Navigation was canceled before commit", "AbortError"); + let _execution = ScriptExecutionScope::enter(scope); + let transition_resolver = traversal_transition_resolver(scope, data.into()); + reject_traversal_transition_committed(scope, navigation, transition_resolver, error); if let Some(signal) = signal && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { @@ -812,6 +875,12 @@ pub(in crate::context_bootstrap) fn cancel_pending_precommit_history_traversal<' finish_navigation_error_events(scope, navigation, error, ""); reject_resolver_array(scope, committed_resolvers, error, false); reject_resolver_array(scope, finished_resolvers, error, true); + settle_navigation_transition_finished_local( + scope, + navigation, + transition_resolver, + Some(error), + ); true } @@ -849,76 +918,23 @@ fn traversal_precommit_fulfilled_callback<'s>( .and_then(|data| get_private_value(scope, data, TRAVERSAL_PRECOMMIT_ADMISSION_SLOT)) .and_then(|value| value.to_string(scope)) .map(|value| value.to_rust_string_lossy(scope)); - let owner = runtime_window_owner(scope, history); - let plan = joint_step - .and_then(|step| super::super::session_history::plan_traversal(scope, owner, step)) - .filter(|plan| { - Some(super::super::session_history::traversal_admission_signature(plan)) == admission - }); - let applied = if joint_step.is_some() && plan.is_none() { - None - } else { - apply_history_entry_commit(scope, history, target_index, plan.as_ref(), Some("other")) - }; - let Some(applied) = applied else { - let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); - finish_navigation_error_events(scope, navigation, error, ""); - reject_resolver_array(scope, committed_resolvers, error, false); - reject_resolver_array(scope, finished_resolvers, error, true); - return; - }; - resolve_resolver_array(scope, committed_resolvers, applied.resolved_entry); - let active_intercept = set_active_traversal_intercept_settlement( + let transition_resolver = traversal_transition_resolver(scope, args.data()); + commit_intercepted_history_traversal( scope, - navigation, - signal, - finished_resolvers, - applied.resolved_entry, - &applied.url, + InterceptedHistoryTraversal { + navigation, + history, + target_index, + joint_step, + admission, + event: Some(event), + signal, + intercept_result: None, + committed_resolvers, + finished_resolvers, + transition_resolver, + }, ); - dispatch_history_entry_currententrychange(scope, &applied); - let (intercept_error, intercept_result) = - run_navigation_precommit_deferred_handlers(scope, event); - suppress_intercept_result_unhandled_rejection(scope, intercept_result); - dispatch_history_entry_post_commit_events(scope, &applied, true); - if !traversal_intercept_is_active(scope, active_intercept.into()) { - return; - } - if let Some(error) = intercept_error { - set_traversal_intercept_inactive(scope, navigation, active_intercept.into()); - finish_navigation_error_events(scope, navigation, error, &applied.url); - reject_resolver_array(scope, finished_resolvers, error, true); - if let Some(signal) = signal - && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) - { - unsafe { &mut *host_ptr }.abort_signal(scope, signal, error); - } - return; - } - let Some(result) = intercept_result else { - set_traversal_intercept_inactive(scope, navigation, active_intercept.into()); - perform_navigation_scroll_if_needed(scope, navigation, &applied.url, true); - dispatch_navigation_success(scope, navigation); - resolve_resolver_array(scope, finished_resolvers, applied.resolved_entry); - return; - }; - if !traversal_intercept_is_active(scope, active_intercept.into()) { - return; - } - set_traversal_intercept_inactive(scope, navigation, active_intercept.into()); - if !queue_pending_traversal_intercept_settlement( - scope, - navigation, - signal, - finished_resolvers, - applied.resolved_entry, - &applied.url, - result, - ) { - perform_navigation_scroll_if_needed(scope, navigation, &applied.url, true); - dispatch_navigation_success(scope, navigation); - resolve_resolver_array(scope, finished_resolvers, applied.resolved_entry); - } } fn traversal_precommit_rejected_callback<'s>( @@ -941,6 +957,9 @@ fn traversal_precommit_rejected_callback<'s>( .filter(|promise| promise.state() == v8::PromiseState::Rejected) .map(|promise| promise.result(scope)) .unwrap_or_else(|| args.get(0)); + let _execution = ScriptExecutionScope::enter(scope); + let transition_resolver = traversal_transition_resolver(scope, args.data()); + reject_traversal_transition_committed(scope, navigation, transition_resolver, error); if let Some(signal) = signal && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { @@ -949,6 +968,105 @@ fn traversal_precommit_rejected_callback<'s>( finish_navigation_error_events(scope, navigation, error, ""); reject_resolver_array(scope, committed_resolvers, error, false); reject_resolver_array(scope, finished_resolvers, error, true); + settle_navigation_transition_finished_local( + scope, + navigation, + transition_resolver, + Some(error), + ); +} + +fn commit_intercepted_history_traversal<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: InterceptedHistoryTraversal<'s>, +) { + // HTML's "prepare to run script" keeps commit-time Promise reactions after + // currententrychange listeners and intercept handlers, even on a native task. + let execution = ScriptExecutionScope::enter(scope); + let owner = runtime_window_owner(scope, data.history); + let plan = data.joint_step + .and_then(|step| super::super::session_history::plan_traversal(scope, owner, step)) + .filter(|plan| Some(super::super::session_history::traversal_admission_signature(plan)) == data.admission); + let applied = if data.joint_step.is_some() && plan.is_none() { + None + } else { + apply_history_entry_commit(scope, data.history, data.target_index, plan.as_ref(), Some("other")) + }; + let Some(applied) = applied else { + let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); + reject_traversal_transition_committed( + scope, + data.navigation, + data.transition_resolver, + error, + ); + finish_navigation_error_events(scope, data.navigation, error, ""); + reject_resolver_array(scope, data.committed_resolvers, error, false); + reject_resolver_array(scope, data.finished_resolvers, error, true); + settle_navigation_transition_finished_local( + scope, + data.navigation, + data.transition_resolver, + Some(error), + ); + return; + }; + let url = v8_string(scope, &applied.url).unwrap_or_else(|| v8::String::empty(scope)); + let active_intercept = TraversalInterceptSettlementDataDeclaration { + active: true, + navigation: data.navigation, + signal: data.signal, + finished_resolvers: data.finished_resolvers, + value: applied.resolved_entry, + url, + transition_resolver: data.transition_resolver, + } + .bind(scope) + .expect("traversal intercept settlement data should bind"); + set_navigation_active_traversal_intercept(scope, data.navigation, active_intercept); + resolve_resolver_array(scope, data.committed_resolvers, applied.resolved_entry); + resolve_navigation_transition_committed(scope, data.navigation, v8::undefined(scope).into()); + dispatch_history_entry_currententrychange(scope, &applied); + let (intercept_error, intercept_result) = if let Some(event) = data.event { + run_navigation_precommit_deferred_handlers(scope, event) + } else { + (None, data.intercept_result) + }; + suppress_intercept_result_unhandled_rejection(scope, intercept_result); + drop(execution); + dispatch_history_entry_post_commit_events(scope, &applied, true); + if !traversal_intercept_is_active(scope, active_intercept.into()) { + return; + } + if let Some(error) = intercept_error { + finish_traversal_intercept(scope, active_intercept.into(), Some(error)); + } else { + // Waiting for an empty handler list is asynchronous too. In particular, + // a precommit Promise callback must not finish before committed reactions. + let result = intercept_result.or_else(|| { + let resolver = v8::PromiseResolver::new(scope)?; + resolver.resolve(scope, v8::undefined(scope).into())?; + Some(resolver.get_promise(scope).into()) + }); + if result.is_none_or(|result| { + !queue_pending_traversal_intercept_settlement(scope, active_intercept, result) + }) { + finish_traversal_intercept(scope, active_intercept.into(), None); + } + } +} + +fn reject_traversal_transition_committed<'s>( + scope: &mut v8::PinScope<'s, '_>, + navigation: v8::Local<'s, v8::Object>, + transition_resolver: Option>, + error: v8::Local<'s, v8::Value>, +) { + if transition_resolver + .is_some_and(|resolver| navigation_transition_matches_resolver(scope, navigation, resolver)) + { + reject_navigation_transition_committed(scope, navigation, error); + } } fn suppress_intercept_result_unhandled_rejection<'s>( @@ -964,11 +1082,7 @@ fn suppress_intercept_result_unhandled_rejection<'s>( fn queue_pending_traversal_intercept_settlement<'s>( scope: &mut v8::PinScope<'s, '_>, - navigation: v8::Local<'s, v8::Object>, - signal: Option>, - finished_resolvers: v8::Local<'s, v8::Array>, - resolved_value: v8::Local<'s, v8::Value>, - url: &str, + data: v8::Local<'s, v8::Object>, result: v8::Local<'s, v8::Value>, ) -> bool { let Some(result_object) = v8::Local::::try_from(result).ok() else { @@ -980,14 +1094,6 @@ fn queue_pending_traversal_intercept_settlement<'s>( else { return false; }; - let data = set_active_traversal_intercept_settlement( - scope, - navigation, - signal, - finished_resolvers, - resolved_value, - url, - ); if let Ok(promise) = v8::Local::::try_from(result) { suppress_unhandled_rejection(scope, promise); set_private_value(scope, data, TRAVERSAL_INTERCEPT_PROMISE_SLOT, result); @@ -1008,29 +1114,6 @@ fn queue_pending_traversal_intercept_settlement<'s>( .is_some() } -fn set_active_traversal_intercept_settlement<'s>( - scope: &mut v8::PinScope<'s, '_>, - navigation: v8::Local<'s, v8::Object>, - signal: Option>, - finished_resolvers: v8::Local<'s, v8::Array>, - resolved_value: v8::Local<'s, v8::Value>, - url: &str, -) -> v8::Local<'s, v8::Object> { - let url = v8_string(scope, url).unwrap_or_else(|| v8::String::empty(scope)); - let data = TraversalInterceptSettlementDataDeclaration { - active: true, - navigation, - signal, - finished_resolvers, - value: resolved_value, - url, - } - .bind(scope) - .expect("traversal intercept settlement data should bind"); - set_navigation_active_traversal_intercept(scope, navigation, data); - data -} - fn traversal_intercept_data<'s>( scope: &mut v8::PinScope<'s, '_>, data: v8::Local<'s, v8::Value>, @@ -1113,20 +1196,8 @@ pub(in crate::context_bootstrap) fn cancel_active_history_traversal_intercept_se else { return false; }; - let Some((navigation, signal, finished_resolvers, _, url, _)) = - traversal_intercept_data(scope, data.into()) - else { - return false; - }; - set_traversal_intercept_inactive(scope, navigation, data.into()); let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); - if let Some(signal) = signal - && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) - { - unsafe { &mut *host_ptr }.abort_signal(scope, signal, error); - } - finish_navigation_error_events(scope, navigation, error, &url); - reject_resolver_array(scope, finished_resolvers, error, true); + finish_traversal_intercept(scope, data.into(), Some(error)); true } @@ -1135,33 +1206,7 @@ fn traversal_intercept_fulfilled_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some((navigation, signal, finished_resolvers, resolved_value, url, _)) = - traversal_intercept_data(scope, args.data()) - else { - return; - }; - set_traversal_intercept_inactive(scope, navigation, args.data()); - let owner = runtime_window_owner(scope, navigation); - if !navigation_document_is_active(scope, owner) { - let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); - if let Some(signal) = signal - && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) - { - unsafe { &mut *host_ptr }.abort_signal(scope, signal, error); - } - let top_owner = runtime_top_window_owner(scope, owner); - let filename = window_location_for_holder(scope, top_owner) - .and_then(|location| { - super::super::location_runtime::location_href_slot(scope, location) - }) - .unwrap_or(url); - finish_navigation_error_events(scope, navigation, error, &filename); - reject_resolver_array(scope, finished_resolvers, error, true); - return; - } - perform_navigation_scroll_if_needed(scope, navigation, &url, true); - dispatch_navigation_success(scope, navigation); - resolve_resolver_array(scope, finished_resolvers, resolved_value); + finish_traversal_intercept(scope, args.data(), None); } fn traversal_intercept_rejected_callback<'s>( @@ -1169,23 +1214,58 @@ fn traversal_intercept_rejected_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some((navigation, signal, finished_resolvers, _, url, promise)) = - traversal_intercept_data(scope, args.data()) - else { + let Some((_, _, _, _, _, promise)) = traversal_intercept_data(scope, args.data()) else { return; }; - set_traversal_intercept_inactive(scope, navigation, args.data()); let error = promise .filter(|promise| promise.state() == v8::PromiseState::Rejected) .map(|promise| promise.result(scope)) .unwrap_or_else(|| args.get(0)); - if let Some(signal) = signal - && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) - { - unsafe { &mut *host_ptr }.abort_signal(scope, signal, error); + finish_traversal_intercept(scope, args.data(), Some(error)); +} + +fn finish_traversal_intercept<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Value>, + error: Option>, +) { + let Some((navigation, signal, finished_resolvers, resolved_value, url, _)) = + traversal_intercept_data(scope, data) + else { + return; + }; + let _execution = ScriptExecutionScope::enter(scope); + let transition_resolver = traversal_transition_resolver(scope, data); + set_traversal_intercept_inactive(scope, navigation, data); + let owner = runtime_window_owner(scope, navigation); + let active = navigation_document_is_active(scope, owner); + let error = error.or_else(|| { + (!active).then(|| navigation_dom_exception(scope, "Navigation was canceled", "AbortError")) + }); + if let Some(error) = error { + if let Some(signal) = signal + && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) + { + unsafe { &mut *host_ptr }.abort_signal(scope, signal, error); + } + let filename = if active { + url + } else { + let top_owner = runtime_top_window_owner(scope, owner); + window_location_for_holder(scope, top_owner) + .and_then(|location| { + super::super::location_runtime::location_href_slot(scope, location) + }) + .unwrap_or(url) + }; + finish_navigation_error_events(scope, navigation, error, &filename); + reject_resolver_array(scope, finished_resolvers, error, true); + } else { + perform_navigation_scroll_if_needed(scope, navigation, &url, true); + dispatch_navigation_success(scope, navigation); + resolve_resolver_array(scope, finished_resolvers, resolved_value); } - finish_navigation_error_events(scope, navigation, error, &url); - reject_resolver_array(scope, finished_resolvers, error, true); + settle_navigation_transition_finished_local(scope, navigation, transition_resolver, error); } pub(in crate::context_bootstrap) fn resolve_resolver_array<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_activation.rs b/moli-renderer-v8/src/context_bootstrap/navigation_activation.rs index 0a79ecbd0..15ba50943 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_activation.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_activation.rs @@ -16,6 +16,9 @@ use crate::util::{get_private_value, set_private_value}; use crate::web_api_interfaces; use moli_webapi_declare::WebApiObject; +pub(super) const NAVIGATE_EVENT_PRECOMMIT_TRANSITION_RESOLVER_SLOT: &str = + "__lmNavigateEventPrecommitTransitionResolver"; + #[derive(WebApiObject)] #[webapi( interface = web_api_interfaces::NavigationActivation, @@ -70,6 +73,9 @@ struct NavigationTransitionSettleDataDeclaration<'scope> { #[webapi(slot = NAVIGATION_TRANSITION_SETTLE_ERROR_SLOT)] error: Option>, + + #[webapi(slot = NAVIGATION_TRANSITION_SETTLE_REJECTED_SLOT)] + rejected: bool, } pub(super) fn install_navigation_activation_template_bindings<'s>( @@ -249,6 +255,19 @@ pub(super) fn resolve_navigation_transition_committed<'s>( } } +pub(super) fn precommit_transition_resolver_from_event<'s>( + scope: &mut v8::PinScope<'s, '_>, + event: v8::Local<'s, v8::Object>, +) -> Option> { + get_private_value( + scope, + event, + NAVIGATE_EVENT_PRECOMMIT_TRANSITION_RESOLVER_SLOT, + ) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|object| unsafe { v8::Local::::cast_unchecked(object) }) +} + pub(super) fn reject_navigation_transition_committed<'s>( scope: &mut v8::PinScope<'s, '_>, navigation: v8::Local<'s, v8::Object>, @@ -270,6 +289,7 @@ const NAVIGATION_TRANSITION_SETTLE_NAVIGATION_SLOT: &str = "__lmNavigationTransitionSettleNavigation"; const NAVIGATION_TRANSITION_SETTLE_RESOLVER_SLOT: &str = "__lmNavigationTransitionSettleResolver"; const NAVIGATION_TRANSITION_SETTLE_ERROR_SLOT: &str = "__lmNavigationTransitionSettleError"; +const NAVIGATION_TRANSITION_SETTLE_REJECTED_SLOT: &str = "__lmNavigationTransitionSettleRejected"; const NAVIGATION_TRANSITION_COMMITTED_RESOLVER_SLOT: &str = "__lmNavigationTransitionCommittedResolver"; @@ -304,6 +324,7 @@ pub(super) fn schedule_settle_navigation_transition<'s>( navigation, resolver, error, + rejected: error.is_some(), } .bind(scope) .expect("navigation transition settle data should bind"); @@ -311,7 +332,9 @@ pub(super) fn schedule_settle_navigation_transition<'s>( .data(data.into()) .build(scope) else { - clear_navigation_transition(scope, navigation); + if navigation_transition_matches_resolver(scope, navigation, resolver) { + clear_navigation_transition(scope, navigation); + } return; }; enqueue_navigation_lifecycle_microtask(scope, callback); @@ -340,9 +363,11 @@ fn settle_navigation_transition_callback<'s>( if navigation_transition_matches_resolver(scope, navigation, resolver) { clear_navigation_transition(scope, navigation); } - if let Some(error) = get_private_value(scope, data, NAVIGATION_TRANSITION_SETTLE_ERROR_SLOT) - .filter(|value| !value.is_undefined()) + if get_private_value(scope, data, NAVIGATION_TRANSITION_SETTLE_REJECTED_SLOT) + .is_some_and(|value| value.is_true()) { + let error = get_private_value(scope, data, NAVIGATION_TRANSITION_SETTLE_ERROR_SLOT) + .unwrap_or_else(|| v8::undefined(scope).into()); let _ = resolver.reject(scope, error); } else { let _ = resolver.resolve(scope, v8::undefined(scope).into()); diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_callbacks/navigation.rs b/moli-renderer-v8/src/context_bootstrap/navigation_callbacks/navigation.rs index e30e2d560..4e0b37a1d 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_callbacks/navigation.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_callbacks/navigation.rs @@ -1,6 +1,6 @@ use super::super::navigation_activation::{ clear_navigation_transition, install_navigation_transition, - navigation_transition_matches_resolver, reject_navigation_transition_committed, + navigation_transition_matches_resolver, precommit_transition_resolver_from_event, reject_navigation_transition_committed, resolve_navigation_transition_committed, }; use super::super::navigation_events::dispatch_popstate_event; @@ -22,9 +22,6 @@ use crate::webidl; use moli_url_policy::{LocalFileNavigationAccess, route_navigation_url}; use moli_webapi_declare::WebApiObject; -const NAVIGATE_EVENT_PRECOMMIT_TRANSITION_RESOLVER_SLOT: &str = - "__lmNavigateEventPrecommitTransitionResolver"; - #[derive(webidl::WebIdlDictionary)] #[webidl(prefix = "NavigationNavigateOptions")] struct NavigationNavigateOptionsMembers { @@ -1088,19 +1085,6 @@ fn cancel_precommit_commit_attempt<'s>( } } -fn precommit_transition_resolver_from_event<'s>( - scope: &mut v8::PinScope<'s, '_>, - event: v8::Local<'s, v8::Object>, -) -> Option> { - get_private_value( - scope, - event, - NAVIGATE_EVENT_PRECOMMIT_TRANSITION_RESOLVER_SLOT, - ) - .and_then(|value| v8::Local::::try_from(value).ok()) - .map(|object| unsafe { v8::Local::::cast_unchecked(object) }) -} - fn set_pending_precommit_commit_active<'s>( scope: &mut v8::PinScope<'s, '_>, navigation: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_lifecycle.rs b/moli-renderer-v8/src/context_bootstrap/navigation_lifecycle.rs index d4408a4d7..96e480c61 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_lifecycle.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_lifecycle.rs @@ -1,6 +1,6 @@ use super::navigation_activation::{ - clear_navigation_transition, resolve_navigation_transition_committed, - schedule_settle_navigation_transition, + clear_navigation_transition, navigation_transition_matches_resolver, + resolve_navigation_transition_committed, schedule_settle_navigation_transition, }; use super::navigation_events::{ NAVIGATE_EVENT_SCROLL_AFTER_TRANSITION_SLOT, NAVIGATE_EVENT_SCROLL_CALLED_SLOT, @@ -174,9 +174,6 @@ pub(super) fn settle_navigation_transition_finished<'s>( transition_resolver: Option>, error: Option>, ) { - if transition_resolver.is_some() { - clear_navigation_transition(scope, navigation); - } if let Some(transition_resolver) = transition_resolver { let transition_resolver = v8::Local::new(scope, transition_resolver); settle_navigation_transition_finished_local( @@ -194,10 +191,10 @@ pub(super) fn settle_navigation_transition_finished_local<'s>( transition_resolver: Option>, error: Option>, ) { - if transition_resolver.is_some() { - clear_navigation_transition(scope, navigation); - } if let Some(transition_resolver) = transition_resolver { + if navigation_transition_matches_resolver(scope, navigation, transition_resolver) { + clear_navigation_transition(scope, navigation); + } schedule_settle_navigation_transition(scope, navigation, transition_resolver, error); } } diff --git a/moli-wpt-compat/fixtures/wpt/manifest.toml b/moli-wpt-compat/fixtures/wpt/manifest.toml index bb19a6859..e677cdc38 100644 --- a/moli-wpt-compat/fixtures/wpt/manifest.toml +++ b/moli-wpt-compat/fixtures/wpt/manifest.toml @@ -20701,3 +20701,75 @@ source_path = "navigation-api/precommit-handler/precommitHandler-traverse.html" source_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" tags = ["navigation-api", "precommit-handler", "navigation", "history"] notes = "Imported upstream WPT test. Keep this in the broad suite until it is proven stable under the Moli harness." + +[[test]] +id = "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-reject-no-currententrychange" +upstream = "web-platform-tests/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html?no-currententrychange" +upstream_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +local_path = "upstream/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html" +type = "testharness" +global = "window" +status = "pass" +wait_until = "load" +timeout_ms = 5000 +suite = "broad" +source = "upstream-wpt" +source_path = "navigation-api/ordering-and-transition/back-same-document-intercept-reject.html" +source_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +tags = ["navigation-api", "ordering-and-transition", "navigation", "history"] +notes = "Unmodified upstream test for intercepted traversal transition lifetime and event/Promise ordering, including this explicit query variant." +query = "no-currententrychange" + +[[test]] +id = "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-reject-currententrychange" +upstream = "web-platform-tests/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html?currententrychange" +upstream_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +local_path = "upstream/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html" +type = "testharness" +global = "window" +status = "pass" +wait_until = "load" +timeout_ms = 5000 +suite = "broad" +source = "upstream-wpt" +source_path = "navigation-api/ordering-and-transition/back-same-document-intercept-reject.html" +source_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +tags = ["navigation-api", "ordering-and-transition", "navigation", "history"] +notes = "Unmodified upstream test for intercepted traversal transition lifetime and event/Promise ordering, including this explicit query variant." +query = "currententrychange" + +[[test]] +id = "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-no-currententrychange" +upstream = "web-platform-tests/navigation-api/ordering-and-transition/back-same-document-intercept.html?no-currententrychange" +upstream_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +local_path = "upstream/navigation-api/ordering-and-transition/back-same-document-intercept.html" +type = "testharness" +global = "window" +status = "pass" +wait_until = "load" +timeout_ms = 5000 +suite = "broad" +source = "upstream-wpt" +source_path = "navigation-api/ordering-and-transition/back-same-document-intercept.html" +source_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +tags = ["navigation-api", "ordering-and-transition", "navigation", "history"] +notes = "Unmodified upstream test for intercepted traversal transition lifetime and event/Promise ordering, including this explicit query variant." +query = "no-currententrychange" + +[[test]] +id = "upstream-navigation-api-ordering-and-transition-back-same-document-intercept-currententrychange" +upstream = "web-platform-tests/navigation-api/ordering-and-transition/back-same-document-intercept.html?currententrychange" +upstream_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +local_path = "upstream/navigation-api/ordering-and-transition/back-same-document-intercept.html" +type = "testharness" +global = "window" +status = "pass" +wait_until = "load" +timeout_ms = 5000 +suite = "broad" +source = "upstream-wpt" +source_path = "navigation-api/ordering-and-transition/back-same-document-intercept.html" +source_commit = "db95fafd1fcef8428805e41eb5705d444e8c67ce" +tags = ["navigation-api", "ordering-and-transition", "navigation", "history"] +notes = "Unmodified upstream test for intercepted traversal transition lifetime and event/Promise ordering, including this explicit query variant." +query = "currententrychange" diff --git a/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html b/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html new file mode 100644 index 000000000..f848c61ec --- /dev/null +++ b/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html @@ -0,0 +1,55 @@ + + + + + + + diff --git a/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept.html b/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept.html new file mode 100644 index 000000000..f66e1dfba --- /dev/null +++ b/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept.html @@ -0,0 +1,48 @@ + + + + + + + diff --git a/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/resources/helpers.mjs b/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/resources/helpers.mjs new file mode 100644 index 000000000..22752e4cf --- /dev/null +++ b/moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/resources/helpers.mjs @@ -0,0 +1,206 @@ +const variants = new Set((new URLSearchParams(location.search)).keys()); + +export function hasVariant(name) { + return variants.has(name); +} + +export class Recorder { + #events = []; + #errors = []; + #navigationAPI; + #domExceptionConstructor; + #location; + #skipCurrentChange; + #finalExpectedEvent; + #finalExpectedEventCount; + #currentFinalEventCount = 0; + + #readyToAssertResolve; + #readyToAssertPromise = new Promise(resolve => { this.#readyToAssertResolve = resolve; }); + + constructor({ window = self, skipCurrentChange = false, finalExpectedEvent, finalExpectedEventCount = 1 }) { + assert_equals(typeof finalExpectedEvent, "string", "Must pass a string for finalExpectedEvent"); + + this.#navigationAPI = window.navigation; + this.#domExceptionConstructor = window.DOMException; + this.#location = window.location; + + this.#skipCurrentChange = skipCurrentChange; + this.#finalExpectedEvent = finalExpectedEvent; + this.#finalExpectedEventCount = finalExpectedEventCount; + } + + setUpNavigationAPIListeners() { + this.#navigationAPI.addEventListener("navigate", e => { + this.record("navigate"); + + e.signal.addEventListener("abort", () => { + this.recordWithError("AbortSignal abort", e.signal.reason); + }); + }); + + this.#navigationAPI.addEventListener("navigateerror", e => { + this.recordWithError("navigateerror", e.error); + + this.#navigationAPI.transition?.finished.then( + () => this.record("transition.finished fulfilled"), + err => this.recordWithError("transition.finished rejected", err) + ); + }); + + this.#navigationAPI.addEventListener("navigatesuccess", () => { + this.record("navigatesuccess"); + + this.#navigationAPI.transition?.finished.then( + () => this.record("transition.finished fulfilled"), + err => this.recordWithError("transition.finished rejected", err) + ); + }); + + if (!this.#skipCurrentChange) { + this.#navigationAPI.addEventListener("currententrychange", () => this.record("currententrychange")); + } + } + + setUpResultListeners(result, suffix = "") { + + result.committed.then( + () => this.record(`committed fulfilled${suffix}`), + err => this.recordWithError(`committed rejected${suffix}`, err) + ); + + result.finished.then( + () => this.record(`finished fulfilled${suffix}`), + err => this.recordWithError(`finished rejected${suffix}`, err) + ); + + this.#navigationAPI.transition?.committed?.then( + () => this.record(`transition.committed fulfilled${suffix}`), + err => this.recordWithError(`transition.committed rejected${suffix}`, err) + ); + } + + record(name) { + const transitionProps = this.#navigationAPI.transition === null ? null : { + from: this.#navigationAPI.transition.from, + navigationType: this.#navigationAPI.transition.navigationType + }; + + this.#events.push({ name, location: this.#location.hash, transitionProps }); + + if (name === this.#finalExpectedEvent && ++this.#currentFinalEventCount === this.#finalExpectedEventCount) { + this.#readyToAssertResolve(); + } + } + + recordWithError(name, errorObject) { + this.record(name); + this.#errors.push({ name, errorObject }); + } + + get readyToAssert() { + return this.#readyToAssertPromise; + } + + // Usage: + // recorder.assert([ + // /* event name, location.hash value, navigation.transition properties */ + // ["currententrychange", "", null], + // ["committed fulfilled", "#1", { from, navigationType }], + // ... + // ]); + // + // The array format is to avoid repitition at the call site, but I recommend + // you document it like above. + // + // This will automatically also assert that any error objects recorded are + // equal to each other. Use the other assert functions to check the actual + // contents of the error objects. + assert(expectedAsArray) { + if (this.#skipCurrentChange) { + expectedAsArray = expectedAsArray.filter(expected => expected[0] !== "currententrychange"); + } + // TODO: Remove once https://github.com/whatwg/html/pull/10919 is merged. + if (!('committed' in NavigationTransition.prototype)) { + expectedAsArray = expectedAsArray.filter(expected => { + return !expected[0].includes("transition.committed fulfilled") && + !expected[0].includes("transition.committed rejected") + }); + } + + // Doing this up front gives nicer error messages because + // assert_array_equals is nice. + const recordedNames = this.#events.map(e => e.name); + const expectedNames = expectedAsArray.map(e => e[0]); + assert_array_equals(recordedNames, expectedNames); + + for (let i = 0; i < expectedAsArray.length; ++i) { + const recorded = this.#events[i]; + const expected = expectedAsArray[i]; + + assert_equals( + recorded.location, + expected[1], + `event ${i} (${recorded.name}): location.hash value` + ); + + if (expected[2] === null) { + assert_equals( + recorded.transitionProps, + null, + `event ${i} (${recorded.name}): navigation.transition expected to be null` + ); + } else { + assert_not_equals( + recorded.transitionProps, + null, + `event ${i} (${recorded.name}): navigation.transition expected not to be null` + ); + assert_equals( + recorded.transitionProps.from, + expected[2].from, + `event ${i} (${recorded.name}): navigation.transition.from` + ); + assert_equals( + recorded.transitionProps.navigationType, + expected[2].navigationType, + `event ${i} (${recorded.name}): navigation.transition.navigationType` + ); + } + } + + if (this.#errors.length > 1) { + for (let i = 1; i < this.#errors.length; ++i) { + assert_equals( + this.#errors[i].errorObject, + this.#errors[0].errorObject, + `error objects must match: error object for ${this.#errors[i].name} did not match the one for ${this.#errors[0].name}` + ); + } + } + } + + assertErrorsAreAbortErrors() { + assert_greater_than( + this.#errors.length, + 0, + "No errors were recorded but assertErrorsAreAbortErrors() was called" + ); + + // Assume assert() has been called so all error objects are the same. + const { errorObject } = this.#errors[0]; + assert_throws_dom("AbortError", this.#domExceptionConstructor, () => { throw errorObject; }); + } + + assertErrorsAre(expectedErrorObject) { + assert_greater_than( + this.#errors.length, + 0, + "No errors were recorded but assertErrorsAre() was called" + ); + + // Assume assert() has been called so all error objects are the same. + const { errorObject } = this.#errors[0]; + assert_equals(errorObject, expectedErrorObject); + } +} diff --git a/moli-wpt-compat/src/server.rs b/moli-wpt-compat/src/server.rs index ed7462283..bafe106bb 100644 --- a/moli-wpt-compat/src/server.rs +++ b/moli-wpt-compat/src/server.rs @@ -1362,7 +1362,7 @@ fn wpt_fixture_content_type(path: &StdPath, fixture_path: &str) -> Option<&'stat Some("css") => Some("text/css; charset=utf-8"), Some("htm") => Some("text/html; charset=utf-8"), Some("html") => Some("text/html; charset=utf-8"), - Some("js") => Some("application/javascript"), + Some("js" | "mjs") => Some("application/javascript"), Some("json") => Some("application/json"), Some("txt") => Some("text/plain; charset=utf-8"), Some("xml") => Some("application/xml"),