From 20f4a26e869e7def89b7295a5f4e914b186f201e Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 15 Dec 2023 17:27:46 +0600 Subject: [PATCH] Patronum v2.1.0 (#314) * Update homepage in package.json and npm website * Add previous method (#313) * Add previousValue method * Add support for undefined * Add store validation * Add type tests * Add `previousValue` to documentation root * Rename `previousValue` to `previous` * Add shorthands for common methods (#315) * Add `debounce(source, timeout)` shorthand * Add `delay(source, timeout)` * Add `throttle(source, timeout)` shorthand * add `status(fx)` shorthand * Add `pending(effects)` shorthand * Add `inFlight(effects)` shorthand * Add `combineEvents(events)` shorthand * Add `spread(targets)` shorthand * Add `time(clock)` shorthand * Update sids in snapshots --- integration/cra/src/demo.test.ts | 2 +- integration/custom/test/integration.spec.ts | 4 +- scripts/source.package.js | 2 +- src/babel-plugin-factories.json | 4 +- src/combine-events/combine-events.test.ts | 219 ++++++++++++++++++++ src/combine-events/index.ts | 35 ++-- src/combine-events/readme.md | 112 ++++++---- src/debounce/debounce.test.ts | 79 ++++++- src/debounce/index.ts | 45 ++-- src/debounce/readme.md | 94 +++++++-- src/delay/delay.test.ts | 110 +++++++++- src/delay/index.ts | 29 ++- src/delay/readme.md | 161 +++++++++++++- src/in-flight/in-flight.test.ts | 90 +++++++- src/in-flight/index.ts | 18 +- src/in-flight/readme.md | 58 +++++- src/index.md | 1 + src/pending/index.ts | 21 +- src/pending/pending.test.ts | 55 ++++- src/pending/readme.md | 81 ++++++-- src/previous/index.ts | 34 +++ src/previous/previous.fork.test.ts | 87 ++++++++ src/previous/previous.test.ts | 73 +++++++ src/previous/readme.md | 65 ++++++ src/spread/index.ts | 57 ++++- src/spread/readme.md | 175 +++++++++------- src/spread/spread.test.ts | 72 +++++-- src/status/index.ts | 27 ++- src/status/readme.md | 10 +- src/status/status.test.ts | 36 +++- src/throttle/index.ts | 29 ++- src/throttle/readme.md | 111 ++++++++-- src/throttle/throttle.test.ts | 19 ++ src/time/index.ts | 22 +- src/time/readme.md | 65 +++++- src/time/time.fork.test.ts | 4 +- src/time/time.test.ts | 17 ++ test-typings/combine-events.ts | 111 +++++++--- test-typings/debounce.ts | 38 ++++ test-typings/delay.ts | 55 +++++ test-typings/in-flight.ts | 3 + test-typings/pending.ts | 3 + test-typings/previous.ts | 34 +++ test-typings/spread.ts | 99 +++++++++ test-typings/status.ts | 9 + test-typings/throttle.ts | 40 ++++ test-typings/time.ts | 10 +- 47 files changed, 2170 insertions(+), 355 deletions(-) create mode 100644 src/previous/index.ts create mode 100644 src/previous/previous.fork.test.ts create mode 100644 src/previous/previous.test.ts create mode 100644 src/previous/readme.md create mode 100644 test-typings/previous.ts diff --git a/integration/cra/src/demo.test.ts b/integration/cra/src/demo.test.ts index 3b79bdfa..82594d2e 100644 --- a/integration/cra/src/demo.test.ts +++ b/integration/cra/src/demo.test.ts @@ -3,5 +3,5 @@ import { $pending } from './demo'; test('should have sid', () => { expect($pending.sid).toBeDefined(); expect($pending.sid).not.toBeNull(); - expect($pending.sid).toMatchInlineSnapshot(`"-y29r2v|a37bj0"`); + expect($pending.sid).toMatchInlineSnapshot(`"-y29r2v|a4upb3"`); }); diff --git a/integration/custom/test/integration.spec.ts b/integration/custom/test/integration.spec.ts index 72509803..642e32df 100644 --- a/integration/custom/test/integration.spec.ts +++ b/integration/custom/test/integration.spec.ts @@ -29,7 +29,7 @@ test('status has sid', () => { expect($status.sid).toBeDefined(); expect($status.sid).not.toBeNull(); - expect($status.sid).toMatchInlineSnapshot(`"-o5m1b3|abrgim"`); + expect($status.sid).toMatchInlineSnapshot(`"-o5m1b3|ph7d4u"`); }); test('pending macro works as expected', () => { @@ -38,7 +38,7 @@ test('pending macro works as expected', () => { expect($pending.sid).toBeDefined(); expect($pending.sid).not.toBeNull(); - expect($pending.sid).toMatchInlineSnapshot(`"-hszfx7|a37bj0"`); + expect($pending.sid).toMatchInlineSnapshot(`"-hszfx7|a4upb3"`); }); function waitFor(unit: Event) { diff --git a/scripts/source.package.js b/scripts/source.package.js index e1a82c34..6dcdc285 100644 --- a/scripts/source.package.js +++ b/scripts/source.package.js @@ -18,7 +18,7 @@ module.exports = () => ({ bugs: { url: 'https://github.com/effector/patronum/issues', }, - homepage: 'https://github.com/effector/patronum#readme', + homepage: 'https://patronum.effector.dev', peerDependencies: { effector: '^23', }, diff --git a/src/babel-plugin-factories.json b/src/babel-plugin-factories.json index 8fc1e510..5588f10f 100644 --- a/src/babel-plugin-factories.json +++ b/src/babel-plugin-factories.json @@ -17,6 +17,7 @@ "patronum/once", "patronum/or", "patronum/pending", + "patronum/previous", "patronum/reset", "patronum/reshape", "patronum/snapshot", @@ -44,6 +45,7 @@ "once": "once", "or": "or", "pending": "pending", + "previous": "previous", "reset": "reset", "reshape": "reshape", "snapshot": "snapshot", @@ -54,4 +56,4 @@ "throttle": "throttle", "time": "time" } -} \ No newline at end of file +} diff --git a/src/combine-events/combine-events.test.ts b/src/combine-events/combine-events.test.ts index b807b76a..5c380060 100644 --- a/src/combine-events/combine-events.test.ts +++ b/src/combine-events/combine-events.test.ts @@ -150,6 +150,152 @@ test('source: shape', () => { `); }); +test('source: shape (shorthand)', () => { + const fn = jest.fn(); + + const event1 = createEvent(); + const event2 = createEvent(); + const event3 = createEvent(); + const event4 = createEvent(); + const event5 = createEvent(); + + type Target = Event<{ + event1: string | void; + event2: string | void; + event3: string | void; + event4: string | void; + event5: string | void; + }>; + + const event: Target = combineEvents({ + event1, + event2, + event3, + event4, + event5, + }); + + event.watch(fn); + + event1(); + event1(); + event2('-'); + event3('c'); + event2('b'); + event2(); + event4(); + event4('d'); + event5('e'); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + { + "event1": undefined, + "event2": undefined, + "event3": "c", + "event4": "d", + "event5": "e", + }, + ] + `); + + event1('a'); + event2('-'); + event3(); + event2('b'); + event3(); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + { + "event1": undefined, + "event2": undefined, + "event3": "c", + "event4": "d", + "event5": "e", + }, + ] + `); + event4('-'); + event4(); + event5('e'); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + { + "event1": undefined, + "event2": undefined, + "event3": "c", + "event4": "d", + "event5": "e", + }, + { + "event1": "a", + "event2": "b", + "event3": undefined, + "event4": undefined, + "event5": "e", + }, + ] + `); + + event1('1'); + event2('-'); + event3('-'); + event2('2'); + event3('3'); + event4('-'); + event4('4'); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + { + "event1": undefined, + "event2": undefined, + "event3": "c", + "event4": "d", + "event5": "e", + }, + { + "event1": "a", + "event2": "b", + "event3": undefined, + "event4": undefined, + "event5": "e", + }, + ] + `); + + event5('5'); + event5('-'); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + { + "event1": undefined, + "event2": undefined, + "event3": "c", + "event4": "d", + "event5": "e", + }, + { + "event1": "a", + "event2": "b", + "event3": undefined, + "event4": undefined, + "event5": "e", + }, + { + "event1": "1", + "event2": "2", + "event3": "3", + "event4": "4", + "event5": "5", + }, + ] + `); +}); + test('source: array', () => { const fn = jest.fn(); @@ -225,6 +371,79 @@ test('source: array', () => { `); }); +test('source: array (shorthand)', () => { + const fn = jest.fn(); + + const event1 = createEvent(); + const event2 = createEvent(); + const event3 = createEvent(); + const event4 = createEvent(); + const event5 = createEvent(); + + type Target = Event< + [string | void, string | void, string | void, string | void, string | void] + >; + + const event: Target = combineEvents([event1, event2, event3, event4, event5]); + + event.watch(fn); + + event1(); + event1(); + event2('-'); + event3('c'); + event2('b'); + event2(); + event4(); + event4('d'); + event5('e'); + + event1('a'); + event2('-'); + event3(); + event2('b'); + event3(); + event4('-'); + event4(); + event5('e'); + + event1('1'); + event2('-'); + event3('-'); + event2('2'); + event3('3'); + event4('-'); + event4('4'); + event5('5'); + event5('-'); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + [ + undefined, + undefined, + "c", + "d", + "e", + ], + [ + "a", + "b", + undefined, + undefined, + "e", + ], + [ + "1", + "2", + "3", + "4", + "5", + ], + ] + `); +}); + test('example from readme', () => { const fn = jest.fn(); diff --git a/src/combine-events/index.ts b/src/combine-events/index.ts index 95d2e9eb..273d5d40 100644 --- a/src/combine-events/index.ts +++ b/src/combine-events/index.ts @@ -5,7 +5,6 @@ import { Event, EventAsReturnType, is, - merge, sample, Store, Unit, @@ -44,15 +43,21 @@ export function combineEvents< T extends UnitTargetable

>, >(config: { events: Events

; target: T; reset?: Unit }): ReturnTarget; -export function combineEvents

({ - events, - reset, - target = createEvent(), -}: { - events: Events; - reset?: Unit; - target?: UnitTargetable | Unit; -}) { +export function combineEvents

( + events: Events

, +): EventAsReturnType

; + +export function combineEvents

( + args: + | { + events: Events; + reset?: Unit; + target?: UnitTargetable | Unit; + } + | Events, +) { + const argsShape = isEventsShape(args) ? { events: args } : args; + const { events, reset, target = createEvent() } = argsShape; if (!(is.unit(target) && is.targetable(target))) throwError('target should be a targetable unit'); if (reset && !is.unit(reset)) throwError('reset should be a unit'); @@ -64,11 +69,11 @@ export function combineEvents

({ const $counter = createStore(keys.length, { serialize: 'ignore' }); const $results = createStore(defaultShape, { serialize: 'ignore' }); - $counter.reset(sample({ source: target })); + sample({ source: target, target: $counter.reinit }); $results.reset(target); if (reset) { - $counter.reset(sample({ source: reset })); + sample({ source: reset, target: $counter.reinit }); $results.reset(reset); } @@ -104,6 +109,12 @@ export function combineEvents

({ return target; } +function isEventsShape

(args: any): args is Events { + return Object.keys(args).some( + (key) => !['events', 'reset', 'target'].includes(key) && is.unit(args[key]), + ); +} + function throwError(message: string) { throw new Error(message); } diff --git a/src/combine-events/readme.md b/src/combine-events/readme.md index db9751c7..706a3883 100644 --- a/src/combine-events/readme.md +++ b/src/combine-events/readme.md @@ -6,64 +6,83 @@ import { combineEvents } from 'patronum'; import { combineEvents } from 'patronum/combine-events'; ``` -## `combineEvents({ events: Record>, reset?: Unit })` +## `combineEvents(events)` + +:::note since +patronum 2.1.0 +Use `combineEvents({ events })` with patronum < 2.1.0 +::: + +### Motivation + +Method allows to trigger event when all of given events are triggered, with payloads ov given events + +:::note +Consider using stores with combine in case of lazy-loaded modules, as they could miss some updates happened before module loaded +::: ### Formulae ```ts -const target = combineEvents({ - events: { - key1: event1, - key2: event2, - }, -}); +const target = combineEvents({ key1: event1, key2: event2 }); ``` -- When all events (`event1` and `event2` from example) is triggered, trigger `target` with data from events mapped to `key1` and `key2` +- When all events are triggered, trigger `target` with `{key1: firstPayload, key2: secondPayload}` + +```ts +const target = combineEvents([event1, event2]); +``` + +- When all events are triggered, trigger `target` with `[firstPayload, secondPayload]` ### Arguments -1. `events` — Object of events +1. `events` — Object or array with events ### Returns - `target` — Event with the same shape as `events`, that triggered after all `events` triggered -### Example +## `combineEvents({ events, reset, target })` -```ts -const first = createEvent(); -const second = createEvent(); -const third = createEvent(); +### Motivation +Object form which allow to pass `reset` unit or `target` + +### Formulae + +```ts const target = combineEvents({ events: { - a: first, - second, - another: third, + key1: event1, + key2: event2, }, + reset: resetUnit, + target: targetUnit, }); +``` -target.watch((object) => { - console.log('first event data', object.a); - console.log('second event data', object.second); - console.log('third event data', object.another); -}); +- When all events are triggered, trigger `target` with `{key1: firstPayload, key2: secondPayload}` -first(15); // nothing -second('wow'); // nothing -third(false); // target triggered with object +```ts +const target = combineEvents({ + events: [event1, event2], + reset: resetUnit, + target: targetUnit, +}); ``` -## `combineEvents({ events: Array> })` +- When all events are triggered, trigger `target` with `[firstPayload, secondPayload]` -### Formulae +### Arguments -```ts -const target = combineEvents({ events: [event1, event2] }); -``` +1. `events` — Object or array with events +2. `reset` `(Unit)` - Optional. Any unit which will reset state of `combineEvents` and collecting of payloads will start from scratch +3. `target` `(Unit)` - Optional. Any unit with type matching `events` shape + +### Returns -- When all events (`event1` and `event2` from example) is triggered, trigger `target` with array from events with the same order as events +- `target` — When `target` option is not defined, will return new event with the same shape as `events`, otherwise `target` unit will returns ### Example @@ -71,16 +90,35 @@ const target = combineEvents({ events: [event1, event2] }); const first = createEvent(); const second = createEvent(); const third = createEvent(); +const target = createEvent<{ a: number; b: string; c: boolean }>(); +const reset = createEvent(); -const target = combineEvents({ events: [first, second, third] }); +combineEvents({ + events: { + a: first, + b: second, + c: third, + }, + reset, + target, +}); -target.watch((list) => { - console.log('first event data', list[0]); - console.log('second event data', list[1]); - console.log('third event data', list[2]); +target.watch((object) => { + console.log('first event data', object.a); + console.log('second event data', object.b); + console.log('third event data', object.c); }); first(15); // nothing second('wow'); // nothing -third(false); // target triggered with array +third(false); // target triggered with {a: 15, b: 'wow', c: false} + +first(10); +second('-'); + +reset(); // combineEvents state is erased + +third(true); // nothing, as it's a first saved payload +first(0); +second('ok'); // target triggered with {a: 0, b: 'ok', c: true} ``` diff --git a/src/debounce/debounce.test.ts b/src/debounce/debounce.test.ts index 5ebdc8be..cdf969f3 100644 --- a/src/debounce/debounce.test.ts +++ b/src/debounce/debounce.test.ts @@ -11,7 +11,6 @@ describe('arguments validation', () => { }); test('domain is not allowed', () => { - // @ts-expect-error expect(() => debounce({ source: createDomain(), timeout: 10 })).toThrowError( /cannot be domain/, ); @@ -67,6 +66,26 @@ describe('timeout as store', () => { await wait(92); expect(watcher).toBeCalledTimes(1); + trigger(); + await wait(120); + expect(watcher).toBeCalledTimes(2); + }); + test('new timeout is used after source trigger (shorthand form)', async () => { + const trigger = createEvent(); + const changeTimeout = createEvent(); + const $timeout = createStore(40).on(changeTimeout, (_, value) => value); + const debounced = debounce(trigger, $timeout); + const watcher = watch(debounced); + + trigger(); + await wait(32); + changeTimeout(100); + trigger(); + await wait(12); + expect(watcher).toBeCalledTimes(0); + await wait(92); + expect(watcher).toBeCalledTimes(1); + trigger(); await wait(120); expect(watcher).toBeCalledTimes(2); @@ -90,13 +109,12 @@ describe('triple trigger one wait', () => { await wait(42); expect(watcher).toBeCalledTimes(1); }); - - test('effect', async () => { + test('event (shorthand)', async () => { const watcher = jest.fn(); - const trigger = createEffect().use(() => undefined); + const trigger = createEvent(); + const debounced = debounce(trigger, 40); - const debounced = debounce({ source: trigger, timeout: 40 }); debounced.watch(watcher); trigger(); @@ -108,6 +126,39 @@ describe('triple trigger one wait', () => { expect(watcher).toBeCalledTimes(1); }); + test('effect', async () => { + const watcher = jest.fn(); + + const triggerFx = createEffect().use(() => undefined); + + const debounced = debounce({ source: triggerFx, timeout: 40 }); + debounced.watch(watcher); + + triggerFx(); + triggerFx(); + triggerFx(); + expect(watcher).not.toBeCalled(); + + await wait(42); + expect(watcher).toBeCalledTimes(1); + }); + test('effect (shorthand)', async () => { + const watcher = jest.fn(); + + const triggerFx = createEffect().use(() => undefined); + + const debounced = debounce(triggerFx, 40); + debounced.watch(watcher); + + triggerFx(); + triggerFx(); + triggerFx(); + expect(watcher).not.toBeCalled(); + + await wait(42); + expect(watcher).toBeCalledTimes(1); + }); + test('store', async () => { const watcher = jest.fn(); @@ -122,6 +173,24 @@ describe('triple trigger one wait', () => { trigger(2); expect(watcher).not.toBeCalled(); + await wait(42); + expect(watcher).toBeCalledTimes(1); + expect(watcher).toBeCalledWith(2); + }); + test('store (shorthand)', async () => { + const watcher = jest.fn(); + + const trigger = createEvent(); + const $store = createStore(0).on(trigger, (_, value) => value); + + const debounced = debounce($store, 40); + debounced.watch(watcher); + + trigger(0); + trigger(1); + trigger(2); + expect(watcher).not.toBeCalled(); + await wait(42); expect(watcher).toBeCalledTimes(1); expect(watcher).toBeCalledWith(2); diff --git a/src/debounce/index.ts b/src/debounce/index.ts index b56ad775..f0a21a82 100644 --- a/src/debounce/index.ts +++ b/src/debounce/index.ts @@ -1,5 +1,4 @@ import { - createEffect, createEvent, createStore, is, @@ -12,6 +11,10 @@ import { EventAsReturnType, } from 'effector'; +export function debounce( + source: Unit, + timeout: number | Store, +): EventAsReturnType; export function debounce(_: { source: Unit; timeout: number | Store; @@ -26,17 +29,21 @@ export function debounce< target: Target; name?: string; }): Target; -export function debounce({ - source, - timeout, - target, - name, -}: { - source: Unit; - timeout?: number | Store; - target?: UnitTargetable | Unit; - name?: string; -}): typeof target extends undefined ? EventAsReturnType : typeof target { +export function debounce( + ...args: + | [ + { + source: Unit; + timeout?: number | Store; + target?: UnitTargetable | Unit; + name?: string; + }, + ] + | [source: Unit, timeout: number | Store] +) { + const argsShape = + args.length === 2 ? { source: args[0], timeout: args[1] } : args[0]; + const { source, timeout, target, name } = argsShape; if (!is.unit(source)) throw new TypeError('source must be unit from effector'); if (is.domain(source)) throw new TypeError('source cannot be domain'); @@ -44,24 +51,22 @@ export function debounce({ const $timeout = toStoreNumber(timeout); const saveCancel = createEvent<[NodeJS.Timeout, () => void]>(); - const $canceller = createStore<[NodeJS.Timeout, () => void] | []>([], { serialize: 'ignore' }) - .on(saveCancel, (_, payload) => payload) + const $canceller = createStore<[NodeJS.Timeout, () => void] | []>([], { + serialize: 'ignore', + }).on(saveCancel, (_, payload) => payload); const tick = (target as UnitTargetable) ?? createEvent(); const timerFx = attach({ name: name || `debounce(${(source as any)?.shortName || source.kind}) effect`, source: $canceller, - effect([ timeoutId, rejectPromise ], timeout: number) { + effect([timeoutId, rejectPromise], timeout: number) { if (timeoutId) clearTimeout(timeoutId); if (rejectPromise) rejectPromise(); return new Promise((resolve, reject) => { - saveCancel([ - setTimeout(resolve, timeout), - reject - ]) + saveCancel([setTimeout(resolve, timeout), reject]); }); - } + }, }); $canceller.reset(timerFx.done); diff --git a/src/debounce/readme.md b/src/debounce/readme.md index b2c95f1b..1e8ad809 100644 --- a/src/debounce/readme.md +++ b/src/debounce/readme.md @@ -6,17 +6,22 @@ import { debounce } from 'patronum'; import { debounce } from 'patronum/debounce'; ``` -## `debounce({ source, timeout })` +## `debounce(source, timeout)` ### Motivation Method creates a new event, that will be triggered after some time. It is useful for handling user events such as scrolling, mouse movement, or keypressing. It is useful when you want to pass created event immediately to another method as argument. +:::note since +patronum 2.1.0 +Use `debounce({ source, timeout })` with patronum < 2.1.0 +::: + ### Formulae ```ts -event = debounce({ source, timeout }); +event = debounce(source, timeout); ``` - Wait for `timeout` after the last time `source` was triggered, then trigger `event` with payload of the `source` @@ -39,10 +44,7 @@ import { debounce } from 'patronum/debounce'; const DEBOUNCE_TIMEOUT_IN_MS = 200; const someHappened = createEvent(); -const debounced = debounce({ - source: someHappened, - timeout: DEBOUNCE_TIMEOUT_IN_MS, -}); +const debounced = debounce(someHappened, DEBOUNCE_TIMEOUT_IN_MS); debounced.watch((payload) => { console.info('someHappened now', payload); @@ -56,6 +58,39 @@ someHappened(4); // someHappened now 4 ``` +### Example with timeout as store + +```ts +import { createStore } from 'effector'; +import { debounce } from 'patronum'; + +const DEBOUNCE_TIMEOUT_IN_MS = 200; + +const changeTimeout = createEvent(); +const $timeout = createStore(DEBOUNCE_TIMEOUT_IN_MS).on( + changeTimeout, + (_, value) => value, +); +const someHappened = createEvent(); +const debounced = debounce(someHappened, $timeout); + +debounced.watch((payload) => { + console.info('someHappened now', payload); +}); + +someHappened(1); +changeTimeout(400); // will be applied after next source trigger +someHappened(2); + +setTimeout(() => { + // console clear +}, 200); + +setTimeout(() => { + // someHappened now 2 +}, 400); +``` + ## `debounce({ source, timeout, target })` ### Motivation @@ -114,23 +149,41 @@ someHappened(4); // got data 4 ``` -### Example with timeout as store +## `debounce({ source, timeout })` + +### Motivation + +This overload recieves `source` and `timeout` as an object. May be useful for additional clarity, but it's longer to write + +### Formulae ```ts -import { createStore } from 'effector'; -import { debounce } from 'patronum'; +event = debounce({ source, timeout }); +``` + +- Wait for `timeout` after the last time `source` was triggered, then trigger `event` with payload of the `source` + +### Arguments + +1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used by the `event` +1. `timeout` `(number | Store)` — time to wait before trigger `event` + +### Returns + +- `event` `(Event)` — New event, that triggered after delay + +### Example + +```ts +import { createEvent } from 'effector'; +import { debounce } from 'patronum/debounce'; const DEBOUNCE_TIMEOUT_IN_MS = 200; -const changeTimeout = createEvent(); -const $timeout = createStore(DEBOUNCE_TIMEOUT_IN_MS).on( - changeTimeout, - (_, value) => value, -); const someHappened = createEvent(); const debounced = debounce({ source: someHappened, - timeout: $timeout, + timeout: DEBOUNCE_TIMEOUT_IN_MS, }); debounced.watch((payload) => { @@ -138,14 +191,9 @@ debounced.watch((payload) => { }); someHappened(1); -changeTimeout(400); // will be applied after next source trigger someHappened(2); +someHappened(3); +someHappened(4); -setTimeout(() => { - // console clear -}, 200); - -setTimeout(() => { - // someHappened now 2 -}, 400); +// someHappened now 4 ``` diff --git a/src/delay/delay.test.ts b/src/delay/delay.test.ts index 14f351db..0a2c9333 100644 --- a/src/delay/delay.test.ts +++ b/src/delay/delay.test.ts @@ -34,6 +34,26 @@ test('delay event with number', async () => { ] `); }); +test('delay event with number (shorthand)', async () => { + const source = createEvent(); + const delayed = delay(source, 100); + const fn = jest.fn(); + delayed.watch(fn); + + source(1); + const start = time(); + expect(fn).toBeCalledTimes(0); + + await waitFor(delayed); + expect(start.diff()).toBeCloseWithThreshold(100, TIMER_THRESHOLD); + expect(fn).toBeCalledTimes(1); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 1, + ] + `); +}); test('delay event with number with target', async () => { const source = createEvent(); @@ -107,8 +127,28 @@ test('delay event with function', async () => { `); }); -test('delay event with function of argument', async () => { +test('delay event with function (shorthand)', async () => { const source = createEvent(); + const delayed = delay(source, () => 100); + const fn = jest.fn(); + delayed.watch(fn); + + const start = time(); + source(1); + + await waitFor(delayed); + expect(start.diff()).toBeCloseWithThreshold(100, TIMER_THRESHOLD); + expect(fn).toBeCalledTimes(1); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 1, + ] + `); +}); + +test('delay event with function of argument', async () => { + const source = createEvent(); const delayed = delay({ source, timeout: (number) => number * 100 }); const fn = jest.fn(); delayed.watch(fn); @@ -139,6 +179,38 @@ test('delay event with function of argument', async () => { `); }); +test('delay event with function of argument (shorthand)', async () => { + const source = createEvent(); + const delayed = delay(source, (number) => number * 100); + const fn = jest.fn(); + delayed.watch(fn); + + const start1 = time(); + source(1); // 100ms delay + expect(fn).toBeCalledTimes(0); + + await waitFor(delayed); + expect(start1.diff()).toBeCloseWithThreshold(100, TIMER_THRESHOLD); + expect(fn).toBeCalledTimes(1); + + const start2 = time(); + source(2); // 200ms delay + + await waitFor(delayed); + expect(start2.diff()).toBeCloseWithThreshold(200, TIMER_THRESHOLD); + expect(fn).toBeCalledTimes(2); + + await wait(120); + expect(fn).toBeCalledTimes(2); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 1, + 2, + ] + `); +}); + test('delay event with store as timeout', async () => { const source = createEvent(); const timeout = createStore(0).on( @@ -175,6 +247,42 @@ test('delay event with store as timeout', async () => { `); }); +test('delay event with store as timeout (shorthand)', async () => { + const source = createEvent(); + const timeout = createStore(0).on( + source, + (current, count) => current + count * 100, + ); + const delayed = delay(source, timeout); + const fn = jest.fn(); + delayed.watch(fn); + + const start1 = time(); + source(1); // 100ms delay + expect(fn).toBeCalledTimes(0); + + await waitFor(delayed); + expect(start1.diff()).toBeCloseWithThreshold(100, TIMER_THRESHOLD); + expect(fn).toBeCalledTimes(1); + + const start2 = time(); + source(2); // 200ms delay + + await waitFor(delayed); + expect(start2.diff()).toBeCloseWithThreshold(300, TIMER_THRESHOLD); + expect(fn).toBeCalledTimes(2); + + await wait(120); + expect(fn).toBeCalledTimes(2); + + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 1, + 2, + ] + `); +}); + test('delay store', async () => { const change = createEvent(); const $source = createStore(0).on(change, (_, value) => value); diff --git a/src/delay/index.ts b/src/delay/index.ts index 44b08001..543a4e31 100644 --- a/src/delay/index.ts +++ b/src/delay/index.ts @@ -15,6 +15,11 @@ import { type TimeoutType = ((payload: Payload) => number) | Store | number; +export function delay>( + source: Source, + timeout: TimeoutType>, +): EventAsReturnType>; + export function delay, Target extends TargetType>(config: { source: Source; timeout: TimeoutType>; @@ -29,15 +34,21 @@ export function delay>(config: { export function delay< Source extends Unit, Target extends TargetType = TargetType, ->({ - source, - timeout, - target = createEvent() as any, -}: { - source: Source; - timeout: TimeoutType>; - target?: MultiTarget>; -}): typeof target extends undefined ? EventAsReturnType> : Target { +>( + ...args: + | [ + { + source: Source; + timeout: TimeoutType>; + target?: MultiTarget>; + }, + ] + | [Source, TimeoutType>] +) { + const argsShape = + args.length === 2 ? { source: args[0], timeout: args[1] } : args[0]; + + const { source, timeout, target = createEvent() as any } = argsShape; const targets = Array.isArray(target) ? target : [target]; if (!is.unit(source)) throw new TypeError('source must be a unit from effector'); diff --git a/src/delay/readme.md b/src/delay/readme.md index 7b61cec2..6736c3f4 100644 --- a/src/delay/readme.md +++ b/src/delay/readme.md @@ -6,6 +6,48 @@ import { delay } from 'patronum'; import { delay } from 'patronum/delay'; ``` +Method for delaying triggering given unit for some amount of time. Can accept `number`, `Store` or `(sourceValue) => number` (function for calculating timeout based on `source` payload) as timeout. Exists in two form: shorthand `delay(source, timeout)` and object form `delay({source, timeout, target})`, the first one needs to create new unit for this specific purpose, last one needs when `target` unit is already exists and the goal is just to call it after delay + +## `delay(source, timeout: number)` + +:::note since +patronum 2.1.0 +Use `delay({ source, timeout })` with patronum < 2.1.0 +::: + +### Formulae + +```ts +target = delay(source, timeout); +``` + +- When `source` is triggered, wait for `timeout`, then trigger `target` with payload of the `source` + +### Arguments + +1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used to trigger `target` with. +1. `timeout` `(number)` — time to wait before trigger `event` + +### Returns + +- `target` `(Event)` — New event which will receive `source` payload after `timeout` delay + +### Example + +```ts +import { createEvent } from 'effector'; +import { delay } from 'patronum/delay'; + +const trigger = createEvent(); // createStore or createEffect +const delayed = delay(trigger, 300); + +delayed.watch((payload) => console.info('triggered', payload)); + +trigger('hello'); +// after 300ms +// => triggered hello +``` + ## `delay({ source, timeout: number, target })` ### Formulae @@ -19,8 +61,8 @@ target = delay({ source, timeout: number, target }); ### Arguments 1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used to trigger `target` with. -1. `timeout` `(number)` — time to wait before trigger `event` -1. `target` `(Unit` | `Array>)` — Optional. Target unit or array of units that will be called after delay. +2. `timeout` `(number)` — time to wait before trigger `event` +3. `target` `(Unit` | `Array>)` — Optional. Target unit or array of units that will be called after delay. ### Returns @@ -33,7 +75,12 @@ import { createEvent } from 'effector'; import { delay } from 'patronum/delay'; const trigger = createEvent(); // createStore or createEffect -const delayed = delay({ source: trigger, timeout: 300 }); +const delayed = createEvent(); +delay({ + source: trigger, + timeout: 300, + target: delayed, +}); delayed.watch((payload) => console.info('triggered', payload)); @@ -42,6 +89,56 @@ trigger('hello'); // => triggered hello ``` +## `delay(source, timeout: Function)` + +:::note since +patronum 2.1.0 +Use `delay({ source, timeout })` with patronum < 2.1.0 +::: + +### Motivation + +This overload allows to calculate timeout from payload of `source`. +It is useful when you know that calculations requires more time if you have more data for payload. + +### Formulae + +```ts +target = delay(source, timeout); +``` + +- When `source` is triggered, call `timeout` with payload to get the timeout for delay, then trigger `target` with payload of the `source` + +### Arguments + +1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used to trigger `target` with. +2. `timeout` `((payload: T) => number)` — Calculate delay for each `source` call. Receives the payload of `source` as argument. Should return `number` — delay in milliseconds. + +### Returns + +- `target` `(Event)` — New event which will receive `source` payload after `timeout` delay + +### Example + +```ts +import { createEvent, createStore } from 'effector'; +import { delay } from 'patronum/delay'; + +const update = createEvent(); +const $data = createStore(''); +const logDelayed = delay($data, (string) => string.length * 100); + +logDelayed.watch((data) => console.log('log', data)); + +update('Hello'); +// after 500ms +// => log Hello + +update('!'); +// after 100ms +// => log ! +``` + ## `delay({ source, timeout: Function, target })` ### Motivation @@ -60,8 +157,8 @@ target = delay({ source, timeout: Function, target }); ### Arguments 1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used to trigger `target` with. -1. `timeout` `((payload: T) => number)` — Calculate delay for each `source` call. Receives the payload of `source` as argument. Should return `number` — delay in milliseconds. -1. `target` `(Unit` | `Array>)` — Optional. Target unit or array of units that will be called after delay. +2. `timeout` `((payload: T) => number)` — Calculate delay for each `source` call. Receives the payload of `source` as argument. Should return `number` — delay in milliseconds. +3. `target` `(Unit` | `Array>)` — Optional. Target unit or array of units that will be called after delay. ### Returns @@ -94,6 +191,53 @@ update('!'); // => log ! ``` +## `delay(source, timeout: Store)` + +:::note since +patronum 2.1.0 +Use `delay({ source, timeout })` with patronum < 2.1.0 +::: + +### Motivation + +This overload allows you to read timeout from another store. +It is useful when you writing music editor and need dynamic delay for your events. + +### Formulae + +```ts +target = delay(source, timeout); +``` + +- When `source` is triggered, read timeout from `timeout` store, then trigger `target` with payload of the `source` + +### Arguments + +1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used to trigger `target` with. +2. `timeout` `(Store)` — Store with number — delay in milliseconds. + +### Returns + +- `target` `(Event)` — New event which will receive `source` payload after `timeout` delay + +### Example + +```ts +import { createEvent, createStore } from 'effector'; +import { delay } from 'patronum/delay'; + +const update = createEvent(); +const $timeout = createStore(500); + +const logDelayed = delay(update, $timeout); + +logDelayed.watch((data) => console.log('log', data)); + +update('Hello'); +// after 500ms +// => log Hello +``` + ## `delay({ source, timeout: Store, target })` ### Motivation @@ -112,8 +256,8 @@ target = delay({ source, timeout: $store, target }); ### Arguments 1. `source` `(Event` | `Store` | `Effect)` — Source unit, data from this unit used to trigger `target` with. -1. `timeout` `(Store)` — Store with number — delay in milliseconds. -1. `target` `(Unit` | `Array>)` — Optional. Target unit or array of units that will be called after delay. +2. `timeout` `(Store)` — Store with number — delay in milliseconds. +3. `target` `(Unit` | `Array>)` — Optional. Target unit or array of units that will be called after delay. ### Returns @@ -128,9 +272,10 @@ import { delay } from 'patronum/delay'; const update = createEvent(); const $timeout = createStore(500); -const logDelayed = delay({ +delay({ source: update, timeout: $timeout, + target: logDelayed, }); logDelayed.watch((data) => console.log('log', data)); diff --git a/src/in-flight/in-flight.test.ts b/src/in-flight/in-flight.test.ts index f9446fd1..2850911e 100644 --- a/src/in-flight/in-flight.test.ts +++ b/src/in-flight/in-flight.test.ts @@ -4,8 +4,8 @@ import { inFlight } from './index'; describe('effects', () => { test('initial at 0', async () => { - const effect = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 1)), + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 1)); }); const $count = inFlight({ effects: [effect] }); const fn = jest.fn(); @@ -18,6 +18,21 @@ describe('effects', () => { `); }); + test('initial at 0 (shorthand)', async () => { + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 1)); + }); + const $count = inFlight([effect]); + const fn = jest.fn(); + + $count.watch(fn); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 0, + ] + `); + }); + test('Run effect to get 1, after effect get 0', async () => { const effect = createEffect({ handler: () => new Promise((resolve) => setTimeout(resolve, 1)), @@ -91,14 +106,14 @@ describe('effects', () => { }); test('Different effects works simultaneously', async () => { - const effect1 = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 10)), + const effect1 = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 10)); }); - const effect2 = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 10)), + const effect2 = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 10)); }); - const effect3 = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 10)), + const effect3 = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 10)); }); const $count = inFlight({ effects: [effect1, effect2, effect3] }); const fn = jest.fn(); @@ -149,6 +164,65 @@ describe('effects', () => { `); }); + test('Different effects works simultaneously (shorthand)', async () => { + const effect1 = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 10)); + }); + const effect2 = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 10)); + }); + const effect3 = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 10)); + }); + const $count = inFlight([effect1, effect2, effect3]); + const fn = jest.fn(); + + $count.watch(fn); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 0, + ] + `); + + effect1(); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 0, + 1, + ] + `); + + await waitFor(effect1.inFlight.updates.filter({ fn: (c) => c === 0 })); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 0, + 1, + 0, + ] + `); + + effect2(); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 0, + 1, + 0, + 1, + ] + `); + + await waitFor(effect2.inFlight.updates.filter({ fn: (c) => c === 0 })); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + 0, + 1, + 0, + 1, + 0, + ] + `); + }); + test('Different concurrent effect runs works simultaneously', async () => { const effect1 = createEffect({ handler: () => new Promise((resolve) => setTimeout(resolve, 10)), diff --git a/src/in-flight/index.ts b/src/in-flight/index.ts index 16126df4..ac399c89 100644 --- a/src/in-flight/index.ts +++ b/src/in-flight/index.ts @@ -1,14 +1,18 @@ import { combine, Domain, Effect, Store } from 'effector'; +export function inFlight(effects: Array>): Store; export function inFlight(_: { effects: Array> }): Store; export function inFlight(_: { domain: Domain }): Store; -export function inFlight({ - effects, - domain, -}: { - effects?: Array>; - domain?: Domain; -}): Store { +export function inFlight( + args: + | { + effects?: Array>; + domain?: Domain; + } + | Array>, +): Store { + const argsShape = Array.isArray(args) ? { effects: args } : args; + const { effects, domain } = argsShape; if (domain) { const $inFlight = domain.createStore(0); diff --git a/src/in-flight/readme.md b/src/in-flight/readme.md index 11b93141..fde35d66 100644 --- a/src/in-flight/readme.md +++ b/src/in-flight/readme.md @@ -6,7 +6,12 @@ import { inFlight } from 'patronum'; import { inFlight } from 'patronum/in-flight'; ``` -## `inFlight({ effects: [] })` +## `inFlight(effects)` + +:::note since +patronum 2.1.0 +Use `inFlight({ effects })` with patronum < 2.1.0 +::: ### Motivation @@ -16,7 +21,7 @@ It is useful when you want to show pending state of complex process. ### Formulae ```ts -$count = inFlight({ effects: [fx1, fx2] }); +$count = inFlight([fx1, fx2]); ``` - Count all pending runs of effects in one store @@ -37,7 +42,7 @@ import { inFlight } from 'patronum/in-flight'; const loadFirst = createEffect().use(() => Promise.resolve(null)); const loadSecond = createEffect().use(() => Promise.resolve(2)); -const $count = inFlight({ effects: [loadFirst, loadSecond] }); +const $count = inFlight([loadFirst, loadSecond]); $count.watch((count) => console.info(`count: ${count}`)); // => count: 0 @@ -102,3 +107,50 @@ loadSecond(); // Wait to resolve all effects // => count: 0 ``` + +## `inFlight({ effects: [] })` + +### Motivation + +This overload recieves `effects` as an object. May be useful for additional clarity, but it's longer to write + +### Formulae + +```ts +$count = inFlight({ effects: [fx1, fx2] }); +``` + +- Count all pending runs of effects in one store + +### Arguments + +1. `effects` `(Array>)` - array of any effects + +## Returns + +- `$count` `(Store)` - Store with count of run effects in pending state + +## Example + +```ts +import { createEffect } from 'effector'; +import { inFlight } from 'patronum/in-flight'; + +const loadFirst = createEffect().use(() => Promise.resolve(null)); +const loadSecond = createEffect().use(() => Promise.resolve(2)); +const $count = inFlight({ effects: [loadFirst, loadSecond] }); + +$count.watch((count) => console.info(`count: ${count}`)); +// => count: 0 + +loadFirst(); +loadSecond(); +// => count: 2 + +loadSecond(); +loadSecond(); +// => count: 4 + +// Wait to resolve all effects +// => count: 0 +``` diff --git a/src/index.md b/src/index.md index 632a8312..a6b5a900 100644 --- a/src/index.md +++ b/src/index.md @@ -42,6 +42,7 @@ All methods split into categories. - [snapshot](./snapshot/readme.md) — Create store value snapshot. - [splitMap](./split-map/readme.md) — Split event to different events and map data. - [spread](./spread/readme.md) — Send fields from object to same targets. +- [previous](./previous/readme.md) - Get previous value of store. ### Debug diff --git a/src/pending/index.ts b/src/pending/index.ts index 0ec028d6..8c20e1ea 100644 --- a/src/pending/index.ts +++ b/src/pending/index.ts @@ -7,20 +7,23 @@ const strategies = { every: (list: T[]) => list.every(Boolean), }; +export function pending(effects: Array>): Store; export function pending(config: { effects: Array>; of?: Strategy; }): Store; export function pending(config: { domain: Domain; of?: Strategy }): Store; -export function pending({ - effects: rawEffects, - domain, - of = 'some', -}: { - effects?: Array>; - of?: Strategy; - domain?: Domain; -}): Store { +export function pending( + args: + | { + effects?: Array>; + of?: Strategy; + domain?: Domain; + } + | Array>, +): Store { + const argsShape = Array.isArray(args) ? { effects: args } : args; + const { effects: rawEffects, domain, of = 'some' } = argsShape; if (!is.domain(domain) && !rawEffects) throw new TypeError('domain or effects should be passed'); diff --git a/src/pending/pending.test.ts b/src/pending/pending.test.ts index 214f4204..b513f644 100644 --- a/src/pending/pending.test.ts +++ b/src/pending/pending.test.ts @@ -39,8 +39,8 @@ describe('strategies', () => { describe('effects', () => { test('initial at false', async () => { - const effect = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 1)), + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 1)); }); const $pending = pending({ effects: [effect] }); const fn = jest.fn(); @@ -53,9 +53,24 @@ describe('effects', () => { `); }); + test('initial at false (shorthand)', async () => { + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 1)); + }); + const $pending = pending([effect]); + const fn = jest.fn(); + + $pending.watch(fn); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + false, + ] + `); + }); + test('Run effect to get true, after effect get false', async () => { - const effect = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 1)), + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 1)); }); const $pending = pending({ effects: [effect] }); const fn = jest.fn(); @@ -85,6 +100,38 @@ describe('effects', () => { `); }); + test('Run effect to get true, after effect get false (shorthand)', async () => { + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 1)); + }); + const $pending = pending([effect]); + const fn = jest.fn(); + + $pending.watch(fn); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + false, + ] + `); + + effect(); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + false, + true, + ] + `); + + await waitFor(effect.finally); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + false, + true, + false, + ] + `); + }); + test('Concurrent runs works simultaneously', async () => { const effect = createEffect({ handler: () => new Promise((resolve) => setTimeout(resolve, 100)), diff --git a/src/pending/readme.md b/src/pending/readme.md index 2e4f931c..35194295 100644 --- a/src/pending/readme.md +++ b/src/pending/readme.md @@ -6,7 +6,12 @@ import { pending } from 'patronum'; import { pending } from 'patronum/pending'; ``` -## `pending({ effects: [] })` +## `pendings(effects)` + +:::note since +patronum 2.1.0 +Use `pending({ effects: [] })` with with patronum < 2.1.0 +::: ### Motivation @@ -15,6 +20,46 @@ you want to show loading state of the whole application. ### Formulae +```ts +$inProcess = pending([fx1, fx2]); +``` + +- When some of `effects` are in pending state, result will be `true` + +### Arguments + +1. `effects` `(Array>)` - array of any effects + +### Returns + +- `$inProcess` `(Store)` - Store with boolean state + +### Example + +```ts +import { createEffect } from 'effector'; +import { pending } from 'patronum/pending'; + +const loadFirstFx = createEffect(() => Promise.resolve(null)); +const loadSecondFx = createEffect(() => Promise.resolve(2)); +const $processing = pending([loadFirstFx, loadSecondFx]); + +$processing.watch((processing) => console.info(`processing: ${processing}`)); +// => processing: false + +loadFirstFx(); +loadSecondFx(); +// => processing: true +``` + +## `pending({ effects: [] })` + +### Motivation + +This overload recieves `effects` and optional `of` strategy as an object. Useful when need to change strategy + +### Formulae + ```ts $inProcess = pending({ effects: [fx1, fx2], of: Strategy }); ``` @@ -36,21 +81,25 @@ The `of` argument was added since patronum 1.1.0 - `$inProcess` `(Store)` - Store with boolean state -### Example +### Example: show processing only when all effects are pending ```ts import { createEffect } from 'effector'; import { pending } from 'patronum/pending'; -const loadFirst = createEffect(() => Promise.resolve(null)); -const loadSecond = createEffect(() => Promise.resolve(2)); -const $processing = pending({ effects: [loadFirst, loadSecond] }); +const loadFirstFx = createEffect(() => Promise.resolve(null)); +const loadSecondFx = createEffect(() => Promise.resolve(2)); +const $processing = pending({ + effects: [loadFirstFx, loadSecondFx], + of: 'every', +}); $processing.watch((processing) => console.info(`processing: ${processing}`)); // => processing: false -loadFirst(); -loadSecond(); +loadFirstFx(); +// => processing is still false +loadSecondFx(); // => processing: true ``` @@ -91,15 +140,15 @@ import { createDomain } from 'effector'; import { pending } from 'patronum/pending'; const app = createDomain(); -const loadFirst = app.createEffect(() => Promise.resolve(null)); -const loadSecond = app.createEffect(() => Promise.resolve(2)); +const loadFirstFx = app.createEffect(() => Promise.resolve(null)); +const loadSecondFx = app.createEffect(() => Promise.resolve(2)); const $processing = pending({ domain: app }); $processing.watch((processing) => console.info(`processing: ${processing}`)); // => processing: false -loadFirst(); -loadSecond(); +loadFirstFx(); +loadSecondFx(); // => processing: true ``` @@ -117,18 +166,18 @@ There available two options: import { createEffect } from 'effector'; import { pending } from 'patronum/pending'; -const loadFirst = createEffect(() => Promise.resolve(null)); -const loadSecond = createEffect(() => Promise.resolve(2)); +const loadFirstFx = createEffect(() => Promise.resolve(null)); +const loadSecondFx = createEffect(() => Promise.resolve(2)); -const $pending = pending({ effects: [loadFirst, loadSecond], of: 'every' }); +const $pending = pending({ effects: [loadFirstFx, loadSecondFx], of: 'every' }); // When no effects is loading, $pending will be true // If only one is loading, also will be false -loadFirst(); +loadFirstFx(); // But after running the second effect, $pending will be true -loadSecond(); +loadSecondFx(); $pending.watch(console.log); // true ``` diff --git a/src/previous/index.ts b/src/previous/index.ts new file mode 100644 index 00000000..ff71db06 --- /dev/null +++ b/src/previous/index.ts @@ -0,0 +1,34 @@ +import { Node, Store, createStore, launch, step, is } from 'effector'; + +export function previous(store: Store): Store; +export function previous( + store: Store, + initialValue: Init, +): Store; +export function previous( + ...args: [store: Store, defaultValue?: Init] +) { + const [store] = args; + const initialValue = (args.length < 2 ? null : args[1]) as Init | null; + if (!is.store(store)) { + throw Error('previous first argument should be a store'); + } + const $prevValue = createStore(initialValue, { + serialize: 'ignore', + skipVoid: false, + }); + const storeNode: Node = (store as any).graphite; + storeNode.seq.push( + step.compute({ + fn(upd, _, stack) { + launch({ + target: $prevValue, + params: stack.a, + defer: true, + }); + return upd; + }, + }), + ); + return $prevValue; +} diff --git a/src/previous/previous.fork.test.ts b/src/previous/previous.fork.test.ts new file mode 100644 index 00000000..36bab800 --- /dev/null +++ b/src/previous/previous.fork.test.ts @@ -0,0 +1,87 @@ +import { + allSettled, + createEvent, + createStore, + fork, + restore, + sample, +} from 'effector'; + +import { previous } from './index'; + +it('has null when store is not changed', () => { + const scope = fork(); + const $initalStore = createStore(10); + const $prevValue = previous($initalStore); + + expect(scope.getState($prevValue)).toBe(null); +}); + +it('has initial value when defined', () => { + const scope = fork(); + const $initalStore = createStore(10); + const $prevValue = previous($initalStore, 0); + + expect(scope.getState($prevValue)).toBe(0); +}); + +it('has first value on update', async () => { + const scope = fork(); + const changeInitialStore = createEvent(); + const $initalStore = restore(changeInitialStore, 10); + + const $prevValue = previous($initalStore); + + await allSettled(changeInitialStore, { scope, params: 20 }); + + expect(scope.getState($prevValue)).toBe(10); +}); + +it('has previous value on multiple updates', async () => { + const scope = fork(); + const changeInitialStore = createEvent(); + const $initalStore = restore(changeInitialStore, 10); + + const $prevValue = previous($initalStore); + + await allSettled(changeInitialStore, { scope, params: 20 }); + await allSettled(changeInitialStore, { scope, params: 30 }); + await allSettled(changeInitialStore, { scope, params: 40 }); + + expect(scope.getState($prevValue)).toBe(30); +}); + +it('has first scope value after first update', async () => { + const inc = createEvent(); + const $initalStore = createStore(0); + const $prevValue = previous($initalStore, -1); + $initalStore.on(inc, (x) => x + 1); + const scope = fork({ values: [[$initalStore, 10]] }); + await allSettled(inc, { scope }); + expect(scope.getState($prevValue)).toBe(10); +}); + +test('undefined support', async () => { + const changeInitialStore = createEvent(); + const $initialStore = createStore('a', { skipVoid: false }); + const $prevValue = previous($initialStore); + + sample({ clock: changeInitialStore, target: $initialStore }); + + const scope = fork({ values: [[$initialStore, 'b']] }); + await allSettled(changeInitialStore, { scope, params: undefined }); + expect(scope.getState($prevValue)).toBe('b'); + await allSettled(changeInitialStore, { scope, params: 'c' }); + expect(scope.getState($prevValue)).toBe(undefined); +}); + +test('undefined as defaultValue support', () => { + const changeInitialStore = createEvent(); + const $initialStore = createStore('a', { skipVoid: false }); + const $prevValue = previous($initialStore, undefined); + + sample({ clock: changeInitialStore, target: $initialStore }); + + const scope = fork({ values: [[$initialStore, 'b']] }); + expect(scope.getState($prevValue)).toBe(undefined); +}); diff --git a/src/previous/previous.test.ts b/src/previous/previous.test.ts new file mode 100644 index 00000000..a6a67a7e --- /dev/null +++ b/src/previous/previous.test.ts @@ -0,0 +1,73 @@ +import { createEvent, createStore, restore, sample } from 'effector'; + +import { previous } from './index'; + +it('has null when store is not changed', () => { + const $initalStore = createStore(10); + const $prevValue = previous($initalStore); + + expect($prevValue.getState()).toBe(null); +}); + +it('has initial value when defined', () => { + const $initalStore = createStore(10); + const $prevValue = previous($initalStore, 0); + + expect($prevValue.getState()).toBe(0); +}); + +it('has first value on update', () => { + const changeInitialStore = createEvent(); + const $initalStore = restore(changeInitialStore, 10); + + const $prevValue = previous($initalStore); + + changeInitialStore(20); + + expect($prevValue.getState()).toBe(10); +}); + +it('has previous value on multiple updates', () => { + const changeInitialStore = createEvent(); + const $initalStore = restore(changeInitialStore, 10); + + const $prevValue = previous($initalStore); + + changeInitialStore(20); + changeInitialStore(30); + changeInitialStore(40); + + expect($prevValue.getState()).toBe(30); +}); + +test('undefined support', () => { + const changeInitialStore = createEvent(); + const $initialStore = createStore('a', { skipVoid: false }); + const $prevValue = previous($initialStore); + + sample({ clock: changeInitialStore, target: $initialStore }); + + changeInitialStore(); + expect($prevValue.getState()).toBe('a'); + changeInitialStore('b'); + expect($prevValue.getState()).toBe(undefined); +}); + +test('undefined as defaultValue support', () => { + const changeInitialStore = createEvent(); + const $initialStore = createStore('a', { skipVoid: false }); + const $prevValue = previous($initialStore, undefined); + + sample({ clock: changeInitialStore, target: $initialStore }); + + expect($prevValue.getState()).toBe(undefined); +}); + +test('store validation', () => { + expect(() => { + // @ts-expect-error + previous(null); + }).toThrowErrorMatchingInlineSnapshot( + `"previous first argument should be a store"`, + ); +}); diff --git a/src/previous/readme.md b/src/previous/readme.md new file mode 100644 index 00000000..c878d403 --- /dev/null +++ b/src/previous/readme.md @@ -0,0 +1,65 @@ +# previous + +:::note since +patronum 2.1.0 +::: + +```ts +import { previous } from 'patronum'; +// or +import { previous } from 'patronum/previous-value'; +``` + +### Motivation + +The method allows to get previous value of given store. Usually need for analytics + +### Formulae + +```ts +$target = previous($source); +$target = previous($source, 'initial value'); +``` + +### Arguments + +1. `$source` ([_`Store`_]) - source store +2. `defaultValue` (_optional_) - default value for `$target` store, if not passed, `null` will be used + +### Returns + +- `$target` ([_`Store`_]) - new store that contain previous value of `$source` after first update and null or default value (if passed) before that + +### Example + +Push analytics with route transition: + +```ts +import { createStore, createEvent, createEffect, sample } from 'effector'; +import { previous } from 'patronum'; + +const openNewRoute = createEvent(); +const $currentRoute = createStore('main_page'); +const $previousRoute = previous($currentRoute); + +const sendRouteTransitionFx = createEffect(async ({ prevRoute, nextRoute }) => { + console.log(prevRoute, '->', newRoute) + await fetch(...) +}); + +sample({clock: openNewRoute, target: $currentRoute}); + +sample({ + clock: openNewRoute, + source: { + prevRoute: $previousRoute, + nextRoute: $currentRoute, + }, + target: sendRouteTransitionFx, +}); + +openNewRoute('messages'); +// main_page -> messages +openNewRoute('chats'); +// messages -> chats +``` diff --git a/src/spread/index.ts b/src/spread/index.ts index 722a596f..db7f1130 100644 --- a/src/spread/index.ts +++ b/src/spread/index.ts @@ -1,11 +1,17 @@ -import { createEvent, Event, EventCallable, sample, Unit, UnitTargetable } from 'effector'; +import { + createEvent, + EventCallable, + is, + sample, + Unit, + UnitTargetable, +} from 'effector'; const hasPropBase = {}.hasOwnProperty; const hasOwnProp = (object: O, key: string) => hasPropBase.call(object, key); type NoInfer = [T][T extends any ? 0 : never]; -type EventAsReturnType = any extends Payload ? Event : never; export function spread(config: { targets: { @@ -25,6 +31,10 @@ export function spread< }; }): Source; +export function spread(targets: { + [Key in keyof Payload]?: UnitTargetable; +}): EventCallable>; + /** * @example * spread({ source: dataObject, targets: { first: targetA, second: targetB } }) @@ -32,15 +42,20 @@ export function spread< * target: spread({targets: { first: targetA, second: targetB } }) * }) */ -export function spread

({ - targets, - source = createEvent

(), -}: { - targets: { - [Key in keyof P]?: Unit; - }; - source?: Unit

; -}): EventCallable

{ +export function spread

( + args: + | { + targets: { + [Key in keyof P]?: Unit; + }; + source?: Unit

; + } + | { + [Key in keyof P]?: Unit; + }, +): EventCallable

{ + const argsShape = isTargets(args) ? { targets: args } : args; + const { targets, source = createEvent

() } = argsShape; for (const targetKey in targets) { if (hasOwnProp(targets, targetKey)) { const currentTarget = targets[targetKey]; @@ -63,3 +78,23 @@ export function spread

({ return source as any; } + +function isTargets

( + args: + | { + targets: { + [Key in keyof P]?: Unit; + }; + source?: Unit

; + } + | { + [Key in keyof P]?: Unit; + }, +): args is { + [Key in keyof P]?: Unit; +} { + return Object.keys(args).some( + (key) => + !['targets', 'source'].includes(key) && is.unit(args[key as keyof typeof args]), + ); +} diff --git a/src/spread/readme.md b/src/spread/readme.md index b4d96020..941e8753 100644 --- a/src/spread/readme.md +++ b/src/spread/readme.md @@ -6,6 +6,101 @@ import { spread } from 'patronum'; import { spread } from 'patronum/spread'; ``` +## `source = spread(targets)` + +:::note since +patronum 2.1.0 +Use `spread({ targets })` with patronum < 2.1.0 +::: + +### Motivation + +This method allows to trigger many target at once, if they match the source structure. +It is useful when you need to destructure object and save values to different stores. + +### Formulae + +```ts +source = spread({ field: target, ... }) +``` + +- When `source` is triggered with **object**, extract `field` from data, and trigger `target` +- `targets` can have multiple properties +- If the `source` was triggered with non-object, nothing would be happening +- If `source` is triggered with object but without propertpy `field`, target for this `field` will not be triggered + +### Arguments + +1. `targets` `(Record | Store | Effect>)` — Flat object which key is key in `source` payload, and value is unit to store value to. + +### Returns + +- `source` `(Event)` — Source event, data passed to it should be an object with fields from `targets` + +### Example + +#### Conditionally save value to stores + +```ts +import { createStore, createEvent, sample } from 'effector'; +import { spread } from 'patronum'; + +const $first = createStore(''); +const $last = createStore(''); + +const formReceived = createEvent(); + +sample({ + source: formReceived, + filter: (form) => form.first.length > 0 && form.last.length > 0, + target: spread({ + first: $first, + last: $last, + }), +}); + +$first.watch((first) => console.log('First name', first)); +$last.watch((last) => console.log('Last name', last)); + +formReceived({ first: '', last: '' }); +// Nothing, because filter returned true + +formReceived({ first: 'Hello', last: 'World' }); +// => First name Hello +// => Last name World +``` + +#### Nested spreading + +```ts +const $targetA = createStore(''); +const $targetB = createStore(0); +const $targetC = createStore(false); + +const trigger = spread({ + first: $targetA, + second: spread({ + foo: $targetB, + bar: $targetC, + }), +}); + +$targetA.watch((payload) => console.log('targetA', payload)); +$targetB.watch((payload) => console.log('targetB', payload)); +$targetC.watch((payload) => console.log('targetC', payload)); + +trigger({ + first: 'Hello', + second: { + foo: 200, + bar: true, + }, +}); +// => targetA Hello +// => targetB 200 +// => targetC true +``` + ## `spread({ source, targets })` ### Motivation @@ -35,7 +130,7 @@ spread({ source, targets: { field: target, ... } }) ```ts import { createStore, createEvent } from 'effector'; -import { spread } from 'patronum/spread'; +import { spread } from 'patronum'; const $first = createStore(''); const $last = createStore(''); @@ -96,8 +191,7 @@ save(null); ### Motivation -This overload creates event `source` that should be triggered and returns it. -It is useful to pass `source` immediately to another method as argument. +This overload recieves `targets` as an object. May be useful for additional clarity, but it's longer to write ### Formulae @@ -116,77 +210,4 @@ source = spread({ targets: { field: target, ... } }) ### Returns -- `source` `(Event` | `Store` | `Effect)` — Source unit, data passed to it should be an object with fields from `targets` - -### Example - -#### Conditionally save value to stores - -```ts -import { createStore, createEvent, sample } from 'effector'; -import { spread } from 'patronum/spread'; - -const $first = createStore(''); -const $last = createStore(''); - -const formReceived = createEvent(); - -sample({ - source: formReceived, - filter: (form) => form.first.length > 0 && form.last.length > 0, - target: spread({ - targets: { - first: $first, - last: $last, - }, - }), -}); - -$first.watch((first) => console.log('First name', first)); -$last.watch((last) => console.log('Last name', last)); - -formReceived({ first: '', last: '' }); -// Nothing, because filter returned true - -formReceived({ first: 'Hello', last: 'World' }); -// => First name Hello -// => Last name World -``` - -#### Nested spreading - -```ts -const trigger = createEvent(); - -const $targetA = createStore(''); -const $targetB = createStore(0); -const $targetC = createStore(false); - -spread({ - source: trigger, - targets: { - first: $targetA, - second: spread({ - targets: { - foo: $targetB, - bar: $targetC, - }, - }), - }, -}); - -$targetA.watch((payload) => console.log('targetA', payload)); -$targetB.watch((payload) => console.log('targetB', payload)); -$targetC.watch((payload) => console.log('targetC', payload)); - -trigger({ - first: 'Hello', - second: { - foo: 200, - bar: true, - }, -}); -// => targetA Hello -// => targetB 200 -// => targetC true -``` +- `source` `(Event)` — Source event, data passed to it should be an object with fields from `targets` diff --git a/src/spread/spread.test.ts b/src/spread/spread.test.ts index a62a3a1e..b3fef538 100644 --- a/src/spread/spread.test.ts +++ b/src/spread/spread.test.ts @@ -133,23 +133,46 @@ describe('spread(targets)', () => { expect(fnA).toBeCalledWith('Hello'); expect(fnB).toBeCalledWith(200); }); - - test('event to stores', () => { + test('event to events (shorthand)', () => { const source = createEvent<{ first: string; second: number }>(); - const targetA = createStore(''); - const targetB = createStore(0); + const targetA = createEvent(); + const targetB = createEvent(); const fnA = jest.fn(); const fnB = jest.fn(); targetA.watch(fnA); targetB.watch(fnB); + sample({ + clock: source, + target: spread({ + first: targetA, + second: targetB, + }), + }); + + source({ first: 'Hello', second: 200 }); + + expect(fnA).toBeCalledWith('Hello'); + expect(fnB).toBeCalledWith(200); + }); + + test('event to stores', () => { + const source = createEvent<{ first: string; second: number }>(); + const $targetA = createStore(''); + const $targetB = createStore(0); + + const fnA = jest.fn(); + const fnB = jest.fn(); + $targetA.watch(fnA); + $targetB.watch(fnB); + sample({ clock: source, target: spread({ targets: { - first: targetA, - second: targetB, + first: $targetA, + second: $targetB, }, }), }); @@ -160,26 +183,50 @@ describe('spread(targets)', () => { expect(fnB).toBeCalledWith(200); }); + test('event to stores (shorthand)', () => { + const source = createEvent<{ first: string; second: number }>(); + const $targetA = createStore(''); + const $targetB = createStore(0); + + const fnA = jest.fn(); + const fnB = jest.fn(); + $targetA.watch(fnA); + $targetB.watch(fnB); + + sample({ + clock: source, + target: spread({ + first: $targetA, + second: $targetB, + }), + }); + + source({ first: 'Hello', second: 200 }); + + expect(fnA).toBeCalledWith('Hello'); + expect(fnB).toBeCalledWith(200); + }); + test('store to stores', () => { const change = createEvent<{ first: string; second: number }>(); const source = createStore({ first: 'hello', second: 200 }).on( change, (_, value) => value, ); - const targetA = createStore(''); - const targetB = createStore(0); + const $targetA = createStore(''); + const $targetB = createStore(0); const fnA = jest.fn(); const fnB = jest.fn(); - targetA.watch(fnA); - targetB.watch(fnB); + $targetA.watch(fnA); + $targetB.watch(fnB); sample({ clock: source, target: spread({ targets: { - first: targetA, - second: targetB, + first: $targetA, + second: $targetB, }, }), }); @@ -427,7 +474,6 @@ describe('invalid', () => { }, }); - // @ts-expect-error no argument source(); source(1); source(''); diff --git a/src/status/index.ts b/src/status/index.ts index 3b66ac7f..c0045b57 100644 --- a/src/status/index.ts +++ b/src/status/index.ts @@ -1,16 +1,31 @@ -import { createStore, Effect, Store } from 'effector'; +import { createStore, Effect, is, Store } from 'effector'; export type EffectState = 'initial' | 'pending' | 'done' | 'fail'; -export function status({ - effect, - defaultValue = 'initial', -}: { +export function status( + effect: Effect, +): Store; +export function status(params: { effect: Effect; defaultValue?: EffectState; -}): Store { +}): Store; +export function status( + params: + | { + effect: Effect; + defaultValue?: EffectState; + } + | Effect, +) { + const { effect, defaultValue = 'initial' } = is.effect(params) + ? { effect: params } + : params; const $status = createStore(defaultValue); + if (!is.effect(effect)) { + throw TypeError(`status: "effect" property is not an effect`); + } + $status .on(effect, () => 'pending') .on(effect.done, () => 'done') diff --git a/src/status/readme.md b/src/status/readme.md index bfa68087..6afb8450 100644 --- a/src/status/readme.md +++ b/src/status/readme.md @@ -14,6 +14,7 @@ It is useful to show correct state of process in UI. ## Formulae ```ts +$status = status(effect); $status = status({ effect, defaultValue }); ``` @@ -22,6 +23,11 @@ $status = status({ effect, defaultValue }); - When `effect` is succeeded, set `done` status. - When `effect` is failed, set `fail` status. +:::note +Shorthand `status(effect)` is available since patronum 2.1.0 +Use `status({ effect })` with patronum < 2.1.0 +::: + ## Arguments 1. `effect` `(Effect)` — any effect, that you need to watch status @@ -76,9 +82,7 @@ import { createEvent, createEffect } from 'effector'; import { status } from 'patronum/status'; const reset = createEvent(); -const effect = createEffect( - () => new Promise((resolve) => setTimeout(resolve, 100)), -); +const effect = createEffect(() => new Promise((resolve) => setTimeout(resolve, 100))); const $status = status({ effect }); $status.reset(reset); diff --git a/src/status/status.test.ts b/src/status/status.test.ts index 37dc1f1f..2a4c3f85 100644 --- a/src/status/status.test.ts +++ b/src/status/status.test.ts @@ -3,8 +3,8 @@ import { argumentHistory, waitFor } from '../../test-library'; import { status } from './index'; test('change status: initial -> pending -> done', async () => { - const effect = createEffect({ - handler: () => new Promise((resolve) => setTimeout(resolve, 100)), + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 100)); }); const $status = status({ effect }); const fn = jest.fn(); @@ -34,6 +34,38 @@ test('change status: initial -> pending -> done', async () => { `); }); +test('change status: initial -> pending -> done (shorthand)', async () => { + const effect = createEffect(() => { + return new Promise((resolve) => setTimeout(resolve, 100)); + }); + const $status = status(effect); + const fn = jest.fn(); + + $status.watch(fn); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + "initial", + ] + `); + + effect(); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + "initial", + "pending", + ] + `); + + await waitFor(effect.finally); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + [ + "initial", + "pending", + "done", + ] + `); +}); + test('change status: initial -> pending -> fail', async () => { const effect = createEffect({ handler: () => new Promise((_, reject) => setTimeout(reject, 100)), diff --git a/src/throttle/index.ts b/src/throttle/index.ts index 687a0073..042189b8 100644 --- a/src/throttle/index.ts +++ b/src/throttle/index.ts @@ -12,6 +12,10 @@ import { type EventAsReturnType = any extends Payload ? Event : never; +export function throttle( + source: Unit, + timeout: number | Store, +): EventAsReturnType; export function throttle(_: { source: Unit; timeout: number | Store; @@ -23,16 +27,21 @@ export function throttle>(_: { target: Target; name?: string; }): Target; -export function throttle({ - source, - timeout, - target = createEvent(), -}: { - source: Unit; - timeout: number | Store; - name?: string; - target?: UnitTargetable; -}): EventAsReturnType { +export function throttle( + ...args: + | [ + { + source: Unit; + timeout: number | Store; + name?: string; + target?: UnitTargetable; + }, + ] + | [Unit, number | Store] +): EventAsReturnType { + const argsShape = + args.length === 2 ? { source: args[0], timeout: args[1] } : args[0]; + const { source, timeout, target = createEvent() } = argsShape; if (!is.unit(source)) throw new TypeError('source must be unit from effector'); const $timeout = toStoreNumber(timeout); diff --git a/src/throttle/readme.md b/src/throttle/readme.md index 0d51d1fa..db611b93 100644 --- a/src/throttle/readme.md +++ b/src/throttle/readme.md @@ -6,17 +6,22 @@ import { throttle } from 'patronum'; import { throttle } from 'patronum/throttle'; ``` -## `target = throttle({ source, timeout })` +## `target = throttle(source, timeout)` ### Motivation This method allows to trigger `target` in equal timeouts regardless of source trigger frequency. It is useful in live search in UI. +:::note since +patronum 2.1.0 +Use `throttle({ source, timeout })` with patronum < 2.1.0 +::: + ### Formulae ```ts -target = throttle({ source, timeout }); +target = throttle(source, timeout); ``` - Triggers `target` at most once per `timeout` after triggering `source` @@ -24,7 +29,7 @@ target = throttle({ source, timeout }); ### Arguments 1. `source` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Source unit, data from this unit used by the `target` -1. `timeout` ([_`number`_] | `Store`) — time to wait before trigger `target` after last trigger or `source` trigger +2. `timeout` ([_`number`_] | `Store`) — time to wait before trigger `target` after last trigger or `source` trigger ### Returns @@ -43,14 +48,11 @@ const someHappened = createEvent(); Create throttled event from it: ```ts -import { throttle } from 'patronum/throttle'; +import { throttle } from 'patronum'; const THROTTLE_TIMEOUT_IN_MS = 200; -const throttled = throttle({ - source: someHappened, - timeout: THROTTLE_TIMEOUT_IN_MS, -}); +const throttled = throttle(someHappened, THROTTLE_TIMEOUT_IN_MS); ``` When you call `someHappened` it will make throttled call the `throttled` event: @@ -73,16 +75,13 @@ Also you can use `Effect` and `Store` as trigger. `throttle` always returns `Eve ```ts const event = createEvent(); -const throttledEvent: Event = throttle({ source: event, timeout: 100 }); +const throttledEvent: Event = throttle(event, 100); const fx = createEffect(); -const throttledEffect: Event = throttle({ source: fx, timeout: 100 }); +const throttledEffect: Event = throttle(fx, 100); const $store = createStore(0); -const throttledStore: Event = throttle({ - source: $store, - timeout: 100, -}); +const throttledStore: Event = throttle($store, 100); ``` ## `throttle({ source, timeout, target })` @@ -103,8 +102,8 @@ throttle({ source, timeout, target }); ### Arguments 1. `source` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Source unit, data from this unit used by the `target` -1. `timeout` ([_`number`_] | `Store`) — time to wait before trigger `target` after last trigger or `source` trigger -1. `target` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Target unit, that triggered each time after triggering `source` with argument from `source` +2. `timeout` ([_`number`_] | `Store`) — time to wait before trigger `target` after last trigger or `source` trigger +3. `target` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Target unit, that triggered each time after triggering `source` with argument from `source` ### Returns @@ -136,7 +135,7 @@ The new timeout will be used after the previous is over (if there was a delayed ```ts import { createEvent } from 'effector'; -import { throttle } from 'patronum/throttle'; +import { throttle } from 'patronum'; const someHappened = createEvent(); const changeTimeout = createEvent(); @@ -165,6 +164,84 @@ setTimeout(() => { }, 500); ``` +## `target = throttle({ source, timeout })` + +### Motivation + +This overload recieves `source` and `timeout` as an object. May be useful for additional clarity, but it's longer to write + +### Formulae + +```ts +target = throttle({ source, timeout }); +``` + +- Triggers `target` at most once per `timeout` after triggering `source` + +### 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 or `source` trigger + +### Returns + +- `target` ([_`Event`_]) — new event, that triggered each time after triggering `source` with argument from `source` + +### Usage + +Create event that should be throttled: + +```ts +import { createEvent } from 'effector'; + +const someHappened = createEvent(); +``` + +Create throttled event from it: + +```ts +import { throttle } from 'patronum'; + +const THROTTLE_TIMEOUT_IN_MS = 200; + +const throttled = throttle({ + source: someHappened, + timeout: THROTTLE_TIMEOUT_IN_MS, +}); +``` + +When you call `someHappened` it will make throttled call the `throttled` event: + +```ts +throttled.watch((payload) => { + console.info('someHappened now', payload); +}); + +someHappened(1); +someHappened(2); +someHappened(3); +someHappened(4); + +// after 200 ms after first call +// => someHappened now 4 +``` + +Also you can use `Effect` and `Store` as trigger. `throttle` always returns `Event`: + +```ts +const event = createEvent(); +const throttledEvent: Event = throttle({ source: event, timeout: 100 }); + +const fx = createEffect(); +const throttledEffect: Event = throttle({ source: fx, timeout: 100 }); + +const $store = createStore(0); +const throttledStore: Event = throttle({ + source: $store, + timeout: 100, +}); +``` + [_`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.test.ts b/src/throttle/throttle.test.ts index 8f5e5bf2..887da512 100644 --- a/src/throttle/throttle.test.ts +++ b/src/throttle/throttle.test.ts @@ -23,6 +23,25 @@ describe('event', () => { expect(watcher).toBeCalledTimes(1); }); + test('throttle event (shorthand)', async () => { + const watcher = jest.fn(); + + const trigger = createEvent(); + const throttled = throttle(trigger, 40); + + throttled.watch(watcher); + + trigger(); + trigger(); + trigger(); + + expect(watcher).not.toBeCalled(); + + await wait(42); + + expect(watcher).toBeCalledTimes(1); + }); + test('throttled event with wait', async () => { const watcher = jest.fn(); diff --git a/src/time/index.ts b/src/time/index.ts index 200b6c18..d0291189 100644 --- a/src/time/index.ts +++ b/src/time/index.ts @@ -1,18 +1,26 @@ -import { createEffect, Unit, restore, sample, Store } from 'effector'; +import { createEffect, Unit, restore, sample, Store, is } from 'effector'; const defaultNow =