GatsbyでURL設計を変えたらスクロールがぶっ壊れてハマった話

GatsbyでURL設計を変えたらスクロールがぶっ壊れてハマった話

  • Post Author:

概要

Gatsbyで弊社サイトの事例紹介ページを修正した際に発生したトラブルについてまとめます。

まず、このページはこんな挙動になっています。

  • 一覧ページでカードをクリックするとモーダルが開く
  • モーダルを開いている間はその枠外で一覧ページが見える(↓こんな感じ)

  • モーダルを閉じると、開く前の一覧ページのスクロール位置へ戻る

いわゆる「よくあるモーダル構成のページ」です。

今回の修正対象はモーダル周りではなかったのですが、作業を進めているうちに、なぜかスクロールの挙動がおかしくなってしまいました。

本記事では、この「関係ないところを触ったはずなのに壊れた」問題の原因と、解決までの過程をまとめます。


経緯

変更①:カテゴリごとにURLを付与するように変更した

一覧ページでは、カテゴリごとにURL(/works/category/xxx/)を分けるようにしたため、モーダルを閉じたときに元のカテゴリのURLへ戻る必要が生じました。
具体的には、closeModalwindow.history.back() だと戻り先のURLを制御できないので navigate() に変更する必要がありました。

// 元のcloseModal
const closeModal = () => {
  if(props.location.state?.fromWorksPage) {
    window.history.back()  // ← 戻り先を制御できない
  } else {
    navigate('/works')
  }
}

そこで、カードクリック時の現在のパスをstateで管理する定数 savedPreviousPath を定義し、navigate() に渡すように変更しました。

// PreviousPathをstateで管理するための定数を定義
const [savedPreviousPath, setSavedPreviousPath] = useState('/works/')
const closeModal = () => {
  navigate(savedPreviousPath, { // 変更
    state: {
      fromModal: true,
    }
  })
}
// 一覧ページのカードクリック時に現在のパスをコールバック経由で親に渡す
<WorksPageContent
  onSavePreviousPath={setSavedPreviousPath} // 追加
  ...
/>

変更②:gatsby-browser.js の中身って不要では?と思ってコメントアウトしてしまった

元々 closeModalwindow.history.back() を使っていたため、Gatsbyのルーターを経由しないのでgatsby-browser.js内のshouldUpdateScroll がそもそも発火していませんでした。
つまりコメントアウト直後は一見何も影響してなさそうに見えたのですが、モーダルを開いたときに背景の一覧が上端にスクロールされる問題をここで見逃してしまいました。

問題発生:スクロールがおかしくなった

navigate() に変更したことでGatsbyのルーターを経由するようになり、カテゴリ選択中にモーダルを開いて閉じるごとにスクロールがリセットされるようになってしまいました。

さらに確認してみると、修正②で見逃していたモーダルを開いた時点で既にスクロールがリセットされていることにも気付きました。

ちなみにReact Router単体ではスクロールリセットはデフォルトでは行われません。
一方Gatsbyでは「ページ遷移時はページ上端から始まる方が自然」という設計思想により、デフォルトでスクロールがリセットされるようになっています。
…というのを私は今回の問題に直面するまで知りませんでした。

最初は navigatepreventScrollReset: true を渡せばいいと思っていましたが、Gatsbyのバージョンによっては効かないことがあるようで、現在の環境では見事に効きませんでした。
次に window.scrollTo で対処しようとしましたが、コンポーネント側からは制御できませんでした。

そこで、以下の対応を試しました。

やったこと

gatsby-browser.js のコメントアウトを戻す

モーダルを開いたときの背景のスクロールは固定されるようになりましたが、カテゴリ選択中にモーダルを開いて閉じた際のスクロールリセットは相変わらずでした(gatsby-browser.js の既存コードの謎についてはおまけ①を参照)。

②スクロール位置を state で管理して navigate() に渡す

カードクリック時のスクロール位置を state で管理し、navigate() に渡すことで、モーダルを閉じた際のスクロールリセットを防ぐようにしました。

