← Blog
ReactCanvas APIDebugPerformance

203個のCanvas/DOMコンポーネントで踏んだバグ Top 10 — 実データから見えたパターン

22 分で読める

このトピックを深掘りする

Canvas vs DOM・インタラクティブUI

CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計

全5本中 2 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

はじめに

このサイトには現在211個のインタラクティブコンポーネントがある。流体シミュレーション、ピアノ、迷路生成、ピンボール、蟻のフェロモンシミュレーション——ジャンルはバラバラだが、共通して踏むバグのパターンは驚くほど似ている。

この記事では、git履歴とコミットログを実際に集計して見えてきた「踏みやすいバグ Top 10」をまとめる。各パターンについて、問題の内容、影響範囲、修正パターン、そして実際のコード例を示す。

目的はシンプルで、同じバグを2度踏まないこと。自分用のチェックリストとして書いたものだが、Canvas API/DOM系のインタラクティブコンポーネントを作る人にとっても参考になるはずだ。

集計の元になった主要コミット:

  • 08925bcb — タイマー系リーク修正
  • 04163d8e — AudioContext・mountedRef・Clipboard修正(18コンポーネント)
  • f51f0c83 — モジュールレベル可変状態の排除(15コンポーネント)
  • 1fc14711 — NaN安全ガード・Canvasサイズバリデーション(8コンポーネント)
  • 7723cfaf — passiveイベントリスナー追加(16コンポーネント)
  • 441e1bf7 — rAF二重起動・デルタタイム修正

1. setTimeout / setInterval のメモリリーク

影響範囲: 18+コンポーネント

ゲーム系コンポーネントで最も踏んだバグ。setTimeoutsetIntervalをコンポーネント内で使うが、アンマウント時にクリアし忘れる。結果、アンマウント後もコールバックが実行され続け、存在しないDOMへのアクセスやsetStateが走る。

問題のコード:

// ❌ MazeRunner.tsx — リーク版
function MazeRunner() {
  const [timeLeft, setTimeLeft] = useState(60)

  useEffect(() => {
    const id = setInterval(() => {
      setTimeLeft((t) => t - 1)
    }, 1000)
    // cleanup忘れ → アンマウント後もsetStateが走り続ける
  }, [])
}

修正パターン:

// ✅ useRefでタイマーID管理 + cleanup関数で確実にクリア
function MazeRunner() {
  const [timeLeft, setTimeLeft] = useState(60)
  const timerRef = useRef<ReturnType<typeof setInterval>>(null)

  useEffect(() => {
    timerRef.current = setInterval(() => {
      setTimeLeft((t) => t - 1)
    }, 1000)

    return () => {
      if (timerRef.current) {
        clearInterval(timerRef.current)
      }
    }
  }, [])
}

ポイントはuseRefでタイマーIDを保持すること。コールバック内から別のsetTimeoutをチェーンする場合、ローカル変数だけでは追跡しきれない。useRefに格納しておけば、どこからでも最新のIDを参照してクリアできる。


2. AudioContext の初期化失敗

影響範囲: 18コンポーネント(コミット 04163d8e

Web Audio APIを使うコンポーネントで頻発。ブラウザの自動再生ポリシーにより、ユーザーインタラクション前にAudioContextを生成するとsuspended状態になる。さらに、一部の環境(古いSafari、メモリ不足のモバイル)ではnew AudioContext()自体が例外を投げることがある。

問題のコード:

// ❌ Kalimba.tsx — ガードなし
function Kalimba() {
  const audioCtx = useRef(new AudioContext())

  function playNote(freq: number) {
    const osc = audioCtx.current.createOscillator()
    osc.frequency.value = freq
    osc.connect(audioCtx.current.destination)
    osc.start()
  }
}

修正パターン:

// ✅ try-catchでラップ + suspended状態のresume()
function Kalimba() {
  const audioCtx = useRef<AudioContext | null>(null)

  function getAudioContext(): AudioContext | null {
    if (!audioCtx.current) {
      try {
        audioCtx.current = new AudioContext()
      } catch {
        console.warn('AudioContext not available')
        return null
      }
    }
    if (audioCtx.current.state === 'suspended') {
      audioCtx.current.resume().catch(() => {})
    }
    return audioCtx.current
  }

  function playNote(freq: number) {
    const ctx = getAudioContext()
    if (!ctx) return

    const osc = ctx.createOscillator()
    osc.frequency.value = freq
    osc.connect(ctx.destination)
    osc.start()
  }
}

この修正を18コンポーネントに適用した。パターンが決まれば機械的に直せるが、問題は「最初に気づくまで」が長いこと。モバイルSafariでだけ無音になる、という報告で初めて発覚した。


3. モジュールレベルの可変状態

影響範囲: 8コンポーネント(コミット f51f0c83

これはReact 18のStrict Modeで顕在化するバグだ。モジュールスコープにlet nextId = 0のようなカウンターを置くと、Strict Modeの2回マウントでIDが衝突する。

問題のコード:

// ❌ Match3.tsx — モジュールレベルのカウンター
let nextId = 0

function createTile(color: string) {
  return { id: nextId++, color }
}

function Match3() {
  const [tiles, setTiles] = useState(() =>
    Array.from({ length: 64 }, () => createTile(randomColor()))
  )
  // Strict Modeで2回実行 → IDが0-63と64-127に分裂
  // 1回目の初期化結果は破棄されるが、nextIdは128に進んでいる
}

修正パターン:

// ✅ useRefでインスタンスレベルのカウンター
function Match3() {
  const nextId = useRef(0)

  function createTile(color: string) {
    return { id: nextId.current++, color }
  }

  const [tiles, setTiles] = useState(() =>
    Array.from({ length: 64 }, () => createTile(randomColor()))
  )
}

React公式ドキュメントにも書いてあるが、実際にやらかすまでピンとこなかった。特にゲーム系ではパフォーマンスの理由でuseRefを避けてモジュールスコープに置きがちなので注意が必要。


4. NaN / Infinity の伝播

影響範囲: 8+コンポーネント(コミット 1fc14711

数値計算が中心のコンポーネントで必ず踏む。1フレームでもNaNが発生すると、それ以降のすべてのフレームが壊れる。CanvasはNaNを渡してもエラーを投げず、ただ描画が消えるだけなのでデバッグが困難。

問題のコード:

// ❌ Mandelbrot — 発散判定で NaN
function smoothColor(zn: number, iter: number) {
  // zn が 0 以下のとき Math.log が NaN → 全ピクセルが黒に
  const smooth = iter - Math.log(Math.log(zn)) / Math.log(2)
  return smooth
}

修正パターン:

// ✅ 入力を安全な範囲にクランプ
function smoothColor(zn: number, iter: number) {
  const smooth = iter - Math.log2(Math.max(Math.log2(zn), 1e-10))
  return Number.isFinite(smooth) ? smooth : iter
}

DoublePendulum(二重振り子)では、角速度がInfinityに飛ぶケースがあった。修正は全計算結果にNumber.isFiniteガードを入れるだけだが、これを怠ると「たまにシミュレーションが止まる」という再現困難なバグになる。

// DoublePendulum — 全更新結果にガード
const newOmega1 = computeOmega1(theta1, theta2, omega1, omega2)
if (!Number.isFinite(newOmega1)) {
  // 発散した場合は前フレームの値を維持
  return prevState
}

5. Canvas サイズ 0 でのクラッシュ

影響範囲: 8コンポーネント(コミット 1fc14711

createImageData(0, 0)new OffscreenCanvas(0, 0)は例外を投げる。SSR時、コンテナのレイアウト完了前、あるいはCSSでdisplay: noneになっている場合に発生する。

問題のコード:

// ❌ ReactionDiffusion.tsx
function ReactionDiffusion() {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!
    // コンテナ未ロード → width=0, height=0
    const imageData = ctx.createImageData(canvas.width, canvas.height) // 💥
  }, [])
}

修正パターン:

// ✅ サイズのバリデーション
useEffect(() => {
  const canvas = canvasRef.current
  if (!canvas) return

  const { width, height } = canvas.getBoundingClientRect()
  const w = Math.max(1, Math.floor(width))
  const h = Math.max(1, Math.floor(height))
  canvas.width = w
  canvas.height = h

  const ctx = canvas.getContext('2d')
  if (!ctx) return

  const imageData = ctx.createImageData(w, h) // 安全
}, [])

Math.max(1, ...)で最小値を1に保証するのがポイント。0チェックだけだとreturnしてしまい、リサイズ後の再初期化ロジックが複雑になる。最小1pxを保証する方がコード全体がシンプルになる。


6. mountedRef の欠如

影響範囲: 18+コンポーネント(コミット 04163d8e

getUserMedia()fetch()AudioContext.decodeAudioData()など、非同期処理の完了後にsetStateを呼ぶコンポーネントで頻発。コンポーネントがアンマウントされた後にPromiseが解決すると、ReactがWarningを出す(React 18以降は実害は少ないが、無駄な処理が走る)。

問題のコード:

// ❌ AudioVisualizer.tsx
function AudioVisualizer() {
  const [stream, setStream] = useState<MediaStream | null>(null)

  useEffect(() => {
    navigator.mediaDevices.getUserMedia({ audio: true }).then((s) => {
      setStream(s) // アンマウント後に実行される可能性
    })
  }, [])
}

修正パターン:

// ✅ mountedRefでガード
function AudioVisualizer() {
  const [stream, setStream] = useState<MediaStream | null>(null)
  const mountedRef = useRef(true)

  useEffect(() => {
    mountedRef.current = true

    navigator.mediaDevices
      .getUserMedia({ audio: true })
      .then((s) => {
        if (mountedRef.current) {
          setStream(s)
        } else {
          // アンマウント済みならリソースを解放
          s.getTracks().forEach((t) => t.stop())
        }
      })
      .catch(() => {})

    return () => {
      mountedRef.current = false
    }
  }, [])
}

特にMediaStreamは、mountedRefチェックに加えてelse節でトラックを停止しないとマイクが使用中のままになる。この修正漏れが実際に「ブラウザのマイクインジケーターが消えない」というバグにつながった。


7. requestAnimationFrame の二重起動

影響範囲: 15+コンポーネント(コミット 441e1bf7

useEffect内でrequestAnimationFrameループを開始し、依存配列の変更やStrict Modeの再マウントでループが2つ同時に走るバグ。フレームごとに2回更新されるため、物理シミュレーションが倍速になったり、描画がちらつく。

問題のコード:

// ❌ ParticleField.tsx
useEffect(() => {
  let rafId: number

  function loop() {
    update()
    draw()
    rafId = requestAnimationFrame(loop)
  }
  loop()

  return () => cancelAnimationFrame(rafId)
  // ⚠️ Strict Modeで2回実行 → 1回目のcleanup後に2回目が開始
  // → 見た目上は問題ないが、2倍のCPU消費
}, [particles]) // particlesが変わるたびにループが増殖

修正パターン:

// ✅ useCanvasGameLoop.ts に抽出
function useCanvasGameLoop(
  callback: (dt: number) => void,
  deps: unknown[] = [],
) {
  const callbackRef = useRef(callback)
  callbackRef.current = callback

  useEffect(() => {
    let rafId: number
    let lastTime = performance.now()
    let running = true

    function loop(now: number) {
      if (!running) return
      const dt = Math.min((now - lastTime) / 1000, 0.1) // 100ms上限
      lastTime = now
      callbackRef.current(dt)
      rafId = requestAnimationFrame(loop)
    }
    rafId = requestAnimationFrame(loop)

    return () => {
      running = false
      cancelAnimationFrame(rafId)
    }
  }, deps)
}

callbackRefパターンで依存配列からコールバック関数を外すのが重要。コールバックが変わるたびにループを再起動する必要はない——参照を更新するだけでいい。また、runningフラグにより、cancelAnimationFrameが間に合わなかった場合でもループが止まる。


8. passive event listener の欠落

影響範囲: 31リスナー / 16コンポーネント(コミット 7723cfaf

touchmovewheelイベントに{ passive: true }を付けないと、ブラウザはリスナーがpreventDefault()を呼ぶ可能性を考慮してスクロールをブロックする。結果、スクロール時にジャンク(カクつき)が発生する。

問題のコード:

// ❌ GravityBadges.tsx
useEffect(() => {
  const handleWheel = (e: WheelEvent) => {
    applyForce(e.deltaY)
  }
  window.addEventListener('wheel', handleWheel)
  return () => window.removeEventListener('wheel', handleWheel)
}, [])

修正パターン:

// ✅ passive: true を明示
useEffect(() => {
  const handleWheel = (e: WheelEvent) => {
    applyForce(e.deltaY)
  }
  window.addEventListener('wheel', handleWheel, { passive: true })
  return () => window.removeEventListener('wheel', handleWheel)
}, [])

加えて、Pointer Events APIへの統一も行った。mousedown/touchstartをそれぞれ登録する代わりにpointerdown一本にし、CSS側でtouch-action: noneを指定する。イベントリスナーの数が半分以下になり、コードの見通しも良くなった。

/* Canvasにtouch-actionを指定してブラウザのデフォルト操作を抑制 */
canvas.interactive {
  touch-action: none;
}

9. Clipboard API の未ガード

影響範囲: 2-3コンポーネント(コミット 04163d8e

影響範囲は小さいが、修正しないと特定環境で完全に動かなくなる厄介なバグ。navigator.clipboard.writeText()はHTTPS環境かつtransient user activationが必要。HTTP環境、sandboxed iframe、Firefox の一部バージョンではnavigator.clipboard自体がundefinedになる。

問題のコード:

// ❌ ColorPicker.tsx
async function copyColor(hex: string) {
  await navigator.clipboard.writeText(hex)
  showToast('Copied!')
}

修正パターン:

// ✅ optional chaining + try-catch + fallback
async function copyColor(hex: string) {
  try {
    await navigator.clipboard?.writeText(hex)
    showToast('Copied!')
  } catch {
    // fallback: 非表示のinputにコピー
    const input = document.createElement('input')
    input.value = hex
    input.style.position = 'fixed'
    input.style.opacity = '0'
    document.body.appendChild(input)
    input.select()
    document.execCommand('copy')
    document.body.removeChild(input)
    showToast('Copied!')
  }
}

document.execCommand('copy')は非推奨だが、フォールバックとしてはまだ動く。完璧を求めるよりも「コピーボタンを押したのに何も起きない」状態を避ける方が重要だ。


10. デルタタイム未リセット

影響範囲: 5+コンポーネント(コミット 441e1bf7

タブをバックグラウンドにしてから復帰すると、performance.now()ベースのデルタタイム計算で数秒〜数十秒のdtが発生する。物理シミュレーションでは「5秒分を1フレームで計算」→ オブジェクトが画面外に瞬間移動する。

問題のコード:

// ❌ 復帰時にdt=5.0が入る
function loop(now: number) {
  const dt = (now - lastTime) / 1000
  lastTime = now
  physics.step(dt) // dt=5.0 → ボールが画面外に飛ぶ
  requestAnimationFrame(loop)
}

修正パターン:

// ✅ dtに上限を設定 + visibilitychangeでリセット
function loop(now: number) {
  const dt = Math.min((now - lastTime) / 1000, 1 / 30) // 最大33ms
  lastTime = now
  physics.step(dt)
  requestAnimationFrame(loop)
}

// visibilitychangeでlastTimeをリセット
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    lastTime = performance.now()
  }
})

Math.min(dt, 1/30)で上限を33msに設定する。これなら復帰時でも最大で1フレーム分の更新しか走らない。visibilitychangeでのリセットと併用すると、より確実になる。


教訓:Canvasバグパターンから学んだこと

バグの80%は「ライフサイクル管理」に集中する

Top 10のうち、#1(タイマー)、#2(AudioContext)、#3(モジュール状態)、#6(mountedRef)、#7(rAF二重起動)、#10(デルタタイム)の6つは、マウント/アンマウント/再マウントに関連するバグだ。

Reactコンポーネントは「いつでも消える可能性がある」という前提で書く必要があるが、Canvasやオーディオのような命令的APIを使うと、この前提を忘れがちになる。

Canvas固有のバグは見落としやすい

#4(NaN伝播)と#5(サイズ0)はCanvas特有の問題。DOMならNaNを設定しても見た目が変になるだけでエラーは出るが、Canvasは黙って壊れるconsole.errorthrowもない。デバッグのとっかかりがないため、発見が遅れる。

一括修正の効率

6つのコミットで延べ100+コンポーネントを修正した。パターンが確立すると、残りの修正は機械的な作業になる:

  1. パターンを1つのコンポーネントで確立
  2. grepで同じパターンのコンポーネントを洗い出し
  3. 一括で修正してコミット

この「パターン確立 → 水平展開」の流れは、211個のコンポーネントを持つプロジェクトだからこそ効率的に回った。1つのバグパターンを見つけたら、同じバグを抱えている他のコンポーネントを探す——この習慣があるだけで、バグの再発率は大幅に下がる。

チェックリスト

新しいCanvasコンポーネントを作るとき、以下を確認している:

  • useEffectのcleanupで全タイマー・rAF・イベントリスナーを解放しているか
  • AudioContextは遅延初期化 + try-catch で保護しているか
  • モジュールスコープに可変なletがないか
  • 数値計算にNumber.isFiniteガードがあるか
  • Canvas幅・高さが0でないことを保証しているか
  • 非同期処理の完了後にmountedRefチェックがあるか
  • rAFループは1つだけ走っているか
  • イベントリスナーに{ passive: true }を付けているか

211個のコンポーネントを作って得た最大の教訓は、バグは個別のロジックミスではなく、パターンとして発生するということだ。パターンを知っていれば、コードを書く段階で防げる。