Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

ポーリングだけDateが文字列化し、getTime is not a functionで盤面が落ちた

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

同じ画面でもSSRの初期描画とJSON経由の自動更新ポーリングでは、Date型フィールドの型が食い違いうる。初期描画はDateのまま復元されるが、ポーリング応答はres.json()を通ると文字列化され、Date前提のコードだけがそこで壊れる。

結論

Date型のフィールドは、同じ画面の中でも描画経路によって型が食い違うことがあります。

SSR(React Server Components)の初期描画はDateをそのままDateとして復元しますが、画面内の自動更新が別途叩くREST APIのJSONレスポンスは、Dateを保持しません。res.json()で受けると、Dateだったフィールドはただの文字列になって返ってきます。

その文字列が、Dateを前提に.getTime()を呼ぶ整形処理へそのまま渡ると、TypeError: ...getTime is not a functionで落ちます。

症状

学校の廊下に置いた常時表示サイネージで、特定のパターンの盤面だけが起動して10秒ほど経つと「問題が発生しました」というエラー画面に切り替わる障害が本番で起きました。

  • 起動直後の初期表示は正常。エラーは即座には出ない
  • ニュース帯を表示するパターンの盤面だけで発生する。ニュースを描かないパターンは無傷
  • ブラウザのlogcatには s.getTime is not a function とだけ残る
  • 手元でリロードしても、初期表示は毎回問題なく通る

「最初は動くのに、少し経つと壊れる」という時間差と、「特定のパターンだけ」という限定のされ方が、原因を追いにくくしていました。

原因

このサイネージは、初期表示をSSRで返したあと、一定間隔で自分自身のAPIを叩いて内容を更新するポーリングを持っています。

// SignageClient.tsx(修正前)
if (res.ok) {
  setData((await res.json()) as SignagePayload);
}

SignagePayloadにはニュースの公開日であるpublishedAtなど、型定義上はDateのフィールドが含まれます。ところがres.json()はHTTPレスポンスのJSON本文をそのままパースするだけなので、シリアライズの時点でDateではなくISO文字列になっていたものを、文字列のまま返します。

初期描画(SSR→hydration)はここを素通りできていました。React Server Componentsのシリアライズ形式はDateを型情報つきで運べるため、クライアント側で受け取った時点でDateはすでにDateのままだったからです。同じpublishedAtというフィールドでも、初期描画とポーリングとでは通ってきた経路が違い、届く型も違っていました。

ニュース帯の日付表示は、このpublishedAtに対して次の関数を呼んでいました。

// SignageBoardView.tsx(当時)
function formatNewsDate(d: Date): string {
  if (Number.isNaN(d.getTime())) {
    return "";
  }
  return d.toLocaleDateString("ja-JP", {
    timeZone: "Asia/Tokyo",
    month: "numeric",
    day: "numeric",
  });
}

引数dの型注釈はDateですが、TypeScriptの型は実行時には存在しません。ポーリング経由で文字列が渡ってきた瞬間、1行目のd.getTime()で例外になります。呼び出し元でこの例外を捕まえていなかったため、error boundaryがコンポーネントの区画ごとエラー画面に置き換えました。ニュース帯を描くパターンの盤面だけが「問題が発生しました」に倒れ、ニュース帯を持たないパターンは同じ障害の影響を受けなかった理由もここにあります。

「起動から10秒ほどで落ちる」のは、初期表示がSSRの結果をそのまま出しているあいだは無事で、最初のポーリングが返ってきた瞬間に初めて文字列が流れ込むためです。

直し方

res.json()をやめ、レスポンス本文を自分でJSON.parseし、reviverでDate型フィールドだけを復元するようにしました。

// rotation.ts
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;

export function reviveSignageDate(_key: string, value: unknown): unknown {
  if (typeof value === "string" && ISO_DATETIME_RE.test(value)) {
    return new Date(value);
  }
  return value;
}
// SignageClient.tsx(修正後)
if (res.ok) {
  setData(JSON.parse(await res.text(), reviveSignageDate) as SignagePayload);
}

reviverは正規表現で「日時(T区切りの時刻を含む)」だけを対象にしています。ペイロードにはforecastDateや課題のdeadlineのように、型上は文字列のまま扱いたいYYYY-MM-DD形式のフィールドも混ざっているため、ここまで文字列からDateへ変換してしまうと別の箇所を壊します。時刻付きのISO文字列だけを狙い撃ちすることで、Date化してよいフィールドとそうでないフィールドを区別しています。

これで、初期描画とポーリングのどちらを通っても「Dateは常にDate」という前提が揃い、formatNewsDate側は何も変更していません。

見落としやすい前提

このバグの厄介さは、SSRとJSON APIという2つの経路が、同じ画面・同じフィールド名の裏で違うシリアライズ形式を使っていたことに気づきにくい点にあります。

  • SSR(RSC)はDateを型情報つきでシリアライズできる
  • 同じ値をREST APIのJSONレスポンスとして返すと、Dateは単なる文字列になる
  • 初期表示だけを確認するテストや手動チェックでは、この差分は現れない。ポーリングが一度回って初めて表面化する

Date型のフィールドを持つデータを、初期描画とポーリングの両方で同じコンポーネントに流し込んでいる箇所があるなら、両方の経路で実際に届く値の型が同じかどうかを確認する価値があります。片方だけ試して「動いている」と判断すると、もう片方の経路でだけ再現する形になりがちです。

よくある質問

Q1getTime is not a functionはどんな時に出ますか?

Date型のはずの値が実際には文字列になっていて、そこへ.getTime()を呼んだ時に出ます。JSONはDate型を持たないため、Dateはシリアライズ時に必ずISO文字列へ変わります。受け取り側でDateへ戻す処理が無いと、文字列のままDate前提のコードに渡って初めて例外になります。

Q2SSRでは問題が起きないのに、なぜポーリングだけ壊れましたか?

React Server Componentsの初期描画は、シリアライズ形式が異なりDateをDateのまま復元します。一方、自動更新の追加データ取得はREST APIのJSONレスポンスをres.json()で受けており、こちらはDateを保持しないただの文字列として返ってきます。同じ画面・同じフィールドでも、描画経路によって型が食い違っていました。

Q3直し方はどう変えましたか?

res.json()をやめ、res.text()で受けた本文をJSON.parse(text, reviver)でパースするようにしました。reviverはISO 8601の日時文字列(T付き)だけをDateへ復元し、日付のみの文字列(YYYY-MM-DD)は型どおり文字列のまま残します。ポーリング応答の「DateはDate」という前提を、初期描画側と一致させています。

Q4TypeErrorに気づかず放置したらどうなりますか?

呼び出し元のコンポーネントがエラーを投げるため、error boundaryがその区画ごとエラー画面に切り替えます。今回は表示中の板面全体が「問題が発生しました」に倒れ、初期表示は無事なのに起動から数秒〜十数秒後に症状が出るという、原因を追いにくい形で現れました。

確認した環境

  • Next.js 16 / React 19(App Router・RSC)
  • 2026-06-20 に本番サイネージで発生・同日修正

この記事の根拠

  • TypeScriptファイル 88〜110行目コミット 4c6b075
  • TypeScriptファイル 60〜80行目コミット 4c6b075
  • TypeScriptファイル 60〜75行目コミット 255c04e
  • TypeScriptファイル 903〜912行目コミット 4c6b075

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。