# @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`, plus `.cancel` / `.flush` added in 1.1). 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, { maxWait = 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`. `maxWait`
  (1.1) forces an emit at least every `maxWait` ms during a sustained burst
  (0 = off; only applies when `ms > 0`; an invalid value throws RangeError).
- **`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.
- **`.cancel()` / `.flush()`** (1.1, both apis) -- `cancel()` drops the pending
  emission (output unchanged, instance stays usable); `flush()` emits the pending
  value synchronously now and returns the current output (no-op if nothing pending).

## 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
    cancel(): void;                                 // 1.1: drop pending emission
    flush(): T;                                     // 1.1: emit pending value now
}

function debounce<T>(
    sourceFn: () => T,
    ms?: number,
    options?: { maxWait?: 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/01-core.test.js`                     -- trailing + maxWait + cancel/flush + debounceLeading basics (12 tests).
- `test/02-leading-corner-cases.test.js`     -- intent guard, NaN dedupe, dispose mid-lockout, re-entrant write, ms=0 pass-through (10 tests).
- `test/03-trailing-corner-cases.test.js`    -- intent guard, output equality passthrough, dispose mid-burst, subscribe value-now-and-on-change (10 tests).
- `test/04-degenerate-and-api.test.js`       -- default args, empty options, api shape, dispose stops emissions (8 tests).
- `test/zero-gc.test.js`                     -- hot-path retention < 10 B/op for both engines + structural twin coalesce tests (5 tests, --expose-gc).
- `bench/benchmark.mjs`                      -- comparative bench vs naive vs lodash, 5-round median.
- `demo/index.html`                          -- three-channel oscilloscope: RAW / debounce(trailing) / debounceLeading. Zero-GC hot paths.

## 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 (c) Zahary Shinikchiev
