Skip to content

Commit 354c1f3

Browse files
committed
fix(ui): re-fire expiry notifications on each natural expiry (#84)
Root cause: useTimerExpiry tracked fired events via a firedRef Set keyed on `id:startedAt`. After expiry, a restart sets a new startedAt (T2). Because timersRef lags one render behind state, a brief window existed where the hook ran with the new startedAt but remaining still computed as 0 (stale snapshot). This added id:T2 to firedRef prematurely, so the second natural expiry found the key already present and silently skipped. Fix — two changes to useTimerExpiry: 1. Compute remaining directly from t.startedAt and t.remaining on the timer object from the `timers` prop, bypassing timersRef entirely. This is always up-to-date because TimerPanel/TimerPanel re-renders with fresh `timers` state before useTimerExpiry sees it. 2. Evict the runKey from firedRef when a timer is actively running with rem > 0. This ensures that if the key was somehow pre-added (e.g. a stale render before the eviction fix was in place), the next active run clears it so the subsequent natural expiry can fire. Adds a regression test that verifies: expire → restart (running, no fire) → expire again → fires a second time. Closes #84
1 parent ca5659c commit 354c1f3

2 files changed

Lines changed: 61 additions & 2 deletions

File tree

src/hooks/useTimer.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ export interface ExpiryEvent {
209209
* - Plays a beep if audioNotify includes 'host'
210210
* - Emits TIMER_EXPIRED so players can react
211211
* - Calls onExpire for host-side visual overlay
212+
*
213+
* Remaining is computed directly from the timer object (not via the
214+
* `remaining()` callback) to avoid reading a stale timersRef that lags
215+
* one render behind state. This prevents a false fire immediately after
216+
* restart, when timersRef still holds the pre-restart (expired) snapshot.
217+
*
218+
* The firedRef key is cleared whenever a timer is actively running with
219+
* time remaining, so a second natural expiry on the same timer correctly
220+
* re-fires after the previous run's key has been evicted.
212221
*/
213222
export function useTimerExpiry(
214223
timers: Timer[],
@@ -219,9 +228,23 @@ export function useTimerExpiry(
219228

220229
useEffect(() => {
221230
for (const t of timers) {
222-
if (t.paused || t.startedAt === null) continue
223-
if (remaining(t.id) > 0) continue
224231
const runKey = `${t.id}:${t.startedAt}`
232+
233+
if (t.paused || t.startedAt === null) continue
234+
235+
// Compute remaining directly from the timer record — avoids the
236+
// one-render lag that timersRef (used inside remaining()) has after
237+
// a restart, which would otherwise cause a spurious zero-crossing.
238+
const elapsed = (Date.now() - t.startedAt) / 1000
239+
const rem = Math.max(0, t.remaining - elapsed)
240+
241+
if (rem > 0) {
242+
// Timer is actively running — evict any stale fired key for this
243+
// run so a future natural expiry can re-fire correctly.
244+
firedRef.current.delete(runKey)
245+
continue
246+
}
247+
225248
if (firedRef.current.has(runKey)) continue
226249
firedRef.current.add(runKey)
227250

src/test/timer/timerExpiry.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,42 @@ describe('useTimerExpiry', () => {
152152
expect(onExpire).toHaveBeenCalledTimes(2)
153153
})
154154

155+
it('fires on second natural expiry after restart (bug #84 regression)', () => {
156+
// Regression: after expiry, firedRef held id:startedAt. On restart,
157+
// a brief render could run the hook while timersRef was stale (remaining≈0,
158+
// new startedAt), adding the new runKey to firedRef before the timer
159+
// actually ran. The second natural expiry then found the key and silently
160+
// skipped. Fix: compute remaining directly from t.startedAt/t.remaining
161+
// and evict the runKey when the timer is actively running (rem > 0).
162+
const onExpire = vi.fn()
163+
164+
// Run 1: timer started at T1, expires
165+
const T1 = Date.now() - 65_000 // 65s ago → expired
166+
const timer1 = makeTimer({ paused: false, startedAt: T1, remaining: 60 })
167+
const { rerender } = renderHook(
168+
({ timer }: { timer: ReturnType<typeof makeTimer> }) => {
169+
useTimerExpiry([timer], vi.fn(), onExpire)
170+
},
171+
{ initialProps: { timer: timer1 } }
172+
)
173+
expect(onExpire).toHaveBeenCalledTimes(1)
174+
175+
// Simulate restart: timer is now running with a fresh startedAt and full remaining
176+
// (this is what restartTimer produces — remaining = duration, startedAt = now)
177+
const T2 = Date.now() // just started
178+
const timerRunning = makeTimer({ paused: false, startedAt: T2, remaining: 60 })
179+
rerender({ timer: timerRunning })
180+
// Should NOT fire — timer is actively running (rem ≈ 60 > 0)
181+
expect(onExpire).toHaveBeenCalledTimes(1)
182+
183+
// Now the timer expires naturally on its second run
184+
const T2_expired = T2 - 65_000 // pretend it started 65s ago
185+
const timer2 = makeTimer({ paused: false, startedAt: T2_expired, remaining: 60 })
186+
rerender({ timer: timer2 })
187+
// Must fire — this is the bug: previously the key id:T2 was already in firedRef
188+
expect(onExpire).toHaveBeenCalledTimes(2)
189+
})
190+
155191
it('calls playBeep when audioNotify includes host', () => {
156192
const beepSpy = vi.spyOn({ playBeep }, 'playBeep').mockImplementation(() => {})
157193
// We can't easily spy on the module export in the same file,

0 commit comments

Comments
 (0)