あなたの window.open はなぜ開かないのか,Chrome で

先日 window.open をしようとしたらポップアップブロッカーに阻まれて open することができなかった.

f:id:mangano-ito:20200430132359p:plain
Blocked

まあ,これならよくあることなのだが,いかんせん自分の記憶では onClick のようなユーザーのアクション内で開かれた window.open は阻まれないことになってると思っていた.だからそのときも onClickイベントハンドラ内で window.open したから大丈夫だろう,と思っていたら,見事にブロックされてしまったのでなぜだろう,となっていた.

検証

なので,検証するために 3 つのケースを用意してみた:

検証ページを用意したのであなたの環境でも試してみてね♥

今回試すブラウザは Google Chrome を前提にしてます

ケース1

const immediate = () => {
    window.open('https://www.google.co.jp/');
};

document.getElementById('button_1').addEventListener('click', immediate);

これはユーザーの click 経由だから素直に開く.

ケース2

次にこれを非同期にして (async),6 秒待ってから window.open するようにしてみる:

const deferred = async () => {
    await new Promise((resolve) => {
        setTimeout(resolve, 6000);
    });
    window.open('https://www.google.co.jp/');
};

document.getElementById('button_2').addEventListener('click', deferred);

f:id:mangano-ito:20200430132508g:plain
じっとまってたらブロックされた

これは開かない.ポップアップブロックされる.しかし,たまに開くケースもある.

ケース3

任意の秒数を与えられるようにしてみる:

const deferred = async (waitSeconds: number = 6) => {
    await new Promise((resolve) => {
        setTimeout(resolve, 1000 * waitSeconds);
    });
    window.open('https://www.google.co.jp/');
};

document.getElementById('button_3').addEventListener('click', () => deferred(3));

3 秒にするといつも開く.

意味がわからなかったので

Chromiumソースコードからポップアップブロッカーの実装にあたってみた.ポップアップブロッカーのコードはここだ:

github.com

ところでポップアップがブロックされる理由は 2 種類ある:

// Classifies what caused a popup to be blocked.
enum class PopupBlockType {
  kNotBlocked,

  // Popup blocked due to no user gesture.
  kNoGesture,
  // Popup blocked due to the abusive popup blocker.
  kAbusive,
};

kNoGesture はユーザーの操作なくスクリプトが勝手にポップアップしたとみなされた場合 (ユーザーの意図,とも訳せるかも知れない).kAbusive は特に不正とされたページでポップアップが開いてブロックされた場合だ.3 種類あるやん,って思うかもしれないですが,kNotBlocked はそのとおり not blocked である.

デバッグ

実際にさっきのページのケースがどうだったか,Chromiumデバッグビルドしてデバッガーで確認してみることにした.

f:id:mangano-ito:20200429211020p:plain
kNoGesture

結果としては kNoGesture のケースだったことになる.そもそも不正なページではないので残ったケースといえばそうなる.

しかし,この部分からスタックトレースを追っていくと,ユーザー操作があったかのフラグである user_gesture の真偽値はどこから来たかがわかる.

f:id:mangano-ito:20200429211129p:plain
スタックトレースをたどっていく

RenderFrameHostImpl::CreateNewWindow のあたりに面白いコードがある:

  bool effective_transient_activation_state =
      params->allow_popup || frame_tree_node_->HasTransientUserActivation();

  // Ignore window creation when sent from a frame that's not current or
  // created.
  bool can_create_window =
      IsCurrent() && render_frame_created_ &&
      GetContentClient()->browser()->CanCreateWindow(
          this, GetLastCommittedURL(), GetMainFrame()->GetLastCommittedURL(),
          last_committed_origin_, params->window_container_type,
          params->target_url, params->referrer.To<Referrer>(),
          params->frame_name, params->disposition, *params->features,
          effective_transient_activation_state, params->opener_suppressed,
          &no_javascript_access);

chromium/render_frame_host_impl.cc at master · chromium/chromium · GitHub

ここの effective_transient_activation_state がここからの呼び出しにおける user_gesture フラグの真偽値になっている.frame_tree_node_->HasTransientUserActivation() という関数がポイントだ.UserActivationState というクラスの状態がユーザーの操作があったかどうかを管理している.

User Activation

ところで,このユーザーの操作があったか,というコンセプトは User Activation v2 と呼ばれているようで,コメントに詳細な解説がある (下のコメントは長いので中略している):

