From a2ceda5345e9fdd5bcb56397ca2af458f9df5966 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 20 Sep 2026 15:03:38 +0800 Subject: [PATCH] fix(navigation): track intercepted traversal transitions Carry transition resolvers through pending traversal commits and settlement, reusing precommit transitions and the shared navigation lifecycle. Keep committed reactions after currententrychange and intercept handlers, and finish empty handler lists asynchronously. Preserve rejection reasons and transitions started by completion callbacks. Add default protocol regressions and four unmodified upstream WPT variants. Serve their .mjs helper through the compat fixture server. --- moli-core/tests/wpt_compat.rs | 4 + .../page/tests/same_document_history.rs | 316 +++++++++++ .../events/subclasses/data.rs | 6 +- .../history_runtime/traversal.rs | 522 ++++++++++-------- .../navigation_activation.rs | 31 +- .../navigation_callbacks/navigation.rs | 18 +- .../context_bootstrap/navigation_lifecycle.rs | 13 +- moli-wpt-compat/fixtures/wpt/manifest.toml | 72 +++ .../back-same-document-intercept-reject.html | 55 ++ .../back-same-document-intercept.html | 48 ++ .../resources/helpers.mjs | 206 +++++++ moli-wpt-compat/src/server.rs | 2 +- 12 files changed, 1040 insertions(+), 253 deletions(-) create mode 100644 moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept-reject.html create mode 100644 moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/back-same-document-intercept.html create mode 100644 moli-wpt-compat/fixtures/wpt/upstream/navigation-api/ordering-and-transition/resources/helpers.mjs 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"),