Watchstop
00:00.00

Architecture

Clock, Store, and Stopwatch layering for Watchstop.

Watchstop separates time (Clock), observation (Store), and elapsed measurement (Stopwatch). Frameworks only need get + subscribe. Runtimes only need now + schedule + cancel. Neither concern belongs inside Stopwatch itself.

Layers

Clock providers          Core                      Framework adapters
─────────────────        ────                      ──────────────────
createBrowserClock  ─┐
createTimerClock    ─┼─► Clock ─► Stopwatch ─► Store<number> ─► React / Svelte / …
createMockClock     ─┘              │
detectClock         ───────────────┘
LayerResponsibilityPackage
ClockRead monotonic time; schedule/cancel ticks@watchstop/core
StoreSnapshot + subscription@watchstop/core (interface); Stopwatch implements it
StopwatchAccumulate elapsed ms while running@watchstop/core
AdaptersBridge Store into framework reactivity@watchstop/react, svelte, …

Design rules

  1. No framework imports in core. Angular, Qwik, and Alpine must consume the same Store contract as React.
  2. One timer clock for Node, Bun, and Deno. All three expose performance.now and setTimeout / clearTimeout. Do not ship @watchstop/node, @watchstop/bun, or @watchstop/deno.
  3. Default clock via detectClock(). Prefer browser (requestAnimationFrame) when present; otherwise timer clock. Each call returns a fresh clock — not a realm singleton. No hard node: imports in the browser build.
  4. Shared schedule per clock identity. Running stopwatches that share one Clock object share one underlying schedule loop. Pass one shared clock when you want coalescing; bare new Stopwatch() / detectClock() do not share.
  5. Countdown / Ticker are out of v1. Only Stopwatch ships in the first core release.
  6. Adapters are thin. No timing math, no clocks, no elapsed accumulation in adapters.

Adapter entry-point naming

Each adapter ships exactly one instance-owning entry point. The name follows the framework idiom rather than forcing one shape across ecosystems:

PackageEntry pointWhy that name
@watchstop/reactuseStopwatchReact hooks
@watchstop/vueuseStopwatchVue composables
@watchstop/soliduseStopwatchSolid hooks
@watchstop/qwikuseStopwatchQwik hooks
@watchstop/angularinjectStopwatchAngular inject / injection-context idiom
@watchstop/sveltecreateStopwatchSvelte has no hook context; factory + onDestroy
@watchstop/alpinecreateStopwatchAlpine has no hook/inject context; factory returns the binding

Factories exist where the framework has no hook or inject context to hang ownership and teardown on. The binding shape is the same everywhere: elapsed, running, start, stop, reset, and the owned stopwatch (Svelte exposes running as a separate Readable<boolean>; $stopwatch stays elapsed ms).

Data flow while running

  1. start() records startTime = clock.now(), notifies subscribers (even when elapsed is still 0) so adapters can sync running, and registers with the shared driver for that clock.
  2. Each shared tick recomputes elapsed = accumulated + (clock.now() - startTime) and notifies subscribers for every registered stopwatch that still wants ticks.
  3. stop() unregisters from the driver, adds the current segment into accumulated, clears startTime, and sets running = false.
  4. get() returns the live elapsed value (running or stopped). running is the public flag for whether a segment is active.
  5. destroy() unregisters, clears listeners, sets running = false, and makes further use a no-op or safe idle state (see Stopwatch).

Rules 3 and 4 as a picture — one shared schedule per Clock object, while each stopwatch still owns its own banked time:

Connection circle — shared Clock schedule
+wants ticks+keeps the loop alive+when bucket allows−coarser buckets+one loop per Clockone shared loopClockis this watch on?runningwatches waiting for ticksregisteredeach ticktick wavesubscriber updatesnotifieshow often to updateprecisionMsreading the valueget() — no arrow inbanked + start readingowned — no arrow in

Same Clock object → one schedule loop. Each stopwatch still owns its banked time and start reading (dashed nodes — no arrow in). get() stays live; precisionMs only cuts how often subscribers update. See Options.

Runtime matrix

EnvironmentFactorynowschedule / cancel
Browser (display)createBrowserClock()performance.now()requestAnimationFrame / cancelAnimationFrame
Node / Bun / DenocreateTimerClock({ intervalMs? })performance.now()setTimeout / clearTimeout
TestscreateMockClock()controllablecontrollable + advance(ms)
AutodetectClock()browser if requestAnimationFrame exists, else timersame

Package map (v1)

  • @watchstop/core
  • Adapters: React, Svelte, Vue, Solid, Angular, Qwik, Alpine

See Spec for public names and adopter constraints that pressure the Store shape.

On this page