Safari環境のNuxtでpopStateが変な動きをする話

先日ブルーベリーガム味という珍しいアイスに出会いました、カリーです。
Nuxt.jsでpopstateを使っている案件を対応したときに、やや挙動が変な感じになってハマったので、その内容を備忘録として残しておこうと思います。
動作環境
- Nuxt2環境(SPA)
- Safariバージョン16.2 (18614.3.7.1.5)
どんなことが起きたか
popstateイベントにてブラウザバック直前に何かしらの処理を入れているNuxt.jsアプリ、
例として、要素編集の途中でブラウザバックしたとき「保存されません、よろしいですか?」のポップアップを出すアプリがあるとします。(編集中か判定などの処理は割愛してます)
<template>
<div>
<div>
<div>
<span>ユーザー名入力</span>
<input :value="userName" @input="userName = $event.target.value" />
</div>
{{ userName }}
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "LinkPage",
data() {
return {
userName: "",
};
},
mounted() {
window.addEventListener("popstate", this.popEvent);
},
destroyed() {
// Vueコンポーネント破棄時にpopstateイベントも削除
window.removeEventListener("popstate", this.popEvent);
},
methods: {
async popEvent(event: PopStateEvent): Promise<void> {
alert("保存されません。よろしいですか?");
},
},
});
</script>
Safari環境だと、Nuxtアプリから外部サイトに遷移した後、ブラウザバックでNuxtアプリに戻ってきたタイミングでpopstateイベントが発火してしまいます。
以下の図でいう、③の直後に起こります。

本来ブラウザバック直前に発火するpopstateが、ブラウザバック後のページ内にて発火しています。ナンデ!?
この現象の原因
調べてみたところ、この事象には複数の原因がありました。
①外部遷移時、Nuxtのdestroyedが発火しない
このコードではVueインスタンスが破棄される、destroyed発火のタイミングでpopstateイベントを削除する処理を入れています。
しかし、外部遷移時にはVueインスタンスが破棄されず、destroyedが発火しません。その為、明示的なpopstateイベントの削除を行えていませんでした。
②Safariのbfchach
Safari独自のキャッシュの仕組みであるbfcache(バック/フォワードキャッシュ)という機能があり、ブラウザの戻る or 進むを行ったとき、前のページの情報をキャッシュから呼び出します。
これにより外部遷移後ブラウザバックした際に、popstateイベントが残った状態のNuxtアプリへ戻ることになります。
③Safariではページ読み込み時にもpopstateイベントが発火する
Safari(と古いChromeバージョン)ではページ読み込み時にもpopstateイベントが発火するようです。
https://developer.mozilla.org/ja/docs/Web/API/Window/popstate_event
そのため、ブラウザバック時にbfcacheからpopstateイベントの残ったページが読み込まれ、読み込み時にpopstateイベントが発火していたようです。
対処法
popstateイベントの代わりに、VueRouterのbeforeRouteLeaveを使用します。
beforeRouteLeaveは、ブラウザバックやVueRouterで前のページに戻る際に発火するナビゲーションガードです。beforedestroyより先に発火するので、ブラウザバック直前の確認処理などに利用できます。
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "LinkPage",
data() {
return {
userName: "",
};
},
// popstateでの処理を無くし、以下のbeforeRouteLeaveを追加
beforeRouteLeave(to, from, next) {
const answer = window.confirm("保存されません。よろしいですか?");
if (answer) {
next();
} else {
next(false);
}
},
});
</script>
上記のコードでは戻る時にダイアログでの確認を行い、OKなら処理続行、NGなら戻る処理を中断させます。
これでSafariやChromeなどの環境に左右されず、ブラウザバック時にのみ正常に動作するようになりました。
あとがき
複数の原因が重なっていて、原因解明まで非常に沼にハマってました。
結果的にはbeforeRouteLeaveを使っていれば早かったのですが、諸々の勉強になってよかったです。
popstateが軽くトラウマになった