Next.js(App Router)でブラウザバック/フォワードの分類に対応した画面遷移ログを出力する

# はじめに
本記事は[QualiArts Advent Calendar 2024](https://qiita.com/advent-calendar/2024/qualiarts) 17日目の記事になります。

管理画面を内製している会社は多いと思います。
弊社でも様々な職種の方が内製している管理画面を使いゲームの運用を行っています。
その中で要望をもらい、改修する機会が多々あります。
しかしこれらの改修に対して実際に改善された工数などの計測は難しい状況でした。

そのため、改善効果の可視化や潜在的な改善点を探すために管理画面上における各種ログを用いて計測・分析することとしました。
その一環として画面遷移ログを出力する必要がありました。

私が所属しているプロジェクトの管理画面は Next.js(App Router) を採用しています。
また、人によってはブラウザバック・フォワードを多用するためこれらにも対応させたいと考えていました。

この記事ではNext.js(App Router)上でブラウザバックに対応した形で画面遷移ログを出力するために実装した内容について紹介します。

今回紹介する実装に関しては下記の資料・リポジトリを参考にしています。
一部実装面などで表現を流用させていただいており、今回扱う範囲についてとても詳しく説明されているため合わせて見ることを推奨します。

https://speakerdeck.com/ypresto/hack-to-prevent-page-navigation-in-next-js

https://github.com/LayerXcom/next-navigation-guard

# App Router経由での画面遷移に対応させる
まずは、App Routerを利用した画面遷移時にログを取れるようにする必要があります。
今回はAppRouterContextに一部差し込みの実装を行う形で進めます。
差し込む際のイメージとしては下記のとおりです。

```tsx
import {
  AppRouterContext,
  AppRouterInstance,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useContext, useMemo } from "react";

export function useMyAppRouter(): AppRouterInstance | null {
  const origRouter = useContext(AppRouterContext);
  return useMemo((): AppRouterInstance | null => {
    if (!origRouter) return null;

    return {
      ...origRouter,
      push: (href, ...args) => {
        // 差し込む処理
        origRouter.push(href, ...args);
      },
    };
  }, [origRouter]);
}
````

上記のコメントの部分に今回のログを送信する処理を差し込みます。
replaceなども同じように処理を書き換えることが可能ですがここではpushのみを扱います。

今回はページ遷移時にログを送信したいためデータの送信に関しては navigator.sendBeacon() を使用します。

https://developer.mozilla.org/ja/docs/Web/API/Navigator/sendBeacon

また、タブ毎の遷移を追いたいのでSessionStorageを利用してidを管理します。
タブの複製などでidが重複してしまう可能性はありますがここでは考慮しません。

```tsx
"use client";

import {
  AppRouterContext,
  AppRouterInstance,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useContext, useMemo, useRef, useEffect, useCallback } from "react";

export function useTrackAppRouter(): AppRouterInstance | null {
  const tabId = useRef("");
  useEffect(() => {
    const id = sessionStorage.getItem("tabId");
    if (id) {
      tabId.current = id;
    } else {
      tabId.current = crypto.randomUUID();
      sessionStorage.setItem("tabId", tabId.current);
    }
  }, []);

  const trackNumber = useRef(1);
  const send = useCallback(
    (
      navigateType: "back" | "forward" | "push",
      fromPath: string,
      toPath: string,
      routerEvent: () => void,
    ) => {
      const url = 'API ENDPOINT';
      const blob = new Blob(
        [
          JSON.stringify({
            tabId: tabId.current,
            number: trackNumber.current,
            navigateType: navigateType,
            fromPath: fromPath,
            toPath: toPath,
          }),
        ],
        {
          type: "application/json",
        },
      );
      navigator.sendBeacon(url, blob);
      routerEvent();
      trackNumber.current++;
    },
    [],
  );

  const origRouter = useContext(AppRouterContext);
  return useMemo((): AppRouterInstance | null => {
    if (!origRouter) return null;

    return {
      ...origRouter,
      push: (href, ...args) => {
        send("push", location.pathname, href, () =>
          origRouter.push(href, ...args),
        );
      },
    };
  }, [origRouter, send]);
}
````

ここまででApp Routerのpushによって遷移する際にはログを送信できるようになりました。

# ブラウザバックに対応させる

ここからはブラウザバックに対応させます。

ブラウザバック・フォワードはそれぞれ個別に検知できるイベントはありません。
しかしどちらもpopstate Eventで検知することが出来るのでここに処理を差し込みます。

https://developer.mozilla.org/ja/docs/Web/API/Window/popstate_event

ブラウザバック・フォワードの判定を行うためにhistoryのセッション履歴から順序関係を取れるようにする必要があります。
そのため、pushState()へ渡るstateにidとしてhistory.lengthを代入します。

(下記の画像はイメージです)
![スクリーンショット 2024-12-17 22.23.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/419074/50f40dd3-38b5-caa7-37e8-3b2babf372b1.png)

popstate Event(PopStateEvent)から取得できるstateオブジェクトはpushState(), replaseState()で渡された値のコピーとなります。

https://developer.mozilla.org/ja/docs/Web/API/History/pushState

popstate Eventが発火しているときには既にhistoryは書き換わっているためhistory.stateも遷移後のstateへ変更されています。
(popstate Eventで渡ってくるstateと同じ)

そのため、popstate Eventにて遷移元のstateが取得できるようにcurrentStateとして保持します。
currentState.idとPopStateEvent.state.idの大小関係によりブラウザバック・フォワードの判定を行います。

![スクリーンショット 2024-12-17 22.34.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/419074/de822161-0336-5f6b-5168-0fbc0d7f3cd7.png)

また、ここではhistory.go()などによる履歴のジャンプは考慮しませんので必要な方は適宜判定を入れていただければと思います。

```tsx
  const currentState = useRef<any>(null);
  useEffect(() => {
    const originalPushState = history.pushState;
    history.pushState = function (state, unused, url) {
      state.id = history.length; // idとして現在のhistory数をそのまま流用
      state.url = url;
      originalPushState.call(this, state, unused, url);
      currentState.current = state;
    };

    return () => {
      history.pushState = originalPushState;
    };
  }, []);

  const popstateListener = useCallback(
    (event: PopStateEvent) => {
      send(
        currentState.current.id > event.state.id ? "back" : "forward",
        currentState.current.url,
        location.pathname,
        () => {},
      );
      currentState.current = event.state;
    },
    [send],
  );

  useEffect(() => {
    addEventListener("popstate", popstateListener);
    return () => {
      removeEventListener("popstate", popstateListener);
    };
  }, [popstateListener]);
````

最終的には下記のとおりとなります。

<details><summary>最終コード</summary>

```tsx
"use client";

import {
  AppRouterContext,
  AppRouterInstance,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useContext, useMemo, useRef, useEffect, useCallback } from "react";

export function useTrackAppRouter(): AppRouterInstance | null {
  const tabId = useRef("");
  const currentState = useRef<any>(null);
  const trackNumber = useRef(1);

  useEffect(() => {
    const id = sessionStorage.getItem("tabId");
    if (id) {
      tabId.current = id;
    } else {
      tabId.current = crypto.randomUUID();
      sessionStorage.setItem("tabId", tabId.current);
    }
  }, []);

  const send = useCallback(
    (
      navigateType: "back" | "forward" | "push",
      fromPath: string,
      toPath: string,
      routerEvent: () => void,
    ) => {
      const url = 'API ENDPOINT';
      const blob = new Blob(
        [
          JSON.stringify({
            tabId: tabId.current,
            number: trackNumber.current,
            navigateType: navigateType,
            fromPath: fromPath,
            toPath: toPath,
          }),
        ],
        {
          type: "application/json",
        },
      );
      navigator.sendBeacon(url, blob);
      routerEvent();
      trackNumber.current++;
    },
    [],
  );

  const popstateListener = useCallback(
    (event: PopStateEvent) => {
      send(
        currentState.current.id > event.state.id ? "back" : "forward",
        currentState.current.url,
        location.pathname,
        () => {},
      );
      currentState.current = event.state;
    },
    [send],
  );

  useEffect(() => {
    addEventListener("popstate", popstateListener);
    return () => {
      removeEventListener("popstate", popstateListener);
    };
  }, [popstateListener]);

  const origRouter = useContext(AppRouterContext);
  return useMemo((): AppRouterInstance | null => {
    if (!origRouter) return null;

    return {
      ...origRouter,
      push: (href, ...args) => {
        send("push", location.pathname, href, () =>
          origRouter.push(href, ...args),
        );
      },
    };
  }, [origRouter, send]);
}
```
</details>

下記のような形で呼び出します。

```tsx
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import React from "react";
import { useTrackAppRouter } from "@/apps/admin/hooks/trackAppRouter";

export function AppRouterProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const trackRouter = useTrackAppRouter();
  if (!trackRouter) {
    return <>{children}</>;
  }

  return (
    <AppRouterContext.Provider value={trackRouter}>
      {children}
    </AppRouterContext.Provider>
  );
}
```

```tsx:layout.tsx
export default function RootLayout({
  children,
}: PropsWithChildren): ReactElement {
  return (
    <AppRouterProvider>
    ...
    </AppRouterProvider>
  );
} 
```

これによりブラウザバック・フォワードに対応した形で画面遷移ログを出力することが出来ます。

# おわりに

今回は管理画面について分析・計測するためのログとしてブラウザバックに対応した画面遷移ログを出力できるようにしました。

これらのログを使ったダッシュボードを作るところまで紹介するつもりでしたが時間が足りなかったので別の機会で紹介できればと思います。

また、改良点などありましたらコメントいただけると嬉しいです。

最後まで見ていただきありがとうございました。

# 参考文献
https://speakerdeck.com/ypresto/hack-to-prevent-page-navigation-in-next-js

https://github.com/LayerXcom/next-navigation-guard

https://developer.mozilla.org/ja/docs/Web/API/Navigator/sendBeacon