0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

非アクティブなタブで setInterval が止まる!?ポモドーロタイマーを「実時間差分方式」へ切り替えた話

0
Posted at

ブラウザで動くポモドーロタイマーを自作したことがある人なら、一度はこの奇妙な現象に頭を抱えた経験があるのではないだろうか。

25分の集中タイマーをスタートさせ、別のタブを開いてコーディングや資料作成に没頭する。「そろそろ休憩の時間かな」と時計を見ると、すでに開始から40分以上が経過している。慌ててタイマーのタブに戻ってみると、画面の残り時間はまだ「08分50秒」のところで止まっているのだ。

image.png

なぜ、1秒ごとにカウントダウンするはずのコードが、別タブに移動した途端に動かなくなるのか。

原因は、現代のWebブラウザ(Chrome, Edge, Safari, Firefoxなど)に例外なく備わっている バックグラウンドタブのスロットリング(電力・リソース抑制機構) にある。

自作の新しいタブダッシュボード「ZenithTab」のポモドーロウィジェットで直面したこのタイマードリフト問題(Issue #53)と、それを克服した「実時間差分(Delta Time)方式」の設計について記録しておく。


誰もが最初に書く「動かないコード」

タイマーを作ろうとしたとき、もっとも直感的な実装は次のようなコードだ。ZenithTab の v1.10.3 までのポモドーロウィジェットも、まさにこの形だった。

// 修正前の PomodoroWidget.tsx: 1秒ごとに1減らす
useEffect(() => {
  let interval: NodeJS.Timeout | null = null;
  if (isActive && timeLeft > 0) {
    interval = setInterval(() => {
      setTimeLeft((prev) => prev - 1);
    }, 1000);
  }
  // ...
  return () => {
    if (interval) clearInterval(interval);
  };
}, [isActive, timeLeft, ...]);

「1000ミリ秒ごとにコールバックを実行し、残り秒数を 1 ずつデクリメントする」。

ローカル環境でタブを開いたままじっと眺めている限り、このタイマーは完璧に動作する。秒針は規則正しく刻まれ、25分経てば終了音が鳴る。

しかし、ユーザーが新しいタブを開いて別のWebサイトを閲覧し始めた瞬間、ブラウザの内部処理が牙を剥く。


ブラウザが仕掛けるスロットリングの罠

ChromeなどのChromium系ブラウザは、バッテリー消費を抑え、前面のタブの描画性能を維持するために、非アクティブ(バックグラウンド)になったタブのタイマーAPIを容赦なく間引く。

具体的には以下のような制限が段階的に適用される。

  1. 最小実行間隔の引き上げ: バックグラウンドタブの setInterval / setTimeout は、まず最短 1 秒に丸められ、タブが非表示のまま5分ほど経過すると「集中的スロットリング(Intensive Throttling)」に入り、1分に1回程度まで実行頻度が落とされる。
  2. 完全な一時停止(Freeze / Discard): Chromeの「メモリセーバー」やタブの休止が働くと、タイマーの実行そのものが凍結される。

つまり、「1秒ごとにコールバックが呼ばれる」という前提そのものが、ブラウザの仕様上、完全に破綻しているのだ。

1分に1回しかコールバックが呼ばれなければ、25秒カウントダウンを進めるのに25分かかる計算になる。これではポモドーロタイマーとしての体をなしていない。


解決策:「回数」ではなく「実時間(Timestamp)」を測る

この問題を根本から解決する唯一の方法は、「タイマーのコールバックが何回呼ばれたか」で時間を測るのをやめ、「現実世界の時刻がどれだけ進んだか」を直接比較する方式へ設計を切り替えることだ。

ゲーム開発の物理演算などで広く使われる「デルタタイム(実時間差分)」の考え方を導入する。

実装のコアロジック

タイマーを開始した瞬間に、現在のUNIXミリ秒(Date.now())から「終了予定時刻」を確定させて記録する。ZenithTab では state を1つ追加しただけだ。

// src/components/widgets/PomodoroWidget/PomodoroWidget.tsx
const [timeLeft, setTimeLeft] = useState(focusDurationMinutes * 60);
// 動作中は「セッションが終わる実時刻」。ここから残り秒数を導く。
const [endAt, setEndAt] = useState<number | null>(null);
const isActive = endAt !== null;

「動いているかどうか」を表す isActive は独立したフラグではなく、endAt が null かどうかから導く。状態の数を減らせば矛盾した組み合わせ(isActive なのに endAt がない)は原理的に起こらない。

タイマーが動作している間は、setInterval の呼び出し頻度がどう狂おうとも、単に「現在時刻との差分」を計算して画面を更新するだけに留める。

// 毎秒、そしてタブが戻ってきた瞬間に、時計から同期し直す
useEffect(() => {
  if (endAt === null) return;
  const sync = () => setTimeLeft(Math.max(0, Math.round((endAt - Date.now()) / 1000)));
  sync();
  const interval = setInterval(sync, 1000);
  document.addEventListener('visibilitychange', sync);
  window.addEventListener('focus', sync);
  return () => {
    clearInterval(interval);
    document.removeEventListener('visibilitychange', sync);
    window.removeEventListener('focus', sync);
  };
}, [endAt]);

// timeLeft が 0 に達したらセッション完了
useEffect(() => {
  if (!isActive || timeLeft > 0) return;
  setEndAt(null);
  if (mode === 'focus') {
    const nextSessions = sessionsCompleted + 1;
    setSessionsCompleted(nextSessions);
    // 4セッションごとに長い休憩
    if (nextSessions % 4 === 0) { setMode('longBreak'); setTimeLeft(longBreakDurationMinutes * 60); }
    else { setMode('shortBreak'); setTimeLeft(shortBreakDurationMinutes * 60); }
  } else {
    setMode('focus');
    setTimeLeft(focusDurationMinutes * 60);
  }
}, [isActive, timeLeft, mode, sessionsCompleted, ...]);

なぜこの方式なら絶対にズレないのか

このコードでは、setInterval が1秒おきに呼ばれようが、ブラウザに間引かれて1分おきに呼ばれようが、計算結果は一切狂わない。

仮にユーザーが別タブで30分間作業し、その間タイマーのコールバックが1回も呼ばれなかったとしても問題ない。ユーザーがタブに戻ってきた瞬間、あるいはスロットリングされたコールバックが実行された瞬間に Date.now() が評価される。

endAt - Date.now() の計算結果はマイナスになるため、Math.max(0, …) で timeLeft は即座に 0 になり、完了処理の useEffect がセッション終了を実行する。

タブがバックグラウンドにいた時間も含めて、現実世界の時間が正しく経過したことが完全に担保されるわけだ。


ポーズ(一時停止)と再開のステート管理

実時間差分方式を採用する際、もう1つ工夫が必要なのが「一時停止(Pause)」の扱いだ。

タイマーを一時停止している間は、現実世界の時間が進んでも残り時間を減らしてはならない。

そのため、ポーズを押した瞬間に endAt を捨て、その瞬間の「残り秒数(timeLeft)」をフリーズさせて保持する。再開時は「いま」を基準に endAt を張り直す。

const handleToggle = () => {
  if (isActive) {
    // ポーズ: いまの残り秒数を凍結し、終了予定時刻を捨てる
    setTimeLeft(Math.max(0, Math.round((endAt - Date.now()) / 1000)));
    setEndAt(null);
  } else if (timeLeft > 0) {
    // 再開: いまを基準に終了予定時刻を張り直す
    setEndAt(Date.now() + timeLeft * 1000);
  }
};

一時停止中は静止した秒数だけをメモリに留め、再開ボタンが押された瞬間に「いま」の時刻を基準にして新しい endAt を未来へ伸ばす。

このシンプルなルールを守るだけで、ポーズと再開を何回繰り返しても、時間のズレが1秒たりとも累積しない堅牢なステートマシンが完成する。


タブ復帰(visibilitychange / focus)時の即時描画更新

実時間差分方式を導入しても、インターバルが1分間隔に間引かれていると、ユーザーが別タブから戻ってきた瞬間に「最大数十秒間、古い数字が表示されたままになる」という視覚的な違和感が残る。

そこで上のコードでは、setInterval と同じ sync 関数を document の visibilitychange と window の focus の両方に登録している。タブが前面に戻った瞬間、あるいはウィンドウにフォーカスが戻った瞬間に Date.now() を評価し直し、ミリ秒単位の最新の残り時間をパッと描画する。イベントリスナーを2行足すだけで、体感は劇的に変わる。

image.png

ついでに直した「設定変更が反映されない」問題

同じ Issue にはもう1つの不満があった。設定画面で集中時間を 25 分から 50 分に変えても、停止中のタイマー表示が古い 25:00 のままになるというものだ。useState の初期値は初回レンダリング時にしか評価されないので、config が後から変わっても追従しない。

// 設定の時間が変わり、かつタイマーが止まっているなら、新しい長さを表示し直す
const durationsRef = useRef([focusDurationMinutes, shortBreakDurationMinutes, longBreakDurationMinutes]);
useEffect(() => {
  const next = [focusDurationMinutes, shortBreakDurationMinutes, longBreakDurationMinutes];
  const changed = next.some((v, i) => v !== durationsRef.current[i]);
  durationsRef.current = next;
  if (!changed || isActive) return;
  setTimeLeft(/* 現在のモードに対応する新しい秒数 */);
}, [focusDurationMinutes, shortBreakDurationMinutes, longBreakDurationMinutes, mode, isActive]);

useRef で前回の値を覚えておき、「本当に設定が変わったとき」かつ「タイマーが動いていないとき」だけ表示を更新する。動作中に設定を変えても、進行中のセッションを途中で巻き戻すことはない。


その先の選択肢:Service Worker と chrome.alarms

ここまでの実装は「タブに戻ったときに正しい時間が表示される」ことを保証する。だが「タブを開いていなくても25分ちょうどにデスクトップ通知を飛ばしたい」という要件になると、タブ内のスクリプトだけでは限界がある。タブが休止すれば setInterval も Date.now() の評価も止まるからだ。

そこで使うのが、Service Worker と chrome.alarms API の組み合わせだ。Chrome の Service Worker と chrome.alarms はブラウザ本体のスケジューラーによってバックグラウンドで起床する。

// タブ側から Service Worker へアラーム予約を投げる(将来の拡張案)
chrome.alarms.create('pomodoro-timer', { when: endAt });

Service Worker 側でアラームを受け取れば、タブが休止していようが別の作業をしていようが、OS標準のネイティブ通知(chrome.notifications)を確実にポップアップさせることができる。

ZenithTab はすでに RSS フィードの定期更新に chrome.alarms を使っている(alarms 権限は取得済み)。ただしポモドーロにはまだ適用していない。タイマーの状態はメモリ上にしかなく、タブをリロードすればリセットされる。この設計のままでも「タブを開いておけば正しい」は満たせるが、「タブを閉じても通知が来る」まで踏み込むなら、endAt をストレージに永続化し、Service Worker 側でアラームを受ける構成が次の一手になる。


信用してはいけないAPI、信用できるAPI

Webフロントエンドの開発において、setInterval や setTimeout は「指定した時間どおりに処理を実行してくれる信頼できるタイマー」ではない。

それらは単なる 「手が空いていたら、だいたいこのくらいの周期で画面を更新してね」というブラウザへの緩やかなリクエスト に過ぎない。

正確な時間の経過を扱う機能を作るなら、基準にすべきは常に Date.now()(または performance.now())の実時間スタンプだ。

「回数を数えるのをやめ、絶対時刻の差分を計算する」。

この原則を徹底するだけで、ブラウザのスロットリングやタブ休止に翻弄されない、本物のツールにふさわしい堅牢なタイマーを組み立てることができる。修正のコミットは fix(pomodoro): keep time from the wall clock so background tabs don't drift——変更は state を1つ足しただけの、驚くほど小さなものだった。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?