Core
Stopwatch
Elapsed-time Store built on an injectable Clock.
Stopwatch measures elapsed milliseconds. It implements Store<number> and uses an injected Clock for time and ticks.
Public API
class Stopwatch implements Store<number> {
constructor(clock?: Clock)
start(): void
stop(): void
reset(): void
get(): number
subscribe(listener: (elapsed: number) => void): () => void
destroy(): void
}Export Stopwatch from @watchstop/core.
Construction
import { Stopwatch, detectClock, createMockClock } from '@watchstop/core'
const live = new Stopwatch()
const explicit = new Stopwatch(detectClock())
const underTest = new Stopwatch(createMockClock())clockis optional. When omitted, usedetectClock().- Initial elapsed is
0. - Initial state is stopped (not running).
Internal model (normative)
Implementers should keep equivalent state:
| Field | Meaning |
|---|---|
clock | Injected Clock |
accumulated | Elapsed ms from completed run segments |
startTime | clock.now() at last start, or unset when stopped |
running | Whether a segment is active |
handle | Pending schedule handle, or unset |
listeners | Set of subscribed callbacks |
destroyed | Whether destroy() has been called |
While running, visible elapsed is:
accumulated + (clock.now() - startTime)While stopped, visible elapsed is accumulated.
Methods
start(): void
- If
destroyed, no-op. - If already running, no-op (do not reset
startTime). - Otherwise set
running = true,startTime = clock.now(), schedule the tick loop, and notify if the visible value should refresh (usually still same number at t0; notifying is allowed but not required when value is unchanged).
stop(): void
- If
destroyedor not running, no-op. - Cancel pending
handle. - Set
accumulated = accumulated + (clock.now() - startTime). - Clear
startTime, setrunning = false. - Notify listeners with the stopped elapsed value.
reset(): void
- If
destroyed, no-op. - Cancel pending
handleif any. - Set
accumulated = 0, clearstartTime, setrunning = false. - Notify listeners with
0when the previous visible value was not already0.
get(): number
- If destroyed, return the last frozen elapsed (typically
accumulatedafter destroy’s stop), or0if never started — pick one and keep it stable: returnaccumulatedafter destroy completes its internal stop. - If running, return live
accumulated + (clock.now() - startTime)as of the call (not only last tick). Required for vanilla / Node. - If stopped, return
accumulated. - Units: milliseconds as a finite number (
>= 0under normal clocks). - There is no separate
peek()API. React adapters must not use livegetasuseSyncExternalStore’sgetSnapshot— see Store and React.
subscribe(listener: (elapsed: number) => void): () => void
- See Store, including listener reentrancy rules.
- Subscribers fire only on clock ticks and on mutating controls as specified (
start/stop/reset/destroy). - If
destroyed,subscribereturns a no-op unsubscribe and does not retain the listener.
destroy(): void
- Idempotent.
- Stop the tick loop (same as
stop()if running). - Clear all listeners without requiring them to unsubscribe first.
- Further
start/stop/resetare no-ops. - Further
subscribedoes not attach. get()remains safe and returns the frozen elapsed.
Tick loop
While running:
handle = clock.schedule(() => onTick())onTick: if not running or destroyed, return; notify listeners withget(); if still running and not destroyed, schedule again. A listener may callstop/reset/destroyduring notify — do not re-schedule in that case.
Browser clocks fire once per frame; timer clocks fire once per intervalMs. Stopwatch does not care which — it re-schedules after each callback only while still running and not destroyed.
Edge cases
| Case | Behavior |
|---|---|
Double start | Second call no-op |
Double stop | Second call no-op |
reset while running | Cancels loop, elapsed → 0, stopped |
start after reset | Fresh segment from 0 |
destroy while running | Stops and freezes elapsed; clears listeners |
| Listener throws | Other listeners still run |
| Listener reentrancy | Added listeners miss current wave; removed skip rest of wave; controls allowed; destroy clears so remaining cleared listeners are skipped |
cancel after natural fire | Idempotent; no throw |
Usage
import { Stopwatch } from '@watchstop/core'
const sw = new Stopwatch()
const unsubscribe = sw.subscribe((elapsed) => {
console.log(elapsed)
})
sw.start()
sw.stop()
console.log(sw.get())
sw.reset()
unsubscribe()
sw.destroy()Out of scope (v1)
- Pause vs stop distinctions beyond
stop - Lap times
- Countdown / Ticker
- Persistence / workers / multiplayer