diff --git a/Tests/LibWeb/Text/expected/DOM/beforeunload.txt b/Tests/LibWeb/Text/expected/DOM/beforeunload.txt new file mode 100644 index 00000000000..1aa6ad32c5d --- /dev/null +++ b/Tests/LibWeb/Text/expected/DOM/beforeunload.txt @@ -0,0 +1,2 @@ +Before unload event fired +Default prevented: true diff --git a/Tests/LibWeb/Text/input/DOM/beforeunload.html b/Tests/LibWeb/Text/input/DOM/beforeunload.html new file mode 100644 index 00000000000..870917ed0ea --- /dev/null +++ b/Tests/LibWeb/Text/input/DOM/beforeunload.html @@ -0,0 +1,14 @@ + + diff --git a/Userland/Libraries/LibWeb/DOM/Document.cpp b/Userland/Libraries/LibWeb/DOM/Document.cpp index 949849becc9..f544f525ac6 100644 --- a/Userland/Libraries/LibWeb/DOM/Document.cpp +++ b/Userland/Libraries/LibWeb/DOM/Document.cpp @@ -5610,4 +5610,54 @@ Unicode::Segmenter& Document::word_segmenter() const return *m_word_segmenter; } +// https://html.spec.whatwg.org/multipage/browsing-the-web.html#steps-to-fire-beforeunload +Document::StepsToFireBeforeunloadResult Document::steps_to_fire_beforeunload(bool unload_prompt_shown) +{ + // 1. Let unloadPromptCanceled be false. + auto unload_prompt_canceled = false; + + // 2. Increase the document's unload counter by 1. + m_unload_counter++; + + // 3. Increase document's relevant agent's event loop's termination nesting level by 1. + auto& event_loop = *verify_cast(*HTML::relevant_agent(*this).custom_data()).event_loop; + event_loop.increment_termination_nesting_level(); + + // 4. Let eventFiringResult be the result of firing an event named beforeunload at document's relevant global object, + // using BeforeUnloadEvent, with the cancelable attribute initialized to true. + auto& global_object = HTML::relevant_global_object(*this); + auto& window = verify_cast(global_object); + auto beforeunload_event = BeforeUnloadEvent::create(realm(), HTML::EventNames::beforeunload); + beforeunload_event->set_cancelable(true); + auto event_firing_result = window.dispatch_event(*beforeunload_event); + + // 5. Decrease document's relevant agent's event loop's termination nesting level by 1. + event_loop.decrement_termination_nesting_level(); + + // FIXME: 6. If all of the following are true: + if (false && + // - unloadPromptShown is false; + !unload_prompt_shown + // - document's active sandboxing flag set does not have its sandboxed modals flag set; + && !has_flag(document().active_sandboxing_flag_set(), HTML::SandboxingFlagSet::SandboxedModals) + // - document's relevant global object has sticky activation; + && window.has_sticky_activation() + // - eventFiringResult is false, or the returnValue attribute of event is not the empty string; and + && (!event_firing_result || !beforeunload_event->return_value().is_empty()) + // - FIXME: showing an unload prompt is unlikely to be annoying, deceptive, or pointless + ) { + // FIXME: 1. Set unloadPromptShown to true. + // FIXME: 2. Invoke WebDriver BiDi user prompt opened with document's relevant global object, "beforeunload", and "". + // FIXME: 3. Ask the user to confirm that they wish to unload the document, and pause while waiting for the user's response. + // FIXME: 4. If the user did not confirm the page navigation, set unloadPromptCanceled to true. + // FIXME: 5. Invoke WebDriver BiDi user prompt closed with document's relevant global object and true if unloadPromptCanceled is false or false otherwise. + } + + // 7. Decrease document's unload counter by 1. + m_unload_counter--; + + // 8. Return (unloadPromptShown, unloadPromptCanceled). + return { unload_prompt_shown, unload_prompt_canceled }; +} + } diff --git a/Userland/Libraries/LibWeb/DOM/Document.h b/Userland/Libraries/LibWeb/DOM/Document.h index c301ed07fa8..e84fd5da946 100644 --- a/Userland/Libraries/LibWeb/DOM/Document.h +++ b/Userland/Libraries/LibWeb/DOM/Document.h @@ -725,6 +725,12 @@ public: Unicode::Segmenter& grapheme_segmenter() const; Unicode::Segmenter& word_segmenter() const; + struct StepsToFireBeforeunloadResult { + bool unload_prompt_shown { false }; + bool unload_prompt_canceled { false }; + }; + StepsToFireBeforeunloadResult steps_to_fire_beforeunload(bool unload_prompt_shown); + protected: virtual void initialize(JS::Realm&) override; virtual void visit_edges(Cell::Visitor&) override; diff --git a/Userland/Libraries/LibWeb/HTML/Navigable.cpp b/Userland/Libraries/LibWeb/HTML/Navigable.cpp index 65d733b4246..238a7aff23d 100644 --- a/Userland/Libraries/LibWeb/HTML/Navigable.cpp +++ b/Userland/Libraries/LibWeb/HTML/Navigable.cpp @@ -1369,10 +1369,11 @@ WebIDL::ExceptionOr Navigable::navigate(NavigateParams params) return; } - // FIXME: 1. Let unloadPromptCanceled be the result of checking if unloading is user-canceled for navigable's active document's inclusive descendant navigables. + // 1. Let unloadPromptCanceled be the result of checking if unloading is user-canceled for navigable's active document's inclusive descendant navigables. + auto unload_prompt_canceled = traversable_navigable()->check_if_unloading_is_canceled(this->active_document()->inclusive_descendant_navigables()); - // FIXME: 2. If unloadPromptCanceled is true, or navigable's ongoing navigation is no longer navigationId, then: - if (!ongoing_navigation().has() || ongoing_navigation().get() != navigation_id) { + // 2. If unloadPromptCanceled is true, or navigable's ongoing navigation is no longer navigationId, then: + if (unload_prompt_canceled != TraversableNavigable::CheckIfUnloadingIsCanceledResult::Continue || !ongoing_navigation().has() || ongoing_navigation().get() != navigation_id) { // FIXME: 1. Invoke WebDriver BiDi navigation failed with targetBrowsingContext and a new WebDriver BiDi navigation status whose id is navigationId, status is "canceled", and url is url. // 2. Abort these steps. diff --git a/Userland/Libraries/LibWeb/HTML/TraversableNavigable.cpp b/Userland/Libraries/LibWeb/HTML/TraversableNavigable.cpp index e9fddf738ec..ed3bc80f9ad 100644 --- a/Userland/Libraries/LibWeb/HTML/TraversableNavigable.cpp +++ b/Userland/Libraries/LibWeb/HTML/TraversableNavigable.cpp @@ -455,11 +455,17 @@ TraversableNavigable::HistoryStepResult TraversableNavigable::apply_the_history_ } // 4. Let navigablesCrossingDocuments be the result of getting all navigables that might experience a cross-document traversal given traversable and targetStep. - [[maybe_unused]] auto navigables_crossing_documents = get_all_navigables_that_might_experience_a_cross_document_traversal(target_step); + auto navigables_crossing_documents = get_all_navigables_that_might_experience_a_cross_document_traversal(target_step); - // 5. FIXME: If checkForCancelation is true, and the result of checking if unloading is canceled given navigablesCrossingDocuments, traversable, targetStep, - // and userInvolvementForNavigateEvents is not "continue", then return that result. - (void)check_for_cancelation; + // 5. If checkForCancelation is true, and the result of checking if unloading is canceled given navigablesCrossingDocuments, traversable, targetStep, + // and userInvolvementForNavigateEvents is not "continue", then return that result. + if (check_for_cancelation) { + auto result = check_if_unloading_is_canceled(navigables_crossing_documents, *this, target_step, user_involvement_for_navigate_events); + if (result == CheckIfUnloadingIsCanceledResult::CanceledByBeforeUnload) + return HistoryStepResult::CanceledByBeforeUnload; + if (result == CheckIfUnloadingIsCanceledResult::CanceledByNavigate) + return HistoryStepResult::CanceledByNavigate; + } // 6. Let changingNavigables be the result of get all navigables whose current session history entry will change or reload given traversable and targetStep. auto changing_navigables = move(change_or_reload_navigables); @@ -825,6 +831,139 @@ TraversableNavigable::HistoryStepResult TraversableNavigable::apply_the_history_ return HistoryStepResult::Applied; } +// https://html.spec.whatwg.org/multipage/browsing-the-web.html#checking-if-unloading-is-canceled +TraversableNavigable::CheckIfUnloadingIsCanceledResult TraversableNavigable::check_if_unloading_is_canceled( + Vector> navigables_that_need_before_unload, + JS::GCPtr traversable, + Optional target_step, + Optional user_involvement_for_navigate_events) +{ + // 1. Let documentsToFireBeforeunload be the active document of each item in navigablesThatNeedBeforeUnload. + Vector> documents_to_fire_beforeunload; + for (auto& navigable : navigables_that_need_before_unload) + documents_to_fire_beforeunload.append(navigable->active_document()); + + // 2. Let unloadPromptShown be false. + auto unload_prompt_shown = false; + + // 3. Let finalStatus be "continue". + auto final_status = CheckIfUnloadingIsCanceledResult::Continue; + + // 4. If traversable was given, then: + if (traversable) { + // 1. Assert: targetStep and userInvolvementForNavigateEvent were given. + // NOTE: This assertion is enforced by the caller. + + // 2. Let targetEntry be the result of getting the target history entry given traversable and targetStep. + auto target_entry = traversable->get_the_target_history_entry(target_step.value()); + + // 3. If targetEntry is not traversable's current session history entry, and targetEntry's document state's origin is the same as + // traversable's current session history entry's document state's origin, then: + if (target_entry != traversable->current_session_history_entry() && target_entry->document_state()->origin() != traversable->current_session_history_entry()->document_state()->origin()) { + // 1. Assert: userInvolvementForNavigateEvent is not null. + VERIFY(user_involvement_for_navigate_events.has_value()); + + // 2. Let eventsFired be false. + auto events_fired = false; + + // 3. Let needsBeforeunload be true if navigablesThatNeedBeforeUnload contains traversable; otherwise false. + auto it = navigables_that_need_before_unload.find_if([&traversable](JS::Handle navigable) { + return navigable.ptr() == traversable.ptr(); + }); + auto needs_beforeunload = it != navigables_that_need_before_unload.end(); + + // 4. If needsBeforeunload is true, then remove traversable's active document from documentsToFireBeforeunload. + if (needs_beforeunload) { + documents_to_fire_beforeunload.remove_first_matching([&](auto& document) { + return document.ptr() == traversable->active_document().ptr(); + }); + } + + // 5. Queue a global task on the navigation and traversal task source given traversable's active window to perform the following steps: + queue_global_task(Task::Source::NavigationAndTraversal, *traversable->active_window(), JS::create_heap_function(heap(), [&] { + // 1. if needsBeforeunload is true, then: + if (needs_beforeunload) { + // 1. Let (unloadPromptShownForThisDocument, unloadPromptCanceledByThisDocument) be the result of running the steps to fire beforeunload given traversable's active document and false. + auto [unload_prompt_shown_for_this_document, unload_prompt_canceled_by_this_document] = traversable->active_document()->steps_to_fire_beforeunload(false); + + // 2. If unloadPromptShownForThisDocument is true, then set unloadPromptShown to true. + if (unload_prompt_shown_for_this_document) + unload_prompt_shown = true; + + // 3. If unloadPromptCanceledByThisDocument is true, then set finalStatus to "canceled-by-beforeunload". + if (unload_prompt_canceled_by_this_document) + final_status = CheckIfUnloadingIsCanceledResult::CanceledByBeforeUnload; + } + + // 2. If finalStatus is "canceled-by-beforeunload", then abort these steps. + if (final_status == CheckIfUnloadingIsCanceledResult::CanceledByBeforeUnload) + return; + + // 3. Let navigation be traversable's active window's navigation API. + auto navigation = traversable->active_window()->navigation(); + + // 4. Let navigateEventResult be the result of firing a traverse navigate event at navigation given targetEntry and userInvolvementForNavigateEvent. + VERIFY(target_entry); + auto navigate_event_result = navigation->fire_a_traverse_navigate_event(*target_entry, *user_involvement_for_navigate_events); + + // 5. If navigateEventResult is false, then set finalStatus to "canceled-by-navigate". + if (!navigate_event_result) + final_status = CheckIfUnloadingIsCanceledResult::CanceledByNavigate; + + // 6. Set eventsFired to true. + events_fired = true; + })); + + // 6. Wait for eventsFired to be true. + main_thread_event_loop().spin_until([&] { + return events_fired; + }); + + // 7. If finalStatus is not "continue", then return finalStatus. + if (final_status != CheckIfUnloadingIsCanceledResult::Continue) + return final_status; + } + } + + // 5. Let totalTasks be the size of documentsThatNeedBeforeunload. + auto total_tasks = documents_to_fire_beforeunload.size(); + + // 6. Let completedTasks be 0. + size_t completed_tasks = 0; + + // 7. For each document of documents, queue a global task on the navigation and traversal task source given document's relevant global object to run the steps: + for (auto& document : documents_to_fire_beforeunload) { + queue_global_task(Task::Source::NavigationAndTraversal, relevant_global_object(*document), JS::create_heap_function(heap(), [&] { + // 1. Let (unloadPromptShownForThisDocument, unloadPromptCanceledByThisDocument) be the result of running the steps to fire beforeunload given document and unloadPromptShown. + auto [unload_prompt_shown_for_this_document, unload_prompt_canceled_by_this_document] = document->steps_to_fire_beforeunload(unload_prompt_shown); + + // 2. If unloadPromptShownForThisDocument is true, then set unloadPromptShown to true. + if (unload_prompt_shown_for_this_document) + unload_prompt_shown = true; + + // 3. If unloadPromptCanceledByThisDocument is true, then set finalStatus to "canceled-by-beforeunload". + if (unload_prompt_canceled_by_this_document) + final_status = CheckIfUnloadingIsCanceledResult::CanceledByBeforeUnload; + + // 4. Increment completedTasks. + completed_tasks++; + })); + } + + // 8. Wait for completedTasks to be totalTasks. + main_thread_event_loop().spin_until([&] { + return completed_tasks == total_tasks; + }); + + // 9. Return finalStatus. + return final_status; +} + +TraversableNavigable::CheckIfUnloadingIsCanceledResult TraversableNavigable::check_if_unloading_is_canceled(Vector> navigables_that_need_before_unload) +{ + return check_if_unloading_is_canceled(navigables_that_need_before_unload, {}, {}, {}); +} + Vector> TraversableNavigable::get_session_history_entries_for_the_navigation_api(JS::NonnullGCPtr navigable, int target_step) { // 1. Let rawEntries be the result of getting session history entries for navigable. @@ -1038,7 +1177,9 @@ void TraversableNavigable::close_top_level_traversable() // 2. Let toUnload be traversable's active document's inclusive descendant navigables. auto to_unload = active_document()->inclusive_descendant_navigables(); - // FIXME: 3. If the result of checking if unloading is canceled for toUnload is true, then return. + // If the result of checking if unloading is canceled for toUnload is true, then return. + if (check_if_unloading_is_canceled(to_unload) != CheckIfUnloadingIsCanceledResult::Continue) + return; // 4. Append the following session history traversal steps to traversable: append_session_history_traversal_steps(JS::create_heap_function(heap(), [this] { diff --git a/Userland/Libraries/LibWeb/HTML/TraversableNavigable.h b/Userland/Libraries/LibWeb/HTML/TraversableNavigable.h index baa0725bffc..72f2a720523 100644 --- a/Userland/Libraries/LibWeb/HTML/TraversableNavigable.h +++ b/Userland/Libraries/LibWeb/HTML/TraversableNavigable.h @@ -97,6 +97,13 @@ public: void paint(Web::DevicePixelRect const&, Painting::BackingStore&, Web::PaintOptions); + enum class CheckIfUnloadingIsCanceledResult { + CanceledByBeforeUnload, + CanceledByNavigate, + Continue, + }; + CheckIfUnloadingIsCanceledResult check_if_unloading_is_canceled(Vector> navigables_that_need_before_unload); + private: TraversableNavigable(JS::NonnullGCPtr); @@ -112,6 +119,8 @@ private: Optional navigation_type, SynchronousNavigation); + CheckIfUnloadingIsCanceledResult check_if_unloading_is_canceled(Vector> navigables_that_need_before_unload, JS::GCPtr traversable, Optional target_step, Optional user_involvement_for_navigate_events); + Vector> get_session_history_entries_for_the_navigation_api(JS::NonnullGCPtr, int); [[nodiscard]] bool can_go_forward() const;