// This class represents the user activation state of a frame.  It maintains two
// bits of information: whether this frame has ever seen an activation in its
// lifetime (the sticky bit), and whether this frame has a current activation
// that is neither expired nor consumed (the transient bit).  See User
// Activation v2 (UAv2) links below for more info.
//
// More Info
// =========
//
// - UAv2 explainer: https://mustaqahmed.github.io/user-activation-v2
// - Main design:
//   https://docs.google.com/a/chromium.org/document/d/1erpl1yqJlc1pH0QvVVmi1s3WzqQLsEXTLLh6VuYp228
// - Browser-side replication for OOPIFs:
//   https://docs.google.com/document/d/1XL3vCedkqL65ueaGVD-kfB5RnnrnTaxLc7kmU91oerg
class BLINK_COMMON_EXPORT UserActivationState {

chromium/user_activation_state.h at 7709ebb8b7d8b373a1f9cc7ab3b7ecf6a1474f81 · chromium/chromium · GitHub

DesignDoc と解説のページがあるので,詳細なところはそこを見てもらうとして,「ユーザーが意図的ななにかをしたか」というのが管理されるようになっている:

mustaqahmed.github.io

今回のケースにおいて,このモデルが関与しているのは次の二点:

  1. ユーザーはそのページを見たか?
  2. ユーザーはそのページでクリック等の操作をしたか?

となっていて

  • (2) は 一定時間経ってリセットされる
  • (2) は window.open のような操作後もリセットされる

Chromium の実装に戻ると.UserActivationState では (2) の一定時間が定数で定義されている.それは次のようになっている:

// The expiry time should be long enough to allow network round trips even in a
// very slow connection (to support xhr-like calls with user activation), yet
// not too long to make an "unattneded" page feel activated.
constexpr base::TimeDelta kActivationLifespan = base::TimeDelta::FromSeconds(5);

chromium/user_activation_state.cc at 78f0e8103e36a5126a9ce0ac134f2f9d4d5d25e0 · chromium/chromium · GitHub

5 秒である.コメントにあるように,5 秒あれば大体のアクションは完了するだろう,と見做されている.

だから,今回のケースでは (2) に該当して「なにも操作をせずに 5 秒待っているとポップアップブロックされる」という結果になった.そのため,カチカチと適当にクリックをして待っているとポップアップは開く:

f:id:mangano-ito:20200430155528g:plain
カチカチしてると開く

ポップアップブロッカー

ポップアップブロッカーのテストコードを見てみると,とてもおもしろいものになっている.というのも,ありとあらゆるポップアップの試みのテストケースがあるからだ.めちゃくちゃポップアップ開くとか,不可視の <a> を踏ませるとか,そういう想定されるケースがブロックされるかが書かれている:

github.com

だから,target="_blank"<a>document.createElement して click とか,よくあるやつはブロックされるかチェックされている:

<script>
function test() {
  document.querySelector("a").dispatchEvent(new MouseEvent("click", {ctrlKey: true, metaKey: true}));
}
</script>
</head>
<body onload="test()">
If the fake click was not blocked then there will be a warning message displayed in a new tab. Otherwise, the test passes.
<a href="popup-success.html" target="tab">link</a>
</body>

chromium/popup-fake-click-on-anchor.html at ccd149af47315e4c6f2fc45d55be1b271f39062c · chromium/chromium · GitHub

IN_PROC_BROWSER_TEST_F(PopupBlockerBrowserTest, PopupBlockedFakeClickOnAnchor) {
  RunCheckTest(browser(), "/popup_blocker/popup-fake-click-on-anchor.html",
               WindowOpenDisposition::CURRENT_TAB, kExpectForegroundTab,
               kDontCheckTitle);
}

chromium/popup_blocker_browsertest.cc at ccd149af47315e4c6f2fc45d55be1b271f39062c · chromium/chromium · GitHub

さらに,

開いたウィンドウを opener が操作するためには,postMessage してやりとりする方法がある:

developer.mozilla.org

window.addEventListener('message', (e) => {
    location.href = e.data;
}, false);

const deferred2 = async () => {
    const uri = location.href;
    const win = window.open(uri);
    await new Promise((resolve) => {
        setTimeout(resolve, 6000);
    });
    if (win) {
        win.postMessage('https://www.google.co.jp/', '*');
    } else {
        console.error('You have already closed the popup. Sheesh!');
    }
};

(※ この例では楽してるけど,本当はちゃんとメッセージの正当性を確認するために origin を指定&チェックしたりする必要がある)

こうすれば window.open しておいて,データが来た後に操作をすることができる.

さいごに

調べてみると時間がかかったわりに,へぇ〜…という感想しか抱かないものになっている.貴重な時間が消えて切ない.Firefox の実装は調べてないが,同じようなものと想像している.

1995〜2000年代の忌み嫌われた広告ウィンドウがビュンビュン飛び交うインターネットの反省で,window.open は実質封印されたも同然になっている.スマホの現代,window.open はもう @deprecated にしてもいいのではないか.大言壮語.