# @zakkster/lite-throttle

> Zero-GC reactive throttle built on `@zakkster/lite-signal`. Timer-based and
> requestAnimationFrame-aligned variants, intent-guarded writes, synchronous
> leading emit. Per source change in steady state: zero JS-heap allocations
> from this package. Clock is read only at potential leading edges, not on
> every write. ESM-only, MIT licensed, ~1 KB min+gz.

Two exports, both build on lite-signal's `signal` / `effect` / `untrack`. Both
return a read-only callable api (`() => T` with `.peek`, `.subscribe`,
`.dispose`). Trailing fire respects re-entrant writes from subscribers (the
flush snapshots and clears `pendingValue` before `out.set`).

## Core API

- **`throttle(sourceFn, ms)`** — leading + trailing throttle. First non-equal
  change emits immediately; subsequent in-window changes coalesce into one
  trailing fire at lockout expiry. `ms` required.
- **`throttleRAF(sourceFn)`** — leading + trailing throttle aligned to the
  host's animation frame. Same shape; the lockout window is one frame.

## Architecture invariants

- **Skip clock during lockout**: once the lockout timer (or rAF) is armed,
  further writes don't read the clock — they just queue `pendingValue`. The
  realistic hot path during a burst. `performance.now()` is only called at
  potential leading edges (when `timerId === null` / `rafId === 0`).
- **Snapshot-before-set in flush**: `pendingValue` is snapshotted into a local
  and cleared BEFORE `out.set`, because `out.set` is synchronous and a
  subscriber that writes back to the source re-enters the effect during emit.
  Clearing after would wipe the re-entrant write.
- **Intent guard**: `Object.is(next, hasPending ? pendingValue : out.peek())`
  short-circuits no-op writes. `NaN` is its own match.
- **Equality-passthrough on output**: the internal signal uses lite-signal's
  default `Object.is` equality. A trailing/leading fire whose value matches
  the current output does not notify subscribers.
- **`api.dispose()` is the disposal contract**: the api is a callable, so
  `lite-signal.dispose(api)` would invoke it under the effect-handle contract
  rather than tearing down. Always call `api.dispose()`.
- **throttleRAF double-emit on re-entrant write**: if a subscriber writes
  back to the source during a trailing fire, `rafId === 0` again and the
  re-queued effect run takes the leading-edge branch — emitting the new value
  and arming a new rAF. The consumer can see two emissions in one tick.
  Correct; worth knowing for feedback loops.

## Performance characteristics

- `throttle(src, ms).set` per change inside lockout: O(1), zero JS-heap alloc,
  zero clock reads. Pure state mutation.
- `throttle` per change with `timerId === null` (rare, post-lockout): one
  performance.now() read, optional setTimeout arm.
- `throttleRAF.set` per change inside frame: O(1), zero alloc, zero clock
  reads, zero rAF churn. The tightest hot path in either package.
- Per trailing fire: one out.set propagation through lite-signal (O(downstream
  observers), zero alloc per lite-signal's pool contract).
- Construction: one signal node, one effect node, links, three closures.

## When to use

- Pointer / scroll / resize event sources at the host's frame rate
  (`throttleRAF`).
- Capping any signal update at a fixed-ms cadence regardless of frame timing
  (`throttle`).
- Driving canvas/WebGL render from a chaotic input signal (`throttleRAF`).
- Background-tab safety: use `throttle(src, 16)` over `throttleRAF` when you
  need updates to continue when the tab is backgrounded.

## When NOT to use

- You want to fire only after the burst ends, not during. Use
  `@zakkster/lite-debounce` instead.
- You want emit-on-every-fire regardless of value equality. The output is
  Object.is-deduped; project to a tuple or attach a write-token.
- You're rendering and need vsync alignment but can't tolerate
  background-tab pause. Use `throttle(src, 16)`.

## API summary

```ts
interface ReadonlyDerived<T> {
    (): T;                                          // tracked read
    peek(): T;                                      // untracked read
    subscribe(fn: (value: T) => void): () => void;  // returns unsubscribe
    dispose(): void;                                // cancel + release
}

function throttle<T>(sourceFn: () => T, ms: number): ReadonlyDerived<T>;
function throttleRAF<T>(sourceFn: () => T): ReadonlyDerived<T>;
```

## Idioms

```js
// Cursor render at vsync
const pointer = signal({ x: 0, y: 0 });
const framed = throttleRAF(() => pointer());
effect(() => drawCursor(framed()));

// Resize cap at ~60Hz
const viewport = signal({ w: 0, h: 0 });
const r = throttle(() => viewport(), 16);
r.subscribe(({ w, h }) => relayout(w, h));

// Scroll-driven progress bar
const scrollY = signal(0);
const perFrame = throttleRAF(() => scrollY());
effect(() => hud.setProgress(perFrame() / pageHeight));
```

## File layout

- `Throttle.js`             — timer-based, full JSDoc.
- `ThrottleRAF.js`          — rAF-aligned, full JSDoc.
- `_shared.js`              — internal `makeReadonlyApi` helper, not re-exported.
- `index.js`                — barrel re-export of `throttle` and `throttleRAF`.
- `index.d.ts`              — TypeScript declarations.
- `test/throttle.test.js`     — vitest with fake timers, re-entrant regression.
- `test/throttleRAF.test.js`  — vitest with manual fake-rAF harness.
- `bench/benchmark.mjs`     — comparative bench vs naive vs lodash, 5-round median.

## Install

```bash
npm install @zakkster/lite-throttle @zakkster/lite-signal
```

`@zakkster/lite-signal` is a peer dependency; the consumer's lite-signal
instance is reused (no duplicate reactive registries).

## License

MIT © Zahary Shinikchiev
