Closed8

【Next.js】ブラウザバック時にスクロール位置を強制的に元に戻す

catnosecatnose

一部のページでブラウザバック時にスクロール位置がずれる

Next.jsを使ったWebアプリで(一部のページで)ブラウザバック時にどうしてもスクロール位置が元の位置からずれてしまう。状況はこんな感じ。

  • MacのChrome/Firefox/Safari
  • Next.js 11.1.0
  • useSWRにより一覧データをフェッチして表示している。ブラウザバック時にはキャッシュが残っているため、レイアウトシフトは発生しない(はず)
    • 画像やローディングの表示によるレイアウトシフトもない
  • experimental.scrollRestorationtrueにしても問題は発生する
catnosecatnose

どうしても原因が分からないので、強制的に元のスクロール位置に戻すためのカスタムフックを作ることにした。

アプリ全体のスクロールの挙動の上書きはせずに特定のページでのみ動くようにする。リスクが大きい + ネックとなるポイントが色々ある + 後から取り外しやすくするため。そのぶんpageコンポーネントの中で処理を完結させる必要がある。

catnosecatnose

実装の方針

  1. ページを離れる直前に一時的にスクロール位置を保存
    • ブラウザをリロードしたら保存した位置がリセットされるようにmemory-cacheを使う
    • Next.jsのscrollRestorationの実装も似たことをやっているがsession storageを使っている
  2. ブラウザバック時にuseEffect内で保存していたスクロール位置に戻す

で、地味に厄介なのが「ブラウザバックかどうか」の判断。ブラウザバック時にのみ位置を調整するようにしないと、next/linkを使ってページを訪れたときにまでスクロール位置へと遷移してしまう。

しかし、このpageコンポーネントの中で処理を完結したいのでwindow.addEventListener('popstate', ...)が使えない。仕方なく、ページを離れる直前にURLにハッシュをつけて、そのハッシュの有無を見てブラウザバックかどうかを判断する。

完成物

そんなわけで出来上がったのがコレ。

/**
 * 実験的なカスタムフック
 * - ブラウザバック時にスクロール位置を保持したいページにおいてこのフックを呼び出す
 * - ルートが変わる瞬間にスクロール位置を記録し、ブラウザバック時にその位置にスクロールする
 */

import { useEffect } from 'react';
import Router, { useRouter } from 'next/router';
import memoryCache, { CacheClass } from 'memory-cache';

const scrollYCache: CacheClass<string, number> = new memoryCache.Cache();
const browserBackHashName = `restore`;

// スクロール位置を保存する
function saveScrollPos(pathname: string) {
  scrollYCache.put(pathname, window.scrollY);
}

// スクロール位置を復元する
function restoreScrollPos(pathname: string) {
  const scrollY = scrollYCache.get(pathname);
  if (scrollY) window.scrollTo(0, scrollY);
}

export function useRestoreScrollOnBack() {
  const router = useRouter();

  useEffect(() => {
    // ブラウザバックを示すハッシュがついている場合はスクロール位置を復元
    if (location.hash === `#${browserBackHashName}`) restoreScrollPos(router.pathname);

    const onRouteChangeStart = () => {
      // 現在のURLにハッシュをつける
      // location.hashによりハッシュを操作するとFireFoxでブラウザバックの挙動がおかしくなるためrouter.replaceを使う
      router.replace({ hash: browserBackHashName }, undefined, { shallow: true });
      saveScrollPos(router.pathname);
    };

    // このpageを離れる直前に発火
    Router.events.on('routeChangeStart', onRouteChangeStart);
    return () => {
      Router.events.off('routeChangeStart', onRouteChangeStart);
    };
  }, []);
}

で、ブラウザバック時にスクロール位置を保持したいpageコンポーネントの中で呼び出す

pages/foo.tsx
const FooPage = () => {

  useRestoreScrollOnBack()

  return <>...</>
}

注意点としてはuseStateを使って一部のコンテンツを折りたたんでいるような場合には向いていない(クリックで開くボタンがある等)。

ブラウザバック時にuseStateの値が元に戻ってしまう(開いていたコンテンツが閉じてしまう)ため、スクロール位置を単純に元に戻すとずれる。
もちろんuseStateの値もメモリやsession storageキャッシュするという手もあるが。

わたるわたる

getServerSidePropsを使っているページ:
スクロール位置がトップに戻る

getStaticPropsを使っているページ:
スクロール位置がキープされている

(どちらもuseSWRを使用している)

という現象を確認しました。
全く理解できていませんが、メモさせていただきます。

catnosecatnose

情報ありがとうございます。なるほど…propsの取り方による違いも今度確認してみます。

たかけんたかけん

以前、getServerSidePropsを使用していた際に同様の現象が起きていました。
再度、解決できないか挑戦してみたところ next.config.jsexperimental.scrollRestorationtrue にすることで、ブラウザバック時もスクロール位置が復帰するようになりました!
Next.jsのバージョンは 12.0.8 です。
以前は、Next.jsのバージョンがv10かv11だったと思うので、アップデートで対応されたのかもしれないです。
(どの時点から対応されたかまでは調査していないですが)

catnosecatnose

Next.js v12.0.9 でexperimental.scrollRestoration: trueとして試したところスクロール位置が復元されるようになっていました!情報ありがとうございます!!

このスクラップは2022/08/22にクローズされました