CVE-2026-91721
Overview
Background
- `JavaScriptDialogDismissNotifier`
- a small helper class in
web_contents_impl.ccthat holds astd::vector<base::OnceClosure> callbacks_and, when destroyed, runs each registered callback to signal that the last navigation-deferring JavaScript dialog has been dismissed. - Deferred navigation / prerender activation
- while a modal dialog is open,
JavaScriptDialogDefersNavigations()reportstrueand navigations (including prerender activations) are held by aCommitDeferringCondition; a callback registered viaNotifyOnJavaScriptDialogDismiss()resumes them once the dialog closes. - Prerender activation frame swap
- resuming a prerender activation commits the prerendered page and destroys the current primary
RenderFrameHostand its child frames, freeing those objects. - `GetUIThreadTaskRunner({})->PostTask`
- posts a closure to run later on the UI thread, allowing the current call stack to fully unwind before the closure executes.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Diff confirms synchronous callbacks in the destructor and the deferral via PostTask, but does not identify which stack-held object is freed. 'Reachable purely from web content' omits that the dialog must be dismissed, which needs user action.
The old ~JavaScriptDialogDismissNotifier() iterated callbacks_ and invoked each one synchronously (std::move(callback).Run()) from inside the destructor. That destructor runs on the stack of dialog-dismiss handling (e.g. HandleJavaScriptDialog running the dialog’s DialogClosedCallback), and the callbacks resume deferred navigations — which for a prerender activation synchronously swaps in the new page and destroys the current RenderFrameHost/child frames. As the removed code’s replacement comment states, “callbacks could destroy an object on the stack,” so a frame or related object that the still-executing dialog-dismiss stack frames continue to reference is freed mid-stack, producing a use-after-free when the stack unwinds and touches it.
The fix moves callbacks_ into a lambda posted via GetUIThreadTaskRunner({})->PostTask(...), so the callbacks (and any navigation resumption / frame destruction they cause) run only after the dialog-dismiss stack has fully unwound, eliminating the reentrant destruction of live stack-referenced objects.
Attack Path
- Set up a prerender and a deferring dialog
A malicious page prerenders a target URL (via speculation rules) and opens a JavaScript dialog (
alertor abeforeunloadconfirm), which creates aJavaScriptDialogDismissNotifiersoJavaScriptDialogDefersNavigations()returnstrue. - Trigger a deferred activation
The page navigates to the prerendered URL; because a dialog is open, the prerender activation is held by a
CommitDeferringConditionand registers a resume callback throughNotifyOnJavaScriptDialogDismiss(). - Dismiss the dialog to reenter destruction
Dismissing the dialog runs the
DialogClosedCallbackand, on the same stack, destroys theJavaScriptDialogDismissNotifier; the pre-patch destructor synchronously runs the resume callback. - Free a live object mid-stack
The synchronous resume commits the prerender activation, destroying the current primary/child
RenderFrameHostobjects while the dialog-dismiss and notifier-destructor stack frames are still active and still reference them. - Use the freed object
As the stack unwinds, code that continues to operate on the now-freed frame/
WebContents-related object dereferences freed memory, yielding the use-after-free (any further read/write primitive or escalation is(inferred)).
Impact Assessment
(inferred) and not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/web_contents/web_contents_impl.cc |
modified | |
TestJavaScriptDialogManagercontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
ifcontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
WebContentsPrerenderWithJSDialogBrowserTestcontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified |
Files Changed
content/browser/web_contents/web_contents_impl_browsertest.ccchrome/browser/ui/browser_browsertest.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/web_contents/web_contents_impl.h
Audit Directions
- Destructors that run client callbacks synchronouslyAudit any destructor or teardown path that invokes stored
base::OnceClosure/observer callbacks inline, since those callbacks may reentrantly destroy objects that the current stack still holds; prefer posting them. - Navigation/prerender resumption reachable from a dismiss stackReview other
CommitDeferringConditionresume points and dialog/permission/prompt dismiss handlers where resuming a navigation can swap or destroyRenderFrameHosts while an outer handler is still on the stack. - Synchronous frame destruction under an active callerLook for places that commit prerender activation or otherwise delete frames from within an event or callback whose caller continues to touch those frames after the call returns.
Patch
From bdf73323cda801a3dbc080e7a423de07e0c48cc2 Mon Sep 17 00:00:00 2001
From: Joe Mason <joenotcharles@google.com>
Date: Fri, 11 Sep 2026 10:58:05 -0700
Subject: [PATCH] Post a task from JavaScriptDialogDismissNotifier
Runs the callbacks registered with JavaScriptDialogDismissNotifier by
posting a task, to unwind the stack before executing them.
Fixed: 557320614
Change-Id: Iaae279449dc996713c08daa6e5fdf16ba574cc77
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8384757
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Joe Mason <joenotcharles@google.com>
Auto-Submit: Joe Mason <joenotcharles@google.com>
Owners-Override: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Dave Tapuska <dtapuska@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1696429}
---
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index 00137a56..db0a85b2 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -736,9 +736,16 @@
const JavaScriptDialogDismissNotifier&) = delete;
~JavaScriptDialogDismissNotifier() {
- for (auto& callback : callbacks_) {
- std::move(callback).Run();
- }
+ // Post a task to notify all clients, since callbacks could destroy an
+ // object on the stack.
+ GetUIThreadTaskRunner({})->PostTask(
+ FROM_HERE, base::BindOnce(
+ [](std::vector<base::OnceClosure> callbacks) {
+ for (auto& callback : callbacks) {
+ std::move(callback).Run();
+ }
+ },
+ std::move(callbacks_)));
}
void NotifyOnDismiss(base::OnceClosure callback) {
diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h
index 9c6e9c17..21097745 100644
--- a/content/browser/web_contents/web_contents_impl.h
+++ b/content/browser/web_contents/web_contents_impl.h
@@ -1589,10 +1589,15 @@
const GURL& scope,
AllowServiceWorkerResult allowed);
+ // Returns true if a dialog that should defer navigations is open. Callbacks
+ // passed to NotifyOnJavaScriptDialogDismiss(), which can be used to resume
+ // any deferred navigations, will be posted after this becomes false.
bool JavaScriptDialogDefersNavigations() {
return javascript_dialog_dismiss_notifier_.get();
}
+ // Adds a callback that will be posted to the UI thread when all Javascript
+ // dialogs that should defer navigations are dismissed.
void NotifyOnJavaScriptDialogDismiss(base::OnceClosure callback);
bool has_persistent_video() { return has_persistent_video_; }
diff --git a/chrome/browser/ui/browser_browsertest.cc b/chrome/browser/ui/browser_browsertest.cc
index 052da07d..09ec483 100644
--- a/chrome/browser/ui/browser_browsertest.cc
+++ b/chrome/browser/ui/browser_browsertest.cc
@@ -26,6 +26,7 @@
#include "base/strings/string_split.h"
#include "base/strings/utf_string_conversions.h"
#include "base/system/sys_info.h"
+#include "base/task/sequenced_task_runner.h"
#include "base/task/single_thread_task_runner.h"
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
@@ -815,17 +816,25 @@
manager.ResumeNavigation();
content::NavigationHandle* handle = manager.GetNavigationHandle();
+ ASSERT_TRUE(handle);
EXPECT_FALSE(handle->IsWaitingToCommit());
EXPECT_TRUE(handle->IsCommitDeferringConditionDeferredForTesting());
EXPECT_TRUE(js_dialog_manager->IsShowingDialogForTesting());
}
- // Dismiss the dialog. This should resume the navigation.
+ // Dismiss the dialog. This should post a task that resumes the navigation.
{
js_dialog_manager->ClickDialogButtonForTesting(true, std::u16string());
ASSERT_FALSE(js_dialog_manager->IsShowingDialogForTesting());
+ // Wait for the dialog close task to execute.
+ base::RunLoop run_loop;
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop.QuitClosure());
+ run_loop.Run();
+
content::NavigationHandle* handle = manager.GetNavigationHandle();
+ ASSERT_TRUE(handle);
EXPECT_FALSE(handle->IsCommitDeferringConditionDeferredForTesting());
EXPECT_TRUE(handle->IsWaitingToCommit());
}
diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc
index f597e01..ee02f1e 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -8,6 +8,7 @@
#include <array>
#include <initializer_list>
#include <optional>
+#include <string>
#include <tuple>
#include <utility>
#include <vector>
@@ -59,6 +60,7 @@
#include "content/browser/site_info.h"
#include "content/browser/site_instance_impl.h"
#include "content/browser/surface_embed/surface_embed_connector_impl.h"
+#include "content/browser/web_contents/web_contents_impl.h"
#include "content/browser/web_contents/web_contents_view.h"
#include "content/common/content_navigation_policy.h"
#include "content/common/frame.mojom-test-utils.h"
@@ -73,6 +75,8 @@
#include "content/public/browser/media_player_id.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/preloading.h"
+#include "content/public/browser/preloading_trigger_type.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/browser/render_view_host.h"
@@ -88,6 +92,7 @@
#include "content/public/common/content_paths.h"
#include "content/public/common/content_switches.h"
#include "content/public/common/isolated_world_ids.h"
+#include "content/public/common/javascript_dialog_type.h"
#include "content/public/common/url_constants.h"
#include "content/public/test/back_forward_cache_util.h"
#include "content/public/test/browser_test.h"
@@ -125,6 +130,7 @@
#include "services/network/public/cpp/features.h"
#include "services/network/public/mojom/web_client_hints_types.mojom.h"
#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/client_hints/client_hints.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/page/page_zoom.h"
@@ -9383,6 +9389,255 @@
EXPECT_FALSE(registry->FindNonReservedHostById(host_id));
}
+namespace {
+
+class TestJavaScriptDialogManager final : public JavaScriptDialogManager,
+ public WebContentsDelegate {
+ public:
+ TestJavaScriptDialogManager() = default;
+ ~TestJavaScriptDialogManager() final = default;
+
+ // WebContentsDelegate:
+ JavaScriptDialogManager* GetJavaScriptDialogManager(
+ WebContents* source) final {
+ return this;
+ }
+
+ PreloadingEligibility IsPrerender2Supported(
+ WebContents& web_contents,
+ PreloadingTriggerType trigger_type) final {
+ // Allow tests using this delegate to trigger preloading.
+ return PreloadingEligibility::kEligible;
+ }
+
+ bool IsBackForwardCacheSupported(WebContents& web_contents) final {
+ // Make sure pages using this delegate are destroyed, not cached, on unload.
+ return false;
+ }
+
+ // JavaScriptDialogManager:
+ void RunJavaScriptDialog(WebContents* web_contents,
+ RenderFrameHost* render_frame_host,
+ JavaScriptDialogType dialog_type,
+ const std::u16string& message_text,
+ const std::u16string& default_prompt_text,
+ DialogClosedCallback callback,
+ bool* did_suppress_message) final {
+ ASSERT_TRUE(did_suppress_message);
+ *did_suppress_message = false;
+ closed_callback_ = std::move(callback);
+ }
+
+ void RunBeforeUnloadDialog(WebContents* web_contents,
+ RenderFrameHost* render_frame_host,
+ bool is_reload,
+ DialogClosedCallback callback) final {
+ closed_callback_ = std::move(callback);
+ }
+
+ bool HandleJavaScriptDialog(WebContents* web_contents,
+ bool accept,
+ const std::u16string* prompt_override) final {
+ if (closed_callback_) {
+ std::move(closed_callback_).Run(/*success=*/true, std::u16string());
+ return true;
+ }
+ return false;
+ }
+
+ void CancelDialogs(WebContents* web_contents, bool reset_state) final {
+ // Do nothing.
+ }
+
+ private:
+ DialogClosedCallback closed_callback_;
+};
+
+} // namespace
+
+class WebContentsPrerenderWithJSDialogBrowserTest
+ : public WebContentsPrerenderBrowserTest {
+ public:
+ void SetUpOnMainThread() override {
+ WebContentsPrerenderBrowserTest::SetUpOnMainThread();
+ web_contents()->SetDelegate(&dialog_manager_);
+
+ // Navigate to a page with a child frame.
+ ASSERT_TRUE(embedded_test_server()->Start());
+ initial_url_ = embedded_test_server()->GetURL("/page_with_iframe.html");
+ ASSERT_TRUE(NavigateToURL(shell(), initial_url_));
+
+ // Prerender another page.
+ prerender_url_ = embedded_test_server()->GetURL("/title1.html");
+ prerender_helper().AddPrerender(prerender_url_);
+ activation_manager_.emplace(web_contents(), prerender_url_);
+ }
+
+ void TearDownOnMainThread() override {
+ activation_manager_.reset();
+ web_contents()->SetDelegate(shell());
+ WebContentsPrerenderBrowserTest::TearDownOnMainThread();
+ }
+
+ RenderFrameHost* GetFirstChildFrame() {
+ RenderFrameHost* main_rfh = web_contents()->GetPrimaryMainFrame();
+ RenderFrameHost* child_rfh = nullptr;
+ main_rfh->ForEachRenderFrameHost([&](RenderFrameHost* rfh) {
+ if (child_rfh) {
+ ADD_FAILURE() << "Should only be 1 child frame";
+ return;
+ }
+ if (rfh != main_rfh) {
+ child_rfh = rfh;
+ }
+ });
+ return child_rfh;
+ }
+
+ void NavigateToPrerenderedPage(bool expect_deferred) {
+ ExecuteScriptAsyncWithoutUserGesture(
+ web_contents(), JsReplace("location.href = $1", prerender_url_));
+ EXPECT_TRUE(activation_manager_->WaitForBeforeChecks());
+
+ // This handle is only valid until ResumeActivation().
+ NavigationHandle* navigation_handle =
+ activation_manager_->GetNavigationHandle();
+ ASSERT_TRUE(navigation_handle);
+ EXPECT_EQ(navigation_handle->IsCommitDeferringConditionDeferredForTesting(),
+ expect_deferred);
+
+ // ResumeActivation() must be called to continue after
+ // WaitForBeforeChecks(), but nothing should happen if activation is
+ // deferred.
+ activation_manager_->ResumeActivation();
+ EXPECT_EQ(activation_manager_->was_activated(), !expect_deferred);
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(),
+ expect_deferred ? initial_url_ : prerender_url_);
+ }
+
+ GURL initial_url() const { return initial_url_; }
+ GURL prerender_url() const { return prerender_url_; }
+
+ TestJavaScriptDialogManager& dialog_manager() { return dialog_manager_; }
+ TestActivationManager& activation_manager() { return *activation_manager_; }
+
+ private:
+ TestJavaScriptDialogManager dialog_manager_;
+ std::optional<TestActivationManager> activation_manager_;
+ GURL initial_url_;
+ GURL prerender_url_;
+};
+
+IN_PROC_BROWSER_TEST_F(WebContentsPrerenderWithJSDialogBrowserTest,
+ ModalDialogDefersActivation) {
+ RenderFrameHostImplWrapper main_rfh(web_contents()->GetPrimaryMainFrame());
+ RenderFrameHostImplWrapper child_rfh(GetFirstChildFrame());
+ ASSERT_TRUE(main_rfh);
+ ASSERT_TRUE(child_rfh);
+
+ // Open a JS dialog. It should defer navigations and prerender activations.
+ main_rfh->RunModalAlertDialog(
+ u"alert", /*disable_third_party_subframe_suppresion=*/true,
+ base::DoNothing());
+ EXPECT_TRUE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+ NavigateToPrerenderedPage(/*expect_deferred=*/true);
+
+ // Dismissing the dialog should asynchronously resume the activation.
+ dialog_manager().HandleJavaScriptDialog(web_contents(), /*accept=*/true,
+ /*prompt_override=*/nullptr);
+ EXPECT_FALSE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+
+ // The prerender activation should not yet have replaced the main or child
+ // frames.
+ EXPECT_FALSE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), initial_url());
+ EXPECT_TRUE(main_rfh);
+ EXPECT_TRUE(child_rfh);
+
+ // Wait until the activation finishes and destroys the child frame.
+ activation_manager().WaitForNavigationFinished();
+ EXPECT_TRUE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), prerender_url());
+ EXPECT_FALSE(child_rfh);
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsPrerenderWithJSDialogBrowserTest,
+ BeforeUnloadDialogDefersActivation) {
+ RenderFrameHostImplWrapper main_rfh(web_contents()->GetPrimaryMainFrame());
+ RenderFrameHostImplWrapper child_rfh(GetFirstChildFrame());
+ ASSERT_TRUE(main_rfh);
+ ASSERT_TRUE(child_rfh);
+
+ // Open a before unload confirm dialog. It should defer navigations and
+ // prerender activations.
+ web_contents_impl()->RunBeforeUnloadConfirm(
+ main_rfh.get(), /*is_reload=*/false, base::DoNothing());
+ EXPECT_TRUE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+ NavigateToPrerenderedPage(/*expect_deferred=*/true);
+
+ // Dismissing the dialog should asynchronously resume the activation.
+ dialog_manager().HandleJavaScriptDialog(web_contents(), /*accept=*/true,
+ /*prompt_override=*/nullptr);
+ EXPECT_FALSE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+
+ // The prerender activation should not yet have replaced the main or child
+ // frames.
+ EXPECT_FALSE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), initial_url());
+ EXPECT_TRUE(main_rfh);
+ EXPECT_TRUE(child_rfh);
+
+ // Wait until the activation finishes and destroys the child frame.
+ activation_manager().WaitForNavigationFinished();
+ EXPECT_TRUE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), prerender_url());
+ EXPECT_FALSE(child_rfh);
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsPrerenderWithJSDialogBrowserTest,
+ MultipleDialogsDeferActivation) {
+ RenderFrameHostImplWrapper main_rfh(web_contents()->GetPrimaryMainFrame());
+ RenderFrameHostImplWrapper child_rfh(GetFirstChildFrame());
+ ASSERT_TRUE(main_rfh);
+ ASSERT_TRUE(child_rfh);
+
+ // Open a JS dialog. It should defer navigations.
+ main_rfh->RunModalAlertDialog(
+ u"main alert", /*disable_third_party_subframe_suppresion=*/true,
+ base::DoNothing());
+ EXPECT_TRUE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+ NavigateToPrerenderedPage(/*expect_deferred=*/true);
+
+ // Open a JS dialog from the child frame. It will replace the existing dialog
+ // without running the close callback. Navigations and activations should NOT
+ // resume.
+ child_rfh->RunModalAlertDialog(
+ u"child alert", /*disable_third_party_subframe_suppresion=*/true,
+ base::DoNothing());
+ EXPECT_TRUE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+ EXPECT_FALSE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), initial_url());
+ EXPECT_TRUE(main_rfh);
+ EXPECT_TRUE(child_rfh);
+
+ // Dismissing the dialog should asynchronously resume the activation.
+ dialog_manager().HandleJavaScriptDialog(web_contents(), /*accept=*/true,
+ /*prompt_override=*/nullptr);
+ EXPECT_FALSE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+
+ // The prerender activation should not yet have replaced the main or child
+ // frames.
+ EXPECT_FALSE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), initial_url());
+ EXPECT_TRUE(main_rfh);
+ EXPECT_TRUE(child_rfh);
+
+ // Wait until the activation finishes and destroys the child frame.
+ activation_manager().WaitForNavigationFinished();
+ EXPECT_TRUE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), prerender_url());
+ EXPECT_FALSE(child_rfh);
+}
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/ui/browser_browsertest.cc b/chrome/browser/ui/browser_browsertest.cc
index 052da07d..09ec483 100644
--- a/chrome/browser/ui/browser_browsertest.cc
+++ b/chrome/browser/ui/browser_browsertest.cc
@@ -26,6 +26,7 @@
#include "base/strings/string_split.h"
#include "base/strings/utf_string_conversions.h"
#include "base/system/sys_info.h"
+#include "base/task/sequenced_task_runner.h"
#include "base/task/single_thread_task_runner.h"
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
@@ -815,17 +816,25 @@
manager.ResumeNavigation();
content::NavigationHandle* handle = manager.GetNavigationHandle();
+ ASSERT_TRUE(handle);
EXPECT_FALSE(handle->IsWaitingToCommit());
EXPECT_TRUE(handle->IsCommitDeferringConditionDeferredForTesting());
EXPECT_TRUE(js_dialog_manager->IsShowingDialogForTesting());
}
- // Dismiss the dialog. This should resume the navigation.
+ // Dismiss the dialog. This should post a task that resumes the navigation.
{
js_dialog_manager->ClickDialogButtonForTesting(true, std::u16string());
ASSERT_FALSE(js_dialog_manager->IsShowingDialogForTesting());
+ // Wait for the dialog close task to execute.
+ base::RunLoop run_loop;
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop.QuitClosure());
+ run_loop.Run();
+
content::NavigationHandle* handle = manager.GetNavigationHandle();
+ ASSERT_TRUE(handle);
EXPECT_FALSE(handle->IsCommitDeferringConditionDeferredForTesting());
EXPECT_TRUE(handle->IsWaitingToCommit());
}
diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc
index f597e01..ee02f1e 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -8,6 +8,7 @@
#include <array>
#include <initializer_list>
#include <optional>
+#include <string>
#include <tuple>
#include <utility>
#include <vector>
@@ -59,6 +60,7 @@
#include "content/browser/site_info.h"
#include "content/browser/site_instance_impl.h"
#include "content/browser/surface_embed/surface_embed_connector_impl.h"
+#include "content/browser/web_contents/web_contents_impl.h"
#include "content/browser/web_contents/web_contents_view.h"
#include "content/common/content_navigation_policy.h"
#include "content/common/frame.mojom-test-utils.h"
@@ -73,6 +75,8 @@
#include "content/public/browser/media_player_id.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/preloading.h"
+#include "content/public/browser/preloading_trigger_type.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/browser/render_view_host.h"
@@ -88,6 +92,7 @@
#include "content/public/common/content_paths.h"
#include "content/public/common/content_switches.h"
#include "content/public/common/isolated_world_ids.h"
+#include "content/public/common/javascript_dialog_type.h"
#include "content/public/common/url_constants.h"
#include "content/public/test/back_forward_cache_util.h"
#include "content/public/test/browser_test.h"
@@ -125,6 +130,7 @@
#include "services/network/public/cpp/features.h"
#include "services/network/public/mojom/web_client_hints_types.mojom.h"
#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/client_hints/client_hints.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/page/page_zoom.h"
@@ -9383,6 +9389,255 @@
EXPECT_FALSE(registry->FindNonReservedHostById(host_id));
}
+namespace {
+
+class TestJavaScriptDialogManager final : public JavaScriptDialogManager,
+ public WebContentsDelegate {
+ public:
+ TestJavaScriptDialogManager() = default;
+ ~TestJavaScriptDialogManager() final = default;
+
+ // WebContentsDelegate:
+ JavaScriptDialogManager* GetJavaScriptDialogManager(
+ WebContents* source) final {
+ return this;
+ }
+
+ PreloadingEligibility IsPrerender2Supported(
+ WebContents& web_contents,
+ PreloadingTriggerType trigger_type) final {
+ // Allow tests using this delegate to trigger preloading.
+ return PreloadingEligibility::kEligible;
+ }
+
+ bool IsBackForwardCacheSupported(WebContents& web_contents) final {
+ // Make sure pages using this delegate are destroyed, not cached, on unload.
+ return false;
+ }
+
+ // JavaScriptDialogManager:
+ void RunJavaScriptDialog(WebContents* web_contents,
+ RenderFrameHost* render_frame_host,
+ JavaScriptDialogType dialog_type,
+ const std::u16string& message_text,
+ const std::u16string& default_prompt_text,
+ DialogClosedCallback callback,
+ bool* did_suppress_message) final {
+ ASSERT_TRUE(did_suppress_message);
+ *did_suppress_message = false;
+ closed_callback_ = std::move(callback);
+ }
+
+ void RunBeforeUnloadDialog(WebContents* web_contents,
+ RenderFrameHost* render_frame_host,
+ bool is_reload,
+ DialogClosedCallback callback) final {
+ closed_callback_ = std::move(callback);
+ }
+
+ bool HandleJavaScriptDialog(WebContents* web_contents,
+ bool accept,
+ const std::u16string* prompt_override) final {
+ if (closed_callback_) {
+ std::move(closed_callback_).Run(/*success=*/true, std::u16string());
+ return true;
+ }
+ return false;
+ }
+
+ void CancelDialogs(WebContents* web_contents, bool reset_state) final {
+ // Do nothing.
+ }
+
+ private:
+ DialogClosedCallback closed_callback_;
+};
+
+} // namespace
+
+class WebContentsPrerenderWithJSDialogBrowserTest
+ : public WebContentsPrerenderBrowserTest {
+ public:
+ void SetUpOnMainThread() override {
+ WebContentsPrerenderBrowserTest::SetUpOnMainThread();
+ web_contents()->SetDelegate(&dialog_manager_);
+
+ // Navigate to a page with a child frame.
+ ASSERT_TRUE(embedded_test_server()->Start());
+ initial_url_ = embedded_test_server()->GetURL("/page_with_iframe.html");
+ ASSERT_TRUE(NavigateToURL(shell(), initial_url_));
+
+ // Prerender another page.
+ prerender_url_ = embedded_test_server()->GetURL("/title1.html");
+ prerender_helper().AddPrerender(prerender_url_);
+ activation_manager_.emplace(web_contents(), prerender_url_);
+ }
+
+ void TearDownOnMainThread() override {
+ activation_manager_.reset();
+ web_contents()->SetDelegate(shell());
+ WebContentsPrerenderBrowserTest::TearDownOnMainThread();
+ }
+
+ RenderFrameHost* GetFirstChildFrame() {
+ RenderFrameHost* main_rfh = web_contents()->GetPrimaryMainFrame();
+ RenderFrameHost* child_rfh = nullptr;
+ main_rfh->ForEachRenderFrameHost([&](RenderFrameHost* rfh) {
+ if (child_rfh) {
+ ADD_FAILURE() << "Should only be 1 child frame";
+ return;
+ }
+ if (rfh != main_rfh) {
+ child_rfh = rfh;
+ }
+ });
+ return child_rfh;
+ }
+
+ void NavigateToPrerenderedPage(bool expect_deferred) {
+ ExecuteScriptAsyncWithoutUserGesture(
+ web_contents(), JsReplace("location.href = $1", prerender_url_));
+ EXPECT_TRUE(activation_manager_->WaitForBeforeChecks());
+
+ // This handle is only valid until ResumeActivation().
+ NavigationHandle* navigation_handle =
+ activation_manager_->GetNavigationHandle();
+ ASSERT_TRUE(navigation_handle);
+ EXPECT_EQ(navigation_handle->IsCommitDeferringConditionDeferredForTesting(),
+ expect_deferred);
+
+ // ResumeActivation() must be called to continue after
+ // WaitForBeforeChecks(), but nothing should happen if activation is
+ // deferred.
+ activation_manager_->ResumeActivation();
+ EXPECT_EQ(activation_manager_->was_activated(), !expect_deferred);
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(),
+ expect_deferred ? initial_url_ : prerender_url_);
+ }
+
+ GURL initial_url() const { return initial_url_; }
+ GURL prerender_url() const { return prerender_url_; }
+
+ TestJavaScriptDialogManager& dialog_manager() { return dialog_manager_; }
+ TestActivationManager& activation_manager() { return *activation_manager_; }
+
+ private:
+ TestJavaScriptDialogManager dialog_manager_;
+ std::optional<TestActivationManager> activation_manager_;
+ GURL initial_url_;
+ GURL prerender_url_;
+};
+
+IN_PROC_BROWSER_TEST_F(WebContentsPrerenderWithJSDialogBrowserTest,
+ ModalDialogDefersActivation) {
+ RenderFrameHostImplWrapper main_rfh(web_contents()->GetPrimaryMainFrame());
+ RenderFrameHostImplWrapper child_rfh(GetFirstChildFrame());
+ ASSERT_TRUE(main_rfh);
+ ASSERT_TRUE(child_rfh);
+
+ // Open a JS dialog. It should defer navigations and prerender activations.
+ main_rfh->RunModalAlertDialog(
+ u"alert", /*disable_third_party_subframe_suppresion=*/true,
+ base::DoNothing());
+ EXPECT_TRUE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+ NavigateToPrerenderedPage(/*expect_deferred=*/true);
+
+ // Dismissing the dialog should asynchronously resume the activation.
+ dialog_manager().HandleJavaScriptDialog(web_contents(), /*accept=*/true,
+ /*prompt_override=*/nullptr);
+ EXPECT_FALSE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+
+ // The prerender activation should not yet have replaced the main or child
+ // frames.
+ EXPECT_FALSE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), initial_url());
+ EXPECT_TRUE(main_rfh);
+ EXPECT_TRUE(child_rfh);
+
+ // Wait until the activation finishes and destroys the child frame.
+ activation_manager().WaitForNavigationFinished();
+ EXPECT_TRUE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), prerender_url());
+ EXPECT_FALSE(child_rfh);
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsPrerenderWithJSDialogBrowserTest,
+ BeforeUnloadDialogDefersActivation) {
+ RenderFrameHostImplWrapper main_rfh(web_contents()->GetPrimaryMainFrame());
+ RenderFrameHostImplWrapper child_rfh(GetFirstChildFrame());
+ ASSERT_TRUE(main_rfh);
+ ASSERT_TRUE(child_rfh);
+
+ // Open a before unload confirm dialog. It should defer navigations and
+ // prerender activations.
+ web_contents_impl()->RunBeforeUnloadConfirm(
+ main_rfh.get(), /*is_reload=*/false, base::DoNothing());
+ EXPECT_TRUE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+ NavigateToPrerenderedPage(/*expect_deferred=*/true);
+
+ // Dismissing the dialog should asynchronously resume the activation.
+ dialog_manager().HandleJavaScriptDialog(web_contents(), /*accept=*/true,
+ /*prompt_override=*/nullptr);
+ EXPECT_FALSE(web_contents_impl()->JavaScriptDialogDefersNavigations());
+
+ // The prerender activation should not yet have replaced the main or child
+ // frames.
+ EXPECT_FALSE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), initial_url());
+ EXPECT_TRUE(main_rfh);
+ EXPECT_TRUE(child_rfh);
+
+ // Wait until the activation finishes and destroys the child frame.
+ activation_manager().WaitForNavigationFinished();
+ EXPECT_TRUE(activation_manager().was_activated());
+ EXPECT_EQ(web_contents()->GetLastCommittedURL(), prerender_url());
+ EXPECT_FALSE(child_rfh);
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsPrerenderWithJSDialogBrowserTest,
+ MultipleDialogsDeferActivation) {
+ RenderFrameHostImplWrapper main_rfh(web_contents()->GetPrimaryMainFrame());
+ RenderFrameHostImplWrapper child_rfh(GetFirstChildFrame());
+ ASSERT_TRUE(main_rfh);
+ ASSERT_TRUE(child_rfh);
+
... (truncated)