// ScrollPositionをstateで管理するための定数を定義
const [savedScrollPosition, setSavedScrollPosition] = useState(0)
const closeModal = () => {
  navigate(savedPreviousPath, {
    state: {
      fromModal: true,
      scrollPosition: savedScrollPosition // 追加
    }
  })
}
// 一覧ページのカードクリック時にスクロール位置をコールバック経由で親に渡す
<WorksPageContent
   onSavePreviousPath={setSavedPreviousPath}
   onSaveScrollPosition={setSavedScrollPosition} // 追加
   ...
/>

これで、カテゴリ選択中にモーダルを開いて閉じた際の不要なスクロールリセットは発生しなくなりました。


おまけ①:gatsby-browser.js の既存コードを簡潔に

gatsby-browser.js の既存の実装

// gatsby-browser.js
exports.shouldUpdateScroll = ({ pathname, prevRouterProps, getSavedScrollPosition }) => {
  const isWorksDetail = pathname.match(/\/works\/.+/)
  const prevLocation = prevRouterProps?.location
  if (isWorksDetail && prevLocation) {
    // just passing false doesn't work.
    // I can't understand why.
    // so we get previous postionY, then set its value when you open a modal.
    const prevPosition = getSavedScrollPosition(prevLocation)
    window.scrollTo(...(prevPosition || [0, 0]))
    return false
  }
  return true
}

について、

// just passing false doesn't work.
// I can't understand why.
// so we get previous postionY, then set its value when you open a modal.

というコメントの通り、「return falseだけでは動かなかった」ために力技で実装されていましたが、原因はシンプルでした。
当初は window.history.back() を使っていたため、shouldUpdateScroll 自体が発火していなかっただけです。
navigate() に変更してルーターを経由するようになったことで shouldUpdateScroll が正しく動作するようになり、getSavedScrollPositionwindow.scrollTo は不要になりました。
ということで、

exports.shouldUpdateScroll = ({ pathname }) => {
  // /works/ への遷移はスクロールリセットを許可
  if (pathname === '/works/') {
    return true
  }
  // それ以外はスクロールリセットしない
  return false
}

とスッキリ書けるようになりました。やったぜ★


おまけ②:モーダルを開いている間のカテゴリフィルタリング状態の保持

なお、スクロール問題とは別に、カテゴリURLをモーダル側に持たせていない設計上、モーダル表示中に選択中のカテゴリが失われるという別の問題も発生したため、あわせて対応しました。
モーダルのURLは /works/category/xxx/xxx ではなく/works/xxx/ なので、モーダルを開いている間は pathname からカテゴリが取得できなくなり、このままだとモーダルの枠外で見える一覧ページのフィルタリングが解除されてしまいます。
これを防ぐため、モーダルを開く前のフィルタリング状態を state で保存しておき、モーダルを閉じたときにリセットする方法で対処しました。

// FilteredWorksをstateで管理するための定数を定義
const [savedFilteredWorks, setSavedFilteredWorks] = useState(null)

savedFilteredWorks は未保存状態を表すため、初期値およびリセット時には0 ではなく null を設定しています。

const closeModal = () => {
  setSavedFilteredWorks(null) // 追加
  navigate(savedPreviousPath, {
    state: {
      fromModal: true,
      scrollPosition: savedScrollPosition
    }
  })
}
// 一覧ページのカードクリック時にフィルタリングされているカテゴリをコールバック経由で親に渡す
<WorksPageContent
  onSavePreviousPath={setSavedPreviousPath}
  onSaveScrollPosition={setSavedScrollPosition}
  onSaveFilteredWorks={setSavedFilteredWorks} // 追加
  ...
/>

まとめ

Gatsbyでモーダルを実装する際のスクロール関連におけるポイントをまとめます。

  • モーダルを開くときのスクロールリセットは gatsby-browser.jsshouldUpdateScroll で制御する
  • モーダルを閉じるときは window.history.back() ではなく navigate() を使い、戻り先のパスを明示的に指定する

特に shouldUpdateScroll はGatsby固有の関数で、コンポーネント側からは制御できないスクロールリセットを止める唯一の手段です。Gatsbyでモーダルを実装する際はぜひ意識してみてください。

we are hiring

優秀な技術者と一緒に、好きな場所で働きませんか

株式会社もばらぶでは、優秀で意欲に溢れる方を常に求めています。働く場所は自由、働く時間も柔軟に選択可能です。

現在、以下の職種を募集中です。ご興味のある方は、リンク先をご参照下さい。

コメントを残す