Browse Source

LibWeb: Implement the "check if unloading is canceled" AO

This method is responsible for firing `beforeunload` events.
Tim Ledbetter 9 months ago
parent
commit
1fa948f114

+ 2 - 0
Tests/LibWeb/Text/expected/DOM/beforeunload.txt

@@ -0,0 +1,2 @@
+Before unload event fired
+Default prevented: true

+ 14 - 0
Tests/LibWeb/Text/input/DOM/beforeunload.html

@@ -0,0 +1,14 @@
+<script src="../include.js"></script>
+<script>
+    asyncTest(done => {
+        const iframe = document.createElement("iframe");
+        document.body.appendChild(iframe);
+        iframe.contentWindow.addEventListener("beforeunload", e => {
+            println("Before unload event fired");
+            e.preventDefault();
+            println(`Default prevented: ${e.defaultPrevented}`);
+            done();
+        });
+        iframe.src = "about:blank";
+    });
+</script>

+ 50 - 0
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<Bindings::WebEngineCustomData>(*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<HTML::Window>(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 };
+}
+
 }

+ 6 - 0
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;

+ 4 - 3
Userland/Libraries/LibWeb/HTML/Navigable.cpp

@@ -1369,10 +1369,11 @@ WebIDL::ExceptionOr<void> 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<String>() || ongoing_navigation().get<String>() != 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<String>() || ongoing_navigation().get<String>() != 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.

+ 147 - 6
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);
-
-    // 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;
+    auto navigables_crossing_documents = get_all_navigables_that_might_experience_a_cross_document_traversal(target_step);
+
+    // 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<JS::Handle<Navigable>> navigables_that_need_before_unload,
+    JS::GCPtr<TraversableNavigable> traversable,
+    Optional<int> target_step,
+    Optional<UserNavigationInvolvement> user_involvement_for_navigate_events)
+{
+    // 1. Let documentsToFireBeforeunload be the active document of each item in navigablesThatNeedBeforeUnload.
+    Vector<JS::Handle<DOM::Document>> 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> 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<JS::Handle<Navigable>> navigables_that_need_before_unload)
+{
+    return check_if_unloading_is_canceled(navigables_that_need_before_unload, {}, {}, {});
+}
+
 Vector<JS::NonnullGCPtr<SessionHistoryEntry>> TraversableNavigable::get_session_history_entries_for_the_navigation_api(JS::NonnullGCPtr<Navigable> 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] {

+ 9 - 0
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<JS::Handle<Navigable>> navigables_that_need_before_unload);
+
 private:
     TraversableNavigable(JS::NonnullGCPtr<Page>);
 
@@ -112,6 +119,8 @@ private:
         Optional<Bindings::NavigationType> navigation_type,
         SynchronousNavigation);
 
+    CheckIfUnloadingIsCanceledResult check_if_unloading_is_canceled(Vector<JS::Handle<Navigable>> navigables_that_need_before_unload, JS::GCPtr<TraversableNavigable> traversable, Optional<int> target_step, Optional<UserNavigationInvolvement> user_involvement_for_navigate_events);
+
     Vector<JS::NonnullGCPtr<SessionHistoryEntry>> get_session_history_entries_for_the_navigation_api(JS::NonnullGCPtr<Navigable>, int);
 
     [[nodiscard]] bool can_go_forward() const;