From 37b83f1529a9de6f77a390f66fd74dd15d8266f1 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sat, 4 Jul 2026 17:14:27 +0000 Subject: [PATCH] fix(dice): clear the roll interval on unmount (setState-after-unmount leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dice.roll() started a setInterval that fires setState every 100ms for 1s with no cleanup. A component unmounted mid-roll kept updating a dead component — a React "update on unmounted component" leak that surfaced as a nondeterministic coverage-teardown crash in CI (exit 1 after all tests pass; caught on the #168 run, unrelated to that change). Track the interval in a ref, clear it on unmount via useEffect, and clear any in-flight roll before starting a new one. Dice tests 14/14, type-check clean. Co-Authored-By: Claude Fable 5 --- src/components/atomic/Dice/Dice.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/atomic/Dice/Dice.tsx b/src/components/atomic/Dice/Dice.tsx index 8c2a745f..d30e1b13 100644 --- a/src/components/atomic/Dice/Dice.tsx +++ b/src/components/atomic/Dice/Dice.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; export interface DiceProps { sides?: 6 | 20; @@ -10,6 +10,17 @@ export interface DiceProps { export default function Dice({ sides = 6, className = '' }: DiceProps) { const [value, setValue] = useState(null); const [isRolling, setIsRolling] = useState(false); + // Track the rolling interval so it can be cleared on unmount — otherwise a + // roll started right before unmount keeps firing setState for up to 1s on a + // dead component (a React "update on unmounted component" leak that crashed + // test teardown; see #168 CI flake). + const intervalRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, []); const roll = () => { setIsRolling(true); @@ -20,16 +31,21 @@ export default function Dice({ sides = 6, className = '' }: DiceProps) { const rollInterval = 100; let elapsed = 0; + // Clear any in-flight roll before starting a new one. + if (intervalRef.current) clearInterval(intervalRef.current); + const interval = setInterval(() => { elapsed += rollInterval; setValue(Math.floor(Math.random() * sides) + 1); if (elapsed >= rollDuration) { clearInterval(interval); + intervalRef.current = null; setIsRolling(false); setValue(Math.floor(Math.random() * sides) + 1); } }, rollInterval); + intervalRef.current = interval; }; const getDiceIcon = () => {