203個のCanvas/DOMコンポーネントで踏んだバグ Top 10 — 実データから見えたパターン
このトピックを深掘りする
Canvas vs DOM・インタラクティブUI
CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計
全5本中 2 本目。
次に読む記事
CSSだけで作るインタラクティブUI — transform, transition, custom propertiesの実践
FidgetSpinner、InfiniteKnob、ToggleSwitchなどのDOM+CSSベースのインタラクティブUIで活用したtransform、transition、custom propertiesのCSS技法を実例で解説する。
自前物理エンジン180行の限界点 — Matter.jsを捨てた理由と、カバーできなかったこと
physics.tsの166行で2D物理エンジンを自作し、Matter.jsの300KBを4KBに削減した。重力・衝突・バウンドに絞り、拘束・摩擦・スタッキングを諦めたトレードオフを解説する。
ブラウザで動く物理エンジンを自前で書いた話
ポートフォリオサイトにMatter.jsを使わず約180行の軽量物理エンジンをTypeScriptで自作した経緯と設計判断。バンドルサイズ300KBを4KBに削減しつつ60fpsを維持した手法を解説。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
はじめに
このサイトには現在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+コンポーネント
ゲーム系コンポーネントで最も踏んだバグ。setTimeoutやsetIntervalをコンポーネント内で使うが、アンマウント時にクリアし忘れる。結果、アンマウント後もコールバックが実行され続け、存在しない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)
touchmoveやwheelイベントに{ 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.errorもthrowもない。デバッグのとっかかりがないため、発見が遅れる。
一括修正の効率
6つのコミットで延べ100+コンポーネントを修正した。パターンが確立すると、残りの修正は機械的な作業になる:
- パターンを1つのコンポーネントで確立
grepで同じパターンのコンポーネントを洗い出し- 一括で修正してコミット
この「パターン確立 → 水平展開」の流れは、211個のコンポーネントを持つプロジェクトだからこそ効率的に回った。1つのバグパターンを見つけたら、同じバグを抱えている他のコンポーネントを探す——この習慣があるだけで、バグの再発率は大幅に下がる。
チェックリスト
新しいCanvasコンポーネントを作るとき、以下を確認している:
-
useEffectのcleanupで全タイマー・rAF・イベントリスナーを解放しているか - AudioContextは遅延初期化 + try-catch で保護しているか
- モジュールスコープに可変な
letがないか - 数値計算に
Number.isFiniteガードがあるか - Canvas幅・高さが0でないことを保証しているか
- 非同期処理の完了後に
mountedRefチェックがあるか - rAFループは1つだけ走っているか
- イベントリスナーに
{ passive: true }を付けているか
211個のコンポーネントを作って得た最大の教訓は、バグは個別のロジックミスではなく、パターンとして発生するということだ。パターンを知っていれば、コードを書く段階で防げる。