Day 10: ダークモードとライトモードが混在するバグを直す
要約 (TL;DR)
Webアプリの UI がユーザーの現地時間に応じて変化する場合、Next.js などの静的サイトジェネレーターを利用するのであれば、HTML文書がブラウザーによってのみ生成されるようにするべき。
例えば、午後6時以降、自動的にダークモードになるようにするには、React を用いる場合、useEffect() 内のコードで HTML 文書が生成されるようにすればいい。そうしないと、上記の見出し画像のように、ブラウザーが生成した Google マップはライトモードなのに、静的生成されたボタンはダークモードで表示されるというようなことが起こる。
バグが発生した文脈
My Ideal Map App という、Googleマップの使い勝手を改善するwebアプリを制作している。(詳しくは、以下の過去記事を参照。)
改善点の一つが、午後6時以降、自動的にダークモードにすること。外出時にスマホ等で使うことを念頭に置いたアプリなので、外が明るい時は明るく、外が暗い時は暗く、表示させたい。わざわざ設定画面で手間をかけていちいち変える必要がないように。(詳しくは、以下の過去記事を参照。)
スクリーンいっぱいに表示させる Google マップの上に重ねて表示するボタンについても、ダークモード仕様をデザインした。
Googleマップと同様に、ボタンの配色も午後6時以降ダークモードに切り替わるようにプログラミングし、Cloudflare Pages にコードをアップした。Cloudflare Pages は自動的に URL を生成してくれるので、webアプリがどのようにブラウザーで表示されるかチェックできる。
バグ発生
この時点で、日本時間の午後3時ごろ。したがって、ライトモード(以下の画像の左側)で表示されるはずだった。
My Ideal Map App のライトモード(左)及びダークモード(右) UI (筆者によるスクリーンショット)
しかし、実際にはこんな風に表示された。
ダークモードのボタンとライトモードの地図(筆者によるスクリーンショット)
ボタンだけダークモードで表示された。。。雨雲のようだ(笑)。
このバグは、https://05da7f84.mima.pages.dev にて確認できる。午前6時から午後6時の間であれば、上記と同じように雨雲が見れるはず(笑)。
なお、ボタンを雲の形にした理由は、以下の過去記事を参照。
* * *
なぜ、ダークモードとライトモードが混在してしまうのか、理解できるまでに数時間かかった。
バグの原因:Next.js による静的サイト生成
原因は、私が Next.js を用いて web アプリのコードを書いているせいだった。
* * *
ウェブサイトを生成する JavaScript コードを、Next.js を利用して書くと、そのコードをサーバーにアップロードした時点で、サーバーによってコードが実行され、HTML文書が生成される。この前もって作られたHTML文書が、ユーザーがサイトにアクセスした時にブラウザーに送られる。いわゆる「静的サイト生成 (static site generation) 」という仕組みである。(詳しくは、Next.js の公式ドキュメントを参照。)
ちなみに、昔、なぜ「静的サイト生成」が望ましいのか、create-react-app じゃ何故ダメなのか、色々調べて英語記事にまとめたことがある。何故か、紅葉の龍安寺石庭の写真を見出し画像にして(笑)。
* * *
ダークモードとライトモードが混在した理由は以下の通り。
Web アプリをアップロードした Cloudflare Pages のサーバーは世界中にある。日本時間午後3時にアップロードした時点で、サーバーのある場所では夜だったらしい。結果、ダークモードの配色で HTML 文書が生成された。
しかし、Google マップの地図はブラウザーからアクセスした場合にのみ生成される(Google Maps Platform の公式ドキュメントには明確に書かれていないが、Starkov 2017 など、多くの人が指摘している)。したがって、ユーザーとして私がサイトに午後3時過ぎにアクセスしたことで、ブラウザーが JavaScript コードを実行し、ライトモードの配色で地図が表示された。
結果として、ボタンはダークモード、地図はライトモードとなってしまった。。。
なお、問題の本質は、サーバーがタイムゾーンの異なる国にあることや、Googleマップを埋め込んでいることではない。サイトをアップする時刻とユーザーがそのサイトを見る時刻が異なる限り、ウェブデザイナーが意図しない UI をユーザーが見ることは起こりうる。
配色などの UI 要素が、ユーザーの現地時間に依存する場合、Next.js や Gatsby などの静的サイトジェネレーターを利用するのは注意が必要。それが、このバグを通して私が学んだことだった。
解決方法
このバグを直すには、サーバーが「静的サイト生成 (static site generation)」をせず、ユーザーのブラウザーによってのみ HTML 文書が生成されるように、コードを書く必要がある。
だったら、Next.js を使わなければいい、とも思ったが、色々便利だし、使い慣れているので、Next.js でアプリを作り続けたい。(My Ideal Map App の配色デザインの際に活用している自作の Triangulum Color Picker は、Next.js で作った。)
半日ほど、色々試した結果、ようやく、Dong (2020) 及び Comeau (2021) に解決方法が書かれているのを見つけた。
コツは、useEffect() を使うこと。ブラウザー上のみで実行されるコードは、useEffect() の引数として記述する、というのが、React用の静的サイトジェネレーターである Next.js (及び Gatsby) のルールらしい。知らんかった。
具体的には以下の通り。My Ideal Map App のコードを例にとって説明してみる。
バグを生んだコード
Next.js において index.html を生成するコードである pages/index.js として、以下のようなコードを書いていた(関連のない部分は省略している)。
// pages/index.js
import {NightModeProvider} from '../context/NightModeContext';
import MenuButton from '../components/MenuButton';
import SearchButton from '../components/SearchButton';
import LocatorButton from '../components/LocatorButton';
import SavePlaceButton from '../components/SavePlaceButton';
import Map from '../components/Map';
function HomePage() {
return (
<>
<NightModeProvider>
<MenuButton />
<SearchButton />
<LocatorButton />
<SavePlaceButton />
<Map /> {/* where Google Maps will be embedded */}
</NightModeProvider>
</>
);
}
export default HomePage;
四つのボタンと、Googleマップを埋め込む <Map> というコンポーネントを作り、それぞれがダークモードに切り替わるように、<NightModeProvider> というコンポーネントの子要素としている。<NightModeProvider>は、React Context Provider を用いて、以下のように定義している。
// context/NightModeContext.js
import {createContext} from 'react';
const NightModeContext = createContext();
export function NightModeProvider(props) {
let nightMode;
const currentTime = new Date();
const currentHour = currentTime.getHours();
if (currentHour < 6 || currentHour >= 18) {
nightMode = true;
} else {
nightMode = false;
}
return <NightModeContext.Provider value={nightMode} {...props} />;
}
コードを実行するコンピューターの時計が午後6時以降かつ午前6時前であるかをチェックし、そうであるなら、nightMode を true として、子要素であるコンポーネントに送る。(詳細は、過去記事 Day 5 の第3節を参照。)
バグを直したコード
ボタンを表示するためのHTML文書が、サーバーにコードをアップロードした時点で作成されないようにするため、clientSideRendering という state 変数を作り、初期値を false にする。そして、この変数が true である時のみ、ボタンが表示されるようにする。
import {useState} from 'react'; // ADDED
...
function HomePage() {
const [clientSideRendering, setClientSideRendering] = useState(false);
return (
<>
<NightModeProvider>
{/* REVISED FROM HERE */}
{clientSideRendering && <MenuButton />}
{clientSideRendering && <SearchButton />}
{clientSideRendering && <LocatorButton />}
{clientSideRendering && <SavePlaceButton />}
{/* REVISED UNTIL HERE */}
<Map />
</NightModeProvider>
</>
);
}
...
サーバーがこのコードを実行するときは、clientSideRendering が false の値をとるので、ボタンを表示する HTML 文書は生成されない。
そして、clientSideRendering の値を true に変更するコードを、useEffect() のコードブロック内に追加する。ユーザーがブラウザーを使ってアプリにアクセスした時のみ実行するため。
import {useState, useEffect} from 'react'; // REVISED
...
function HomePage() {
const [clientSideRendering, setClientSideRendering] = useState(false);
// ADDED FROM HERE
useEffect(() => {
setClientSideRendering(true);
}, []);
// ADDED UNTIL HERE
return (
<>
<NightModeProvider>
{clientSideRendering && <MenuButton />}
{clientSideRendering && <SearchButton />}
{clientSideRendering && <LocatorButton />}
{clientSideRendering && <SavePlaceButton />}
<Map />
</NightModeProvider>
</>
);
}
こうすることで、ユーザーのスマホやパソコンの時刻に従ってダークモードにするかどうかを判断して、ボタンの HTML コードが生成されるようになる。
なお、useEffect() 内のコードは、ユーザーがサイトにアクセスした後に一度だけ実行すればいいので、二つ目の引数は、[] (empty array) とする(Reactの公式ドキュメントを参照)。
以上のコードにより、午後6時から午前6時まではダークモードが以下のように正しく表示されるようになった。
午前6時から午後6時までは、ライトモードが表示される。デモはこちら。もし、昼なのにダークモードが、夜なのにライトモードが表示されたら、是非ご一報を(この記事へのコメントを書き込んでください)。
次のステップ
これでボタンについての作業がようやく終わった。
次のステップは、この4つのボタンに機能を付け加えること。まずは、離陸する飛行機のアイコンがついている右下のボタン。これを押すと、ユーザーの現在位置が表示されるように、プログラミングをする。
注: この記事は、英語で書いた同内容の記事を和訳すると同時に日本人向けに編集したものです。
引用文献
Comeau, Josh (2021) “The Perils of Rehydration”, joshwcomeau.com, May 30, 2021.
Dong, Hao (2020) “Render client-side only component in Next.js”, Hao's Learning Log, Jun 30, 2020.
Starkov, Ivan (2017) “It is expected, google map api, over which this component build does not support server tile rendering...”, GitHub Issues for google-map-react, #302, Feb 15, 2017.
この記事が気に入ったらサポートをしてみませんか?