# @zakkster/lite-debounce

> Zero-GC reactive debounce built on `@zakkster/lite-signal`. Trailing and
> leading-edge variants, intent-guarded writes, synchronous emit. Per source
> change in steady state: zero JS-heap allocations from this package.
> Sliding-timestamp scheme avoids per-change clearTimeout/setTimeout churn.
> 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`, so a subscriber
that writes back to the source during emit doesn't get its new pending value
wiped).

## Core API

- **`debounce(sourceFn, ms = 0)`** — trailing-edge debounce. Emits the most
  recent source value once `ms` of quiet has elapsed. Collapses bursts into
  one fire. `ms === 0` uses `queueMicrotask` instead of `setTimeout`.
- **`debounceLeading(sourceFn, ms = 0, { trailing = false })`** — leading-edge
  debounce. First non-equal change emits immediately; further changes are
  locked out for `ms`. With `trailing: true`, the most recent in-lockout value
  emits at expiry.

## Architecture invariants

- **Sliding-timestamp trailing fire**: instead of clearing and re-arming the
  setTimeout on every source change (the naive implementation), we record
  `lastWriteTime` and let the existing timer fire. When it fires, `flush`
  checks whether `ms` of quiet has actually elapsed; if not, it re-arms for
  the remaining gap. Amortized cost: 1 setTimeout per quiet window, not per
  change. This is the per-write speedup over naive and lodash.
- **Skip clock during lockout** (`debounceLeading`): once the lockout timer is
  armed, further writes don't need `performance.now()` — they just queue
  pending or drop. Hot path is two state writes and a `setTimeout` check.
- **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. Consistent with the rest of
  the lite-signal contract.
- **`api.dispose()` is the disposal contract**: the api is a callable, so
  `lite-signal.dispose(api)` would invoke it under the effect-handle contract
  (reading the value) rather than tearing down. Always call `api.dispose()`.
- **Monotonic clock**: `performance.now()` (not `Date.now()`) so wall-clock
  adjustments (NTP, manual user changes) can't cause spurious early fires in
  long-running sessions.

## Performance characteristics

- `debounce(src, ms).set` per change inside the quiet window: O(1), zero
  JS-heap alloc. One performance.now() read. setTimeout armed at most once
  per window (sliding-timestamp).
- `debounceLeading` write inside lockout: O(1), no clock read, no timer arm.
- 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, four closures.
  All from lite-signal's pools where applicable.

## When to use

- Search-as-you-type inputs (trailing).
- Save-on-edit forms where the save call is expensive.
- Click guards where a double-tap should be one action (leading, no trailing).
- Bursty config syncs that should collapse to one settle (trailing).
- Microtask coalescing inside `batch()` blocks (`ms === 0`).

## When NOT to use

- 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 want a fixed-rate emit during the burst (not after). Use
  `@zakkster/lite-throttle` instead.
- You're on the server, or you need persistence/serialization. This is a
  client-side reactive utility.

## 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 debounce<T>(
    sourceFn: () => T,
    ms?: number
): ReadonlyDerived<T>;

function debounceLeading<T>(
    sourceFn: () => T,
    ms?: number,
    options?: { trailing?: boolean }
): ReadonlyDerived<T>;
```

## Idioms

```js
// Search-as-you-type
const query = signal("");
const debounced = debounce(() => query(), 300);
effect(() => runSearch(debounced()));

// Click guard (no double-fire)
const click = signal(0);
const guarded = debounceLeading(() => click(), 500);
guarded.subscribe(() => submitOrder());

// Microtask coalescing in a batch
const x = signal(0);
const view = debounce(() => x());
view.subscribe(v => render(v));
batch(() => { for (let i = 0; i < 1000; i++) x.set(i); });
// One render(999), one microtask later.

// Leading + trailing (lodash-style)
const scrollY = signal(0);
const reactive = debounceLeading(() => scrollY(), 100, { trailing: true });
```

## File layout

- `Debounce.js`           — trailing-edge implementation, full JSDoc.
- `DebounceLeading.js`    — leading + optional trailing implementation, full JSDoc.
- `_shared.js`            — internal `makeReadonlyApi` helper, not re-exported.
- `index.js`              — barrel re-export of `debounce` and `debounceLeading`.
- `index.d.ts`            — TypeScript declarations.
- `test/debounce.test.js`         — vitest, fake timers, re-entrant regression.
- `test/debounceLeading.test.js`  — vitest, fake timers, leading-edge cases.
- `bench/benchmark.mjs`           — comparative bench vs naive vs lodash, 5-round median.

## Install

```bash
npm install @zakkster/lite-debounce @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
