Allow the navigate event to cancel same-document main-frame traversals

Currently, the navigate event is allowed to cancel push/replace/reload
navigations, but not traversals. There are two reasons for this:
chromium's architecture made it difficult to support cancelation
without getting out of sync with the authoritative version of the
joint session history in the browser process, and we were
concerned about the possibility of trapping the user if canceling
a traversal was too easy.

The case where we might get out of sync is when multiple frames
navigate as part of a traversal. We want to avoid the case where
some frames cancel the navigation in their frame, but others allow
it to proceed (since giving every frame what it requested would
cause some frames to be out of sync with the browser process). Therefore, only the main frame is allowed to cancel the navigation
via the navigate event. In order to ensure the main frame is able to
cancel the entire traversal, we send the main frame navigation (if any)
to the renderer first and wait for its commit to complete before
proceeding with any subframe navigations.

The main frame is only allowed to cancel a traversal when it is
traversing same-document (regardless of whether its subframes
traverse same-document or cross-document). We had originally
planned on allowing cross-document traversals to be cancelled, too
(https://chromium-review.googlesource.com/c/chromium/src/+/3868615),
but this proved to have unacceptable performance characteristics,
requiring roundtrips to the renderer whenever a navigate event
handler was present, even if the navigate event handler had no
intention of ever cancelling a traversal.

Therefore the sequence during a traversal is now:
1. Calculate which frames to navigate, and invoke
   Navigator::Navigate() for each.
2. The main frame's NavigationRequest will proceed as normal.
3. If the main frame needs to do a same-document navigation, then:
  3a. Any subframe navigations will be deferred until the main
      frame NavigationRequest either commits or is canceled.
  3b. If it cancels, abort the entire traversal.
  3c. Resume all deferred subframes. These navigations will all fire
      a navigate event just before committing, but none of those of
      those events will be cancelable.

As for preventing trapping the user, we only allow canceling the
navigation in the main frame if the navigating is programmatic, or
if there is a consumable user activation. This ensures that, e.g.,
pressing the back button once might be canceled by the navigate
event, but the second back button press is guaranteed to go through.
Traversals via the navigation API or the legacy history API will
always be cancelable because they are programmatic. Canceling a
traversal consumes HistoryUserActivationState rather than
UserActivationState, in order to minimize the potential for
collisions with other UserActivationState consumers that are not
in the history/navigation space.

Bug: 1371580
Change-Id: I0c8c39bec8e21f3ca86389a4343881ebe2bde43e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4092862
Reviewed-by: Domenic Denicola <domenic@chromium.org>
Commit-Queue: Nate Chapin <japhet@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1106877}
diff --git a/navigation-api/navigate-event/navigate-history-back-after-fragment.html b/navigation-api/navigate-event/navigate-history-back-after-fragment.html
index 976754f..57a30c8 100644
--- a/navigation-api/navigate-event/navigate-history-back-after-fragment.html
+++ b/navigation-api/navigate-event/navigate-history-back-after-fragment.html
@@ -12,7 +12,7 @@
 
     navigation.onnavigate = t.step_func_done(e => {
       assert_equals(e.navigationType, "traverse");
-      assert_false(e.cancelable);
+      assert_true(e.cancelable);
       assert_true(e.canIntercept);
       assert_false(e.userInitiated);
       assert_true(e.hashChange);
diff --git a/navigation-api/navigate-event/navigate-history-back-after-pushState.html b/navigation-api/navigate-event/navigate-history-back-after-pushState.html
index 4d870fb..bf2e6e4 100644
--- a/navigation-api/navigate-event/navigate-history-back-after-pushState.html
+++ b/navigation-api/navigate-event/navigate-history-back-after-pushState.html
@@ -12,7 +12,7 @@
 
     navigation.onnavigate = t.step_func_done(e => {
       assert_equals(e.navigationType, "traverse");
-      assert_false(e.cancelable);
+      assert_true(e.cancelable);
       assert_true(e.canIntercept);
       assert_false(e.userInitiated);
       assert_false(e.hashChange);
diff --git a/navigation-api/navigate-event/navigate-navigation-back-cross-document.html b/navigation-api/navigate-event/navigate-navigation-back-cross-document.html
index 2146440..2e1adbe 100644
--- a/navigation-api/navigate-event/navigate-navigation-back-cross-document.html
+++ b/navigation-api/navigate-event/navigate-navigation-back-cross-document.html
@@ -9,7 +9,9 @@
     let target_id = i.contentWindow.navigation.currentEntry.id;
     i.contentWindow.navigation.navigate("?foo");
     i.onload = t.step_func(() => {
+      let beforeunload_called = false;
       i.contentWindow.navigation.onnavigate = t.step_func_done(e => {
+        assert_true(beforeunload_called);
         assert_equals(e.navigationType, "traverse");
         assert_false(e.cancelable);
         assert_false(e.canIntercept);
@@ -24,6 +26,7 @@
         assert_equals(e.formData, null);
         assert_equals(e.info, "hi");
       });
+      i.contentWindow.onbeforeunload = () => beforeunload_called = true;
       assert_true(i.contentWindow.navigation.canGoBack);
       i.contentWindow.navigation.back({ info: "hi" });
     })
diff --git a/navigation-api/navigate-event/navigate-navigation-back-same-document-in-iframe.html b/navigation-api/navigate-event/navigate-navigation-back-same-document-in-iframe.html
new file mode 100644
index 0000000..cebd2f3
--- /dev/null
+++ b/navigation-api/navigate-event/navigate-navigation-back-same-document-in-iframe.html
@@ -0,0 +1,33 @@
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<iframe id="i" src="/common/blank.html"></iframe>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  await new Promise(resolve => window.onload = () => t.step_timeout(resolve, 0));
+
+  let target_key = i.contentWindow.navigation.currentEntry.key;
+  let target_id = i.contentWindow.navigation.currentEntry.id;
+  await i.contentWindow.navigation.navigate("#").finished;
+  assert_true(i.contentWindow.navigation.canGoBack);
+
+  i.contentWindow.navigation.onnavigate = e => {
+    assert_equals(e.navigationType, "traverse");
+    assert_false(e.cancelable, "traversals in iframes should never be cancelable");
+    assert_true(e.canIntercept);
+    assert_false(e.userInitiated);
+    assert_true(e.hashChange);
+    assert_equals(e.downloadRequest, null);
+    assert_equals(new URL(e.destination.url).hash, "");
+    assert_true(e.destination.sameDocument);
+    assert_equals(e.destination.key, target_key);
+    assert_equals(e.destination.id, target_id);
+    assert_equals(e.destination.index, 0);
+    assert_equals(e.formData, null);
+    assert_equals(e.info, "hi");
+  }
+  await i.contentWindow.navigation.back({ info: "hi" }).finished;
+}, "navigate event for navigation.back() - same-document in an iframe");
+</script>
diff --git a/navigation-api/navigate-event/navigate-navigation-back-same-document.html b/navigation-api/navigate-event/navigate-navigation-back-same-document.html
index 8753e6b..431d384 100644
--- a/navigation-api/navigate-event/navigate-navigation-back-same-document.html
+++ b/navigation-api/navigate-event/navigate-navigation-back-same-document.html
@@ -11,7 +11,7 @@
     navigation.navigate("#foo").committed.then(t.step_func(() => {
       navigation.onnavigate = t.step_func_done(e => {
         assert_equals(e.navigationType, "traverse");
-        assert_false(e.cancelable);
+        assert_true(e.cancelable);
         assert_true(e.canIntercept);
         assert_false(e.userInitiated);
         assert_true(e.hashChange);
diff --git a/navigation-api/navigate-event/navigation-back-cross-document-preventDefault.html b/navigation-api/navigate-event/navigation-back-cross-document-preventDefault.html
new file mode 100644
index 0000000..0b5b750
--- /dev/null
+++ b/navigation-api/navigate-event/navigation-back-cross-document-preventDefault.html
@@ -0,0 +1,33 @@
+
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../navigation-methods/return-value/resources/helpers.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  let w = window.open("resources/opener-postMessage-onload.html");
+  await new Promise(resolve => window.onmessage = resolve);
+  // Navigate to a url that will notify us when the navigation is complete.
+  w.navigation.navigate("opener-postMessage-onload.html?1");
+
+  await new Promise(resolve => window.onmessage = resolve);
+  assert_equals(w.navigation.entries().length, 2);
+  assert_equals(w.navigation.currentEntry.index, 1);
+  let navigate_called = false;
+  w.navigation.onnavigate = t.step_func(e => {
+    navigate_called = true;
+    assert_false(e.destination.sameDocument);
+    assert_false(e.cancelable);
+    // Should do nothing.
+    e.preventDefault();
+  });
+  w.navigation.back();
+  await new Promise(resolve => window.onmessage = resolve);
+  assert_equals(w.navigation.currentEntry.index, 0);
+  assert_true(navigate_called);
+}, "navigation.back() cross-document cannot be cancelled with the navigate event");
+</script>
+
diff --git a/navigation-api/navigate-event/navigation-back-same-document-preventDefault.html b/navigation-api/navigate-event/navigation-back-same-document-preventDefault.html
new file mode 100644
index 0000000..7edb188
--- /dev/null
+++ b/navigation-api/navigate-event/navigation-back-same-document-preventDefault.html
@@ -0,0 +1,26 @@
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../navigation-methods/return-value/resources/helpers.js"></script>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  await new Promise(resolve => window.onload = () => t.step_timeout(resolve, 0));
+
+  await navigation.navigate("#").finished;
+  assert_equals(navigation.entries().length, 2);
+  assert_equals(navigation.currentEntry.index, 1);
+
+  navigation.onnavigate = e => e.preventDefault();
+
+  navigation.onnavigateerror = t.step_func(e => {
+    assert_equals(e.constructor, ErrorEvent);
+    assert_equals(e.filename, location.href);
+    navigateerror_called = true;
+  });
+  await assertBothRejectDOM(t, navigation.back(), "AbortError");
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_true(navigateerror_called);
+}, "navigation.back() same-document preventDefault");
+</script>
diff --git a/navigation-api/navigate-event/navigation-traverseTo-in-iframe-same-document-preventDefault.html b/navigation-api/navigate-event/navigation-traverseTo-in-iframe-same-document-preventDefault.html
new file mode 100644
index 0000000..d68b11f
--- /dev/null
+++ b/navigation-api/navigate-event/navigation-traverseTo-in-iframe-same-document-preventDefault.html
@@ -0,0 +1,47 @@
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../navigation-methods/return-value/resources/helpers.js"></script>
+<iframe id="i" src="/common/blank.html"></iframe>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  await new Promise(resolve => window.onload = () => t.step_timeout(resolve, 0));
+
+  // Navigate the iframe, then the top window, so that when the iframe goes back
+  // to its initial entry, the top window navigates as well.
+  await i.contentWindow.navigation.navigate("#").finished;
+  await navigation.navigate("#").finished;
+  assert_equals(navigation.entries().length, 2);
+  assert_equals(i.contentWindow.navigation.entries().length, 2);
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_equals(i.contentWindow.navigation.currentEntry.index, 1);
+
+  // Ensure the top window, which is allowed to cancel the traversal, does so.
+  navigation.onnavigate = e => e.preventDefault();
+
+  let top_navigateerror_fired = false;
+  navigation.onnavigateerror = t.step_func(e => {
+    assert_equals(e.constructor, ErrorEvent);
+    assert_equals(e.filename, location.href);
+    top_navigateerror_fired = true;
+  });
+  let iframe_navigateerror_fired = false;
+  i.contentWindow.navigation.onnavigateerror = t.step_func(e => {
+    assert_equals(e.constructor, i.contentWindow.ErrorEvent);
+    assert_equals(e.filename, i.contentWindow.location.href);
+    iframe_navigateerror_fired = true;
+  });
+
+  // When the top window blocks the traversal, it should be blocked in the
+  // iframe as well, and the traversal promises in the iframe should be rejected.
+  const iWindow = i.contentWindow;
+  const iDOMException = iWindow.DOMException;
+  await assertBothRejectDOM(t, i.contentWindow.navigation.traverseTo(i.contentWindow.navigation.entries()[0].key), "AbortError", iWindow, iDOMException);
+  assert_true(top_navigateerror_fired);
+  assert_true(iframe_navigateerror_fired);
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_equals(i.contentWindow.navigation.currentEntry.index, 1);
+}, "navigation.traverseTo() in an iframe with same-document preventDefault in its parent");
+</script>
diff --git a/navigation-api/navigate-event/navigation-traverseTo-navigates-top-and-same-doc-child-and-cross-doc-child.html b/navigation-api/navigate-event/navigation-traverseTo-navigates-top-and-same-doc-child-and-cross-doc-child.html
new file mode 100644
index 0000000..31cb54f
--- /dev/null
+++ b/navigation-api/navigate-event/navigation-traverseTo-navigates-top-and-same-doc-child-and-cross-doc-child.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<iframe id="i1" src="/common/blank.html"></iframe>
+<iframe id="i2" src="/common/blank.html"></iframe>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  await new Promise(resolve => window.onload = () => t.step_timeout(resolve, 0));
+  await navigation.navigate("#").finished;
+  await i1.contentWindow.navigation.navigate("#").finished;
+  i2.contentWindow.navigation.navigate("?");
+  await new Promise(resolve => i2.onload = () => t.step_timeout(resolve, 0));
+
+  assert_equals(navigation.entries().length, 2);
+  assert_equals(i1.contentWindow.navigation.entries().length, 2);
+  assert_equals(i2.contentWindow.navigation.entries().length, 2);
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_equals(i1.contentWindow.navigation.currentEntry.index, 1);
+  assert_equals(i2.contentWindow.navigation.currentEntry.index, 1);
+
+  let navigate_event_count = 0;
+  navigation.onnavigate = t.step_func(e => {
+    assert_equals(navigate_event_count, 0);
+    navigate_event_count++;
+    assert_true(e.cancelable);
+  });
+  i1.contentWindow.navigation.onnavigate = t.step_func(e => {
+    assert_true(navigate_event_count > 0);
+    navigate_event_count++;
+    assert_false(e.cancelable);
+  });
+  i2.contentWindow.navigation.onnavigate = t.step_func(e => {
+    assert_true(navigate_event_count > 0);
+    navigate_event_count++;
+    assert_false(e.cancelable);
+  });
+
+  await navigation.traverseTo(navigation.entries()[0].key).finished;
+  // The top window will finish quickly, becuase it is same-document traversal.
+  // i2 will be slower because it is cross-document, so wait for its onload.
+  await new Promise(resolve => i2.onload = () => t.step_timeout(resolve, 0));
+  assert_equals(navigate_event_count, 3);
+  assert_equals(navigation.currentEntry.index, 0);
+  assert_equals(i1.contentWindow.navigation.currentEntry.index, 0);
+  assert_equals(i2.contentWindow.navigation.currentEntry.index, 0);
+}, "navigation.traverseTo() can navigate 3 frames of different types with correct navigate event cancelable values");
+</script>
diff --git a/navigation-api/navigate-event/navigation-traverseTo-same-document-preventDefault-multiple-windows.html b/navigation-api/navigate-event/navigation-traverseTo-same-document-preventDefault-multiple-windows.html
new file mode 100644
index 0000000..9bb64fb
--- /dev/null
+++ b/navigation-api/navigate-event/navigation-traverseTo-same-document-preventDefault-multiple-windows.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<iframe id="i" src="/common/blank.html"></iframe>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  await new Promise(resolve => window.onload = () => t.step_timeout(resolve, 0));
+  await navigation.navigate("#").finished;
+  await i.contentWindow.navigation.navigate("#").finished;
+  assert_equals(navigation.entries().length, 2);
+  assert_equals(i.contentWindow.navigation.entries().length, 2);
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_equals(i.contentWindow.navigation.currentEntry.index, 1);
+
+  navigation.onnavigate = e => e.preventDefault();
+  i.contentWindow.navigation.onnavigate = t.unreached_func("navigate event should not fire in the iframe, because the traversal was cancelled in the top window");
+  await promise_rejects_dom(t, "AbortError", navigation.traverseTo(navigation.entries()[0].key).finished);
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_equals(i.contentWindow.navigation.currentEntry.index, 1);
+}, "navigation.traverseTo() - if a top window cancels the traversal, any iframes should not fire navigate");
+</script>
diff --git a/navigation-api/navigate-event/navigation-traverseTo-top-cancels-cross-document-child.html b/navigation-api/navigate-event/navigation-traverseTo-top-cancels-cross-document-child.html
new file mode 100644
index 0000000..11f07af
--- /dev/null
+++ b/navigation-api/navigate-event/navigation-traverseTo-top-cancels-cross-document-child.html
@@ -0,0 +1,26 @@
+<!doctype html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../navigation-methods/return-value/resources/helpers.js"></script>
+<iframe id="i" src="/common/blank.html"></iframe>
+<script>
+promise_test(async t => {
+  // Wait for after the load event so that the navigation doesn't get converted
+  // into a replace navigation.
+  await new Promise(resolve => window.onload = () => t.step_timeout(resolve, 0));
+  await navigation.navigate("#").finished;
+  i.contentWindow.navigation.navigate("?");
+  await new Promise(resolve => i.onload = () => t.step_timeout(resolve, 0));
+
+  assert_equals(navigation.entries().length, 2);
+  assert_equals(i.contentWindow.navigation.entries().length, 2);
+  assert_equals(navigation.currentEntry.index, 1);
+  assert_equals(i.contentWindow.navigation.currentEntry.index, 1);
+
+  navigation.onnavigate = t.step_func(e => e.preventDefault());
+  i.contentWindow.navigation.onnavigate = t.unreached_func("navigation should be cancelled before iframe fires navigate event");
+  await assertBothRejectDOM(t, navigation.traverseTo(navigation.entries()[0].key), "AbortError");
+  // Give the iframe time to navigate in case it was incorrectly permitted.
+  await new Promise(resolve => t.step_timeout(resolve, 50));
+}, "navigate.traverseTo() cancelled by top frame cancels cross-document iframe");
+</script>
diff --git a/navigation-api/navigation-methods/traverseTo-detach-between-navigate-and-navigatesuccess.html b/navigation-api/navigation-methods/traverseTo-detach-between-navigate-and-navigatesuccess.html
index a0a2918..43bde9a 100644
--- a/navigation-api/navigation-methods/traverseTo-detach-between-navigate-and-navigatesuccess.html
+++ b/navigation-api/navigation-methods/traverseTo-detach-between-navigate-and-navigatesuccess.html
@@ -20,8 +20,16 @@
       let result;
 
       i.contentWindow.navigation.onnavigate = t.step_func(e => {
-        e.intercept({ handler: () => new Promise(resolve => t.step_timeout(resolve, 2)) });
-        t.step_timeout(() => i.remove(), 1);
+        // 1. The intercept handler runs.
+        // 2. "t.step_timeout(handlerRunResolve, 0)" executes handlerRunResolve in a macro task.
+        // 3. In the next microtask, the iframe is removed.
+        // 4. "t.step_timeout(resolve, 5)" executes and the intercept handler promise resolves.
+        let handlerRunResolve;
+        new Promise(r => handlerRunResolve = r).then(() => i.remove());
+        e.intercept({ handler() {
+          t.step_timeout(handlerRunResolve, 0);
+          return new Promise(resolve => t.step_timeout(resolve, 5));
+        }});
       });
 
       i.contentWindow.navigation.onnavigatesuccess = t.unreached_func("navigatesuccess must not fire");