ある日、海外チームからバグの報告がありました。日付と曜日が対応していないというのです。
テスト環境でいくつか設定を変更して試したところ、OSのタイムゾーンをシンガポール標準時 (UTC+8) に変更したときに、曜日が2つずれることが確認できました。4/2は金曜日ですが、4/2が水曜日として表示されてしまっています。
原因調査
この部分のソースコードはだいたい以下のようになっていました。Moment.jsというライブラリを使って日付と曜日を表示しています。
// TimelineItem.jsx
// date は 2021-04-02 のような文字列
const DateBox = ({ date, showMonth }) => {
const mDate = moment(date);
const dateHuman = showMonth ? mDate.format("M.D") : mDate.format("D");
return (
<div>
<div>{dateHuman}</div>
<div>{moment.weekdaysShort(mDate)}</div>
</div>
);
};
dateを日付と曜日にそれぞれ加工して表示しているだけで、何も変なところはありません。広く使われている枯れたライブラリであるMoment.jsに限ってこんなバグはあるでしょうか?
さらに調べてみます。
$ node
> const moment = require("moment");
> mDate = moment('2021-04-02')
Moment<2021-04-02T00:00:00+09:00>
> mDate.format("D")
'2'
> moment.weekdaysShort(mDate)
'Fri'
> mDate = moment('2021-04-02T00:00:00+10:00')
Moment<2021-04-01T23:00:00+09:00>
> mDate.format("D")
'1'
> moment.weekdaysShort(mDate)
'Sun'
タイムゾーンを1時間後にすると、今度は後ろに2つずれます。
Moment.jsのバージョンを最新にしても問題は解決しません。
よくわからなくなって、weekdaysShortに関するバグが既に報告されていないか調べることにしました。ぴったりのものは見つかりませんでしたが、たとえば次のようなissueがあったので読んでみました。
https://github.com/moment/moment/issues/4066
こんなバグがあったようです。
// 正しく動く
moment.weekdays(true);
// 正しく動かない
moment.localeData('zh_CN').weekdaysShort()
ここで気がつきました。もしかしてこの weekday
という関数は、ある日付の曜日を返す関数ではないのでは? そう考えてみると、Momentオブジェクトのメソッドではないことも、 weekdays という奇妙な名前にも説明がつきます。
試しに整数を入れてみたら、どういう関数なのか予想がつきました。
> moment.weekdaysShort()
[
'Sun', 'Mon',
'Tue', 'Wed',
'Thu', 'Fri',
'Sat'
]
> moment.weekdaysShort(0)
'Sun'
> moment.weekdaysShort(1)
'Mon'
> moment.weekdaysShort(2)
'Tue'
本来はこうすべきだったようです。
> mDate.format("ddd")
'Fri'
つまり、最初にこのプログラムが書かれたときに、 moment.weekdaysShort
関数を誤った方法で使っていて、それが毎日UTC9時ちょうど (日本標準時で0時ちょうど) のMomentオブジェクトを与えたときだけたまたま正しく動作していたというのが真相でした。
不幸なことに、このコードはTypeScript化されていない部分でした。TypeScriptであれば型エラーにより問題に気付けていた可能性が高いでしょう。
なぜうまく動いていたのか
ここまでで問題自体は解決されていますが、なぜこの関数にMomentオブジェクトを渡したときにエラーにならなかったのか、そしてその結果がたまたまうまくいったのはなぜかという点が気になります。
weekdaysShort
の実装は src/lib/locale/lists.js
内の listWeekdaysImpl
にあります。 https://github.com/moment/moment/blob/2.29.1/src/lib/locale/lists.js#L39-L73
listWeekdaysImpl
は途中の引数を省略可能なので、そのために引数をずらす処理が最初にあります。
// localeSorted, format, index は全て省略可能
// fieldには 'weekdays' や 'weekdaysShort' などが入る
function listWeekdaysImpl(localeSorted, format, index, field) {
if (typeof localeSorted === 'boolean') {
// ...
} else {
// localeSortedがbooleanでない場合は引数をずらす
format = localeSorted;
index = format;
localeSorted = false;
if (isNumber(format)) {
index = format;
format = undefined;
}
format = format || '';
}
// ...
}
この結果、今回渡されているMomentオブジェクトはformatとindexに入ることになります。
このindexは次のようにオフセットの計算に使われます。
return get(format, (index + shift) % 7, field, 'day');
オブジェクトが計算式に渡されたときは [[ToPrimitive]]
型強制が発生します。Momentオブジェクトは valueOf
メソッドを実装しているため、これが呼ばれます。
試しにvalueOfを呼んでみると、こんな値が返ってきます。
> moment('2021-04-02T00:00:00Z').valueOf()
1617321600000
> moment('2021-04-02T00:00:01Z').valueOf()
1617321601000
> moment('1970-01-01T00:00:00Z').valueOf()
0
つまり、Moment.jsの valueOf
は Unix epoch からのミリ秒数を返すようです。
1日は86400000ミリ秒で (7を法として) 1 と合同なので、どんなタイムゾーンであっても1日進めば weekdays
の返す曜日も1日分ずつ進みます。また、 1時間は3600000ミリ秒で (-2) と合同なので、タイムゾーンが1時間東に移動すると (そのタイムゾーンで日付文字列をパースしたときの結果は1時間分過去に戻るので) 曜日は2日分進むことになります。これなら確かにここまでの現象と辻褄が合います。少なくともどこかのタイムゾーンでは実際の曜日と同じ結果が返ることになりますが、日本標準時がそうだったというのは偶然でしょう。
最後に、Momentオブジェクトはさらにformat引数にも含まれていますが、これはさらに src/lib/units/day-of-week.js
内の localeWeekdays
系の関数に渡されます。 https://github.com/moment/moment/blob/2.29.1/src/lib/units/day-of-week.js#L123-L152 ここで localeWeekdaysShort
と localeWeekdaysMin
は第二引数を無視しています。 localeWeekdays
は与えられたフォーマットを正規表現のテストにかけています。ここでも [[ToPrimitive]]
が発生し、日付をあらわす文字列が返されるため、少なくともエラーにならずにtrueかfalseが返されることになります。
追記 04/06
Q. まだMoment.jsを使ってるのか
A. そうなんです。 ただ、Moment.jsは機能面では凍結されているものの、メンテナンスは今後も行われるようなので、既存のコードを移行する優先度は低いと考えています。
Q. なぜリファレンスを見ないのか
A. ごもっともですが少し言い訳させてください。まず、最初にこのコードを書いたのは私ではないので、そのときの話はわかりません (誰が書いたかを調べる予定はありません)。そして、バグの調査は何もわからないところから一つずつ試していく過程で、今回はリファレンスに当たるよりも前の試行で問題のありかがわかったので、参照する機会がありませんでした。そして後半の調査は、規定された振舞いではなく規定の外にある振舞いを調べるものなので、リファレンスを見る意味はありません。
まとめ
- タイムゾーンをずらすと曜日がずれる、という問題が起きていた
- Moment.jsの関数の使い方を誤っており、日本のタイムゾーンでたまたま動いていただけだった
- 型は重要