diff --git a/src/throttle/index.ts b/src/throttle/index.ts index 042189b8..0f571c37 100644 --- a/src/throttle/index.ts +++ b/src/throttle/index.ts @@ -20,12 +20,14 @@ export function throttle(_: { source: Unit; timeout: number | Store; name?: string; + leading?: boolean; }): EventAsReturnType; export function throttle>(_: { source: Unit; timeout: number | Store; target: Target; name?: string; + leading?: boolean; }): Target; export function throttle( ...args: @@ -35,16 +37,18 @@ export function throttle( timeout: number | Store; name?: string; target?: UnitTargetable; + leading?: boolean; }, ] | [Unit, number | Store] ): EventAsReturnType { const argsShape = args.length === 2 ? { source: args[0], timeout: args[1] } : args[0]; - const { source, timeout, target = createEvent() } = argsShape; + const { source, timeout, leading, target = createEvent() } = argsShape; if (!is.unit(source)) throw new TypeError('source must be unit from effector'); const $timeout = toStoreNumber(timeout); + const $leading = toStoreBoolean(leading ?? false); const timerFx = createEffect({ name: `throttle(${(source as Event).shortName || source.kind}) effect`, @@ -57,28 +61,44 @@ export function throttle( skipVoid: false, }).on(source, (_, payload) => payload); - const triggerTick = createEvent(); + const trailingTick = createEvent(); + const leadingTick = createEvent(); - const $canTick = createStore(true, { serialize: 'ignore' }) - .on(triggerTick, () => false) - .on(target, () => true); + const $canTrailingTick = createStore(true, { serialize: 'ignore' }) + .on(trailingTick, () => false) + .on(timerFx.done, () => true); + + const $canLeadingTick = createStore(true, { serialize: 'ignore' }) + .on(leadingTick, () => false) + .on(timerFx.done, () => true); + + sample({ + clock: source, + source: [$canTrailingTick, $leading, $canLeadingTick], + filter: ([canTrailingTick, leading, canLeadingTick]) => + canTrailingTick && ((!canLeadingTick && leading) || !leading), + fn: (_, clock) => clock, + target: trailingTick, + }); sample({ clock: source, - filter: $canTick, - target: triggerTick, + source: [$canTrailingTick, $canLeadingTick], + filter: ([canTrailingTick, canLeadingTick]) => canTrailingTick && canLeadingTick, + fn: (_, clock) => clock, + target: [target, leadingTick], }); sample({ source: $timeout, - clock: triggerTick as Unit, + clock: trailingTick as Unit, target: timerFx, }); sample({ source: $payload, clock: timerFx.done, - target, + target: target, }); return target as any; @@ -98,3 +118,15 @@ function toStoreNumber(value: number | Store | unknown): Store { `timeout parameter should be number or Store. "${typeof value}" was passed`, ); } + +function toStoreBoolean(value: boolean | Store | unknown): Store { + if (is.store(value)) return value; + + if (typeof value !== 'boolean') { + throw new TypeError( + `leading parameter should be boolean or Store. "${typeof value}" was passed`, + ); + } + + return createStore(value, { name: '$leading' }); +} diff --git a/src/throttle/readme.md b/src/throttle/readme.md index 8fa2cd41..97adefd2 100644 --- a/src/throttle/readme.md +++ b/src/throttle/readme.md @@ -247,6 +247,98 @@ const throttledStore: Event = throttle({ }); ``` +## `throttle({ source, timeout, leading })` + +### Motivation + +By default, `throttle` triggers `target` at the **end** of the timeout (trailing edge). With `leading: true`, the first call triggers `target` **immediately**, and subsequent calls within the timeout period are throttled normally. + +This is useful when you want immediate feedback on the first interaction, while still preventing excessive calls. + +### Formulae + +```ts +target = throttle({ source, timeout, leading: true }); +``` + +- First trigger of `source` immediately triggers `target` +- Subsequent triggers within `timeout` are collected, and only the last value triggers `target` after `timeout` + +### Arguments + +1. `source` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Source unit, data from this unit used by the `target` +2. `timeout` ([_`number`_] | `Store`) — time to wait before trigger `target` after last trigger +3. `leading` (`boolean`) — if `true`, first call triggers immediately. Default: `false` +4. `target` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — (optional) Target unit + +### Returns + +- `target` ([_`Event`_]) — new event, that triggered on first call immediately (if `leading: true`) and after timeout with the last value + +### Usage + +```ts +import { createEvent } from 'effector'; +import { throttle } from 'patronum'; + +const buttonClicked = createEvent(); + +const throttled = throttle({ + source: buttonClicked, + timeout: 200, + leading: true, +}); + +throttled.watch((payload) => { + console.info('clicked', payload); +}); + +buttonClicked(1); // => clicked 1 (immediately) +buttonClicked(2); // (ignored, within timeout) +buttonClicked(3); // (ignored, within timeout) +buttonClicked(4); // (saved as last value) + +// after 200ms +// => clicked 4 +``` + +### Comparison: `leading: false` vs `leading: true` + +Given `timeout: 200ms`: + +#### `leading: false` (default behavior) + +| Time | Action | State | target fires? | +|------|--------|-------|---------------| +| 0ms | `source(1)` | Timer starts, saved=1 | ❌ No | +| 50ms | `source(2)` | saved=2 | ❌ No | +| 100ms | `source(3)` | saved=3 | ❌ No | +| 200ms | Timer done | — | ✅ **target(3)** | +| 200ms | `source(4)` | Timer starts, saved=4 | ❌ No | +| 250ms | `source(5)` | saved=5 | ❌ No | +| 400ms | Timer done | — | ✅ **target(5)** | + +**Result:** target fires **2 times** with values `3` and `5` (only trailing edge). + +#### `leading: true` + +| Time | Action | State | target fires? | +|------|--------|-------|---------------| +| 0ms | `source(1)` | Timer starts, saved=1 | ✅ **target(1)** (immediate!) | +| 50ms | `source(2)` | saved=2 | ❌ No | +| 100ms | `source(3)` | saved=3 | ❌ No | +| 200ms | Timer done | — | ✅ **target(3)** | +| 200ms | `source(4)` | Timer starts, saved=4 | ✅ **target(4)** (immediate!) | +| 250ms | `source(5)` | saved=5 | ❌ No | +| 400ms | Timer done | — | ✅ **target(5)** | + +**Result:** target fires **4 times** with values `1`, `3`, `4`, `5` (leading + trailing edges). + +#### Key difference + +- **`leading: false`**: User must wait for timeout before seeing any result +- **`leading: true`**: User gets immediate feedback on first interaction, then throttled updates + [_`event`_]: https://effector.dev/docs/api/effector/event [_`effect`_]: https://effector.dev/docs/api/effector/effect [_`store`_]: https://effector.dev/docs/api/effector/store diff --git a/src/throttle/throttle.fork.test.ts b/src/throttle/throttle.fork.test.ts index 2ac32753..f4509b3d 100644 --- a/src/throttle/throttle.fork.test.ts +++ b/src/throttle/throttle.fork.test.ts @@ -157,3 +157,67 @@ describe('edge cases', () => { expect(listener).toBeCalledWith('two'); }); }); + +describe('leading option', () => { + test('leading throttle works in forked scope', async () => { + const app = createDomain(); + const source = app.createEvent(); + const $counter = app.createStore(0); + + const throttled = throttle({ source, timeout: 40, leading: true }); + + $counter.on(throttled, (value, payload) => value + payload); + + const scope = fork(); + + await allSettled(source, { scope, params: 1 }); + + expect(serialize(scope)).toMatchObject({ + [$counter.sid!]: 1, + }); + }); + + test('leading throttle do not affect another forks', async () => { + const app = createDomain(); + const $counter = app.createStore(0); + const trigger = app.createEvent(); + + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + $counter.on(throttled, (value, payload) => value + payload); + + const scopeA = fork(); + const scopeB = fork(); + + await allSettled(trigger, { scope: scopeA, params: 1 }); + await allSettled(trigger, { scope: scopeB, params: 100 }); + + expect(serialize(scopeA)).toMatchObject({ + [$counter.sid!]: 1, + }); + + expect(serialize(scopeB)).toMatchObject({ + [$counter.sid!]: 100, + }); + }); + + test('leading throttle do not affect original store value', async () => { + const app = createDomain(); + const $counter = app.createStore(0); + const trigger = app.createEvent(); + + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + $counter.on(throttled, (value, payload) => value + payload); + + const scope = fork(); + + await allSettled(trigger, { scope, params: 5 }); + + expect(serialize(scope)).toMatchObject({ + [$counter.sid!]: 5, + }); + + expect($counter.getState()).toBe(0); + }); +}); diff --git a/src/throttle/throttle.test.ts b/src/throttle/throttle.test.ts index 887da512..0c507d94 100644 --- a/src/throttle/throttle.test.ts +++ b/src/throttle/throttle.test.ts @@ -597,3 +597,208 @@ test('source store, target effect', async () => { ] `); }); + +describe('leading', () => { + test('leading throttle trigger on first call', async () => { + const watcher = jest.fn(); + + const trigger = createEvent(); + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + throttled.watch(watcher); + + trigger(); + + expect(watcher).toBeCalledTimes(1); + }); + + test('leading throttle triggers on second call after timeout', async () => { + const watcher = jest.fn(); + + const trigger = createEvent(); + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + throttled.watch(watcher); + + trigger(1); + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(1); + + trigger(2); + expect(watcher).toBeCalledTimes(1); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toBeCalledWith(2); + }); + + test('leading throttle with multiple rapid calls', async () => { + const watcher = jest.fn(); + + const trigger = createEvent(); + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + throttled.watch(watcher); + + trigger(1); + trigger(2); + trigger(3); + trigger(4); + + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(1); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toBeCalledWith(4); + }); + + test('leading throttle works correctly after first cycle', async () => { + const watcher = jest.fn(); + + const trigger = createEvent(); + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + throttled.watch(watcher); + + // First cycle + trigger(1); + trigger(2); + trigger(3); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toHaveBeenNthCalledWith(1, 1); + expect(watcher).toHaveBeenNthCalledWith(2, 3); + + // Second cycle - should behave the same way + trigger(3); + trigger(4); + trigger(5); + + await wait(42); + + expect(watcher).toBeCalledTimes(4); + expect(watcher).toHaveBeenNthCalledWith(3, 3); + expect(watcher).toHaveBeenNthCalledWith(4, 5); + }); + + test('leading throttle with target effect', async () => { + const watcher = jest.fn(); + + const source = createEvent(); + const target = createEffect().use(() => undefined); + + throttle({ source, timeout: 40, target, leading: true }); + + target.watch(watcher); + + source(1); + source(2); + source(3); + + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(1); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toBeCalledWith(3); + }); + + test('leading throttle with effect as source', async () => { + const watcher = jest.fn(); + + const trigger = createEffect().use(() => undefined); + const throttled = throttle({ source: trigger, timeout: 40, leading: true }); + + throttled.watch(watcher); + + trigger(1); + trigger(2); + trigger(3); + + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(1); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toBeCalledWith(3); + }); + + test('leading throttle with store as source', async () => { + const watcher = jest.fn(); + + const change = createEvent(); + const $store = createStore(0).on(change, (_, value) => value); + + const throttled = throttle({ source: $store, timeout: 40, leading: true }); + + throttled.watch(watcher); + + change(1); + change(2); + change(3); + + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(1); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toBeCalledWith(3); + }); + + test('leading throttle with store timeout', async () => { + const watcher = jest.fn(); + const changeTimeout = createEvent(); + const $timeout = createStore(40).on(changeTimeout, (_, value) => value); + + const trigger = createEvent(); + const throttled = throttle({ source: trigger, timeout: $timeout, leading: true }); + + throttled.watch(watcher); + + trigger(1); + trigger(2); + + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(1); + + await wait(42); + + expect(watcher).toBeCalledTimes(2); + expect(watcher).toBeCalledWith(2); + + // Change timeout + changeTimeout(80); + + trigger(3); + trigger(4); + + await wait(42); + expect(watcher).toBeCalledTimes(3); + expect(watcher).toBeCalledWith(3); + + await wait(35); + expect(watcher).toBeCalledTimes(3); + expect(watcher).toBeCalledWith(3); + + await wait(10); + expect(watcher).toBeCalledTimes(4); + expect(watcher).toBeCalledWith(4); + }); + + test('leading throttle returns target when provided', async () => { + const source = createEvent(); + const target = createEvent(); + + const result = throttle({ source, timeout: 40, target, leading: true }); + + expect(result).toBe(target); + }); +}); diff --git a/test-typings/throttle.ts b/test-typings/throttle.ts index 318254a4..8c6b2e27 100644 --- a/test-typings/throttle.ts +++ b/test-typings/throttle.ts @@ -189,3 +189,48 @@ import { throttle } from '../dist/throttle'; // @ts-expect-error throttle({ source, target, timeout: 10, name: [] }); } + +// Leading argument should be boolean +{ + const source = createEvent(); + expectType>(throttle({ source, timeout: 10, leading: true })); + expectType>(throttle({ source, timeout: 10, leading: false })); + expectType>(throttle({ source, timeout: 10, leading: undefined })); + + // @ts-expect-error + throttle({ source, timeout: 10, leading: null }); + // @ts-expect-error + throttle({ source, timeout: 10, leading: 'true' }); + // @ts-expect-error + throttle({ source, timeout: 10, leading: 1 }); + // @ts-expect-error + throttle({ source, timeout: 10, leading: Symbol() }); + // @ts-expect-error + throttle({ source, timeout: 10, leading: () => {} }); + // @ts-expect-error + throttle({ source, timeout: 10, leading: {} }); + // @ts-expect-error + throttle({ source, timeout: 10, leading: [] }); +} + +// Leading argument with target +{ + const source = createEvent(); + const target = createStore(0); + expectType>(throttle({ source, target, timeout: 10, leading: true })); + expectType>(throttle({ source, target, timeout: 10, leading: false })); + + const targetEvent = createEvent(); + expectType>(throttle({ source, target: targetEvent, timeout: 10, leading: true })); + + const targetEffect = createEffect(); + expectType>(throttle({ source, target: targetEffect, timeout: 10, leading: true })); +} + +// Leading argument with store timeout +{ + const source = createEvent(); + const $timeout = createStore(100); + expectType>(throttle({ source, timeout: $timeout, leading: true })); + expectType>(throttle({ source, timeout: $timeout, leading: false })); +}