From 520a790a1f7a75c882ad80c030dc7d60b31180e4 Mon Sep 17 00:00:00 2001 From: Edward Gigolaev <49155506+movpushmov@users.noreply.github.com> Date: Tue, 23 Apr 2024 02:07:11 +0300 Subject: [PATCH 1/6] feat: expose timers api --- src/debounce/debounce.fork.test.ts | 43 ++++++++++++++-- src/debounce/index.ts | 57 ++++++++++++++------- src/debounce/readme.md | 22 ++++++++ src/delay/delay.fork.test.ts | 45 ++++++++++++++++- src/delay/index.ts | 37 ++++++++------ src/delay/readme.md | 21 ++++++++ src/interval/index.ts | 80 ++++++++++++++++++++---------- src/interval/interval.fork.test.ts | 53 ++++++++++++++++++-- src/interval/readme.md | 35 +++++++++++++ src/throttle/index.ts | 28 +++++++---- src/throttle/readme.md | 18 +++++++ src/throttle/throttle.fork.test.ts | 36 +++++++++++++- src/time/index.ts | 31 +++++++++--- src/time/readme.md | 17 +++++++ src/time/time.fork.test.ts | 31 +++++++++++- src/time/time.test.ts | 1 + 16 files changed, 470 insertions(+), 85 deletions(-) diff --git a/src/debounce/debounce.fork.test.ts b/src/debounce/debounce.fork.test.ts index 130d076f..8de77172 100644 --- a/src/debounce/debounce.fork.test.ts +++ b/src/debounce/debounce.fork.test.ts @@ -7,11 +7,11 @@ import { createEvent, createStore, sample, - createWatch, -} from 'effector'; + createWatch, createEffect, +} from 'effector' import { wait, watch } from '../../test-library'; -import { debounce } from './index'; +import { debounce, DebounceTimerFxProps } from './index' test('debounce works in forked scope', async () => { const app = createDomain(); @@ -232,3 +232,40 @@ describe('edge cases', () => { expect(triggerListener).toBeCalledTimes(0); }) }); + +test('exposed timers api', async () => { + const timerFx = createEffect(({ timeoutId, rejectPromise, saveCancel, timeout }: DebounceTimerFxProps) => { + if (timeoutId) clearTimeout(timeoutId); + if (rejectPromise) rejectPromise(); + return new Promise((resolve, reject) => { + saveCancel([setTimeout(resolve, timeout / 2), reject]); + }); + }); + + const scope = fork({ + handlers: [ + [debounce.timerFx, timerFx], + ] + }); + + const mockedFn = jest.fn(); + + const clock = createEvent(); + const tick = debounce(clock, 50); + + createWatch({ + unit: tick, + fn: mockedFn, + scope, + }); + + allSettled(clock, { scope }); + + await wait(20); + + expect(mockedFn).not.toBeCalled(); + + await wait(5); + + expect(mockedFn).toBeCalled(); +}); diff --git a/src/debounce/index.ts b/src/debounce/index.ts index f0a21a82..d52d0981 100644 --- a/src/debounce/index.ts +++ b/src/debounce/index.ts @@ -9,18 +9,34 @@ import { merge, UnitTargetable, EventAsReturnType, -} from 'effector'; + createEffect, EventCallable +} from 'effector' + +export type DebounceTimerFxProps = { + timeoutId?: NodeJS.Timeout; + rejectPromise?: () => void; + saveCancel: EventCallable<[NodeJS.Timeout, () => void]>; + timeout: number; +}; + +const timerFx = createEffect(({ timeoutId, rejectPromise, saveCancel, timeout }: DebounceTimerFxProps) => { + if (timeoutId) clearTimeout(timeoutId); + if (rejectPromise) rejectPromise(); + return new Promise((resolve, reject) => { + saveCancel([setTimeout(resolve, timeout), reject]); + }); +}); -export function debounce( +export function _debounce( source: Unit, timeout: number | Store, ): EventAsReturnType; -export function debounce(_: { +export function _debounce(_: { source: Unit; timeout: number | Store; name?: string; }): EventAsReturnType; -export function debounce< +export function _debounce< T, Target extends UnitTargetable | UnitTargetable, >(_: { @@ -29,7 +45,7 @@ export function debounce< target: Target; name?: string; }): Target; -export function debounce( +export function _debounce( ...args: | [ { @@ -57,18 +73,19 @@ export function debounce( const tick = (target as UnitTargetable) ?? createEvent(); - const timerFx = attach({ + const innerTimerFx = attach({ name: name || `debounce(${(source as any)?.shortName || source.kind}) effect`, source: $canceller, - effect([timeoutId, rejectPromise], timeout: number) { - if (timeoutId) clearTimeout(timeoutId); - if (rejectPromise) rejectPromise(); - return new Promise((resolve, reject) => { - saveCancel([setTimeout(resolve, timeout), reject]); - }); - }, + mapParams: (timeout: number, [timeoutId, rejectPromise]) => ({ + timeout, + timeoutId, + rejectPromise, + saveCancel + }), + effect: timerFx, }); - $canceller.reset(timerFx.done); + + $canceller.reset(innerTimerFx.done); // It's ok - nothing will ever start unless source is triggered const $payload = createStore([], { serialize: 'ignore', skipVoid: false }).on( @@ -88,7 +105,7 @@ export function debounce( // debounce timeout should be restarted on timeout change $timeout, // debounce timeout can be restarted in later ticks - timerFx, + innerTimerFx, ], () => true, ); @@ -96,7 +113,7 @@ export function debounce( const requestTick = merge([ source, // debounce timeout is restarted on timeout change - sample({ clock: $timeout, filter: timerFx.pending }), + sample({ clock: $timeout, filter: innerTimerFx.pending }), ]); sample({ @@ -108,12 +125,12 @@ export function debounce( sample({ source: $timeout, clock: triggerTick, - target: timerFx, + target: innerTimerFx, }); sample({ source: $payload, - clock: timerFx.done, + clock: innerTimerFx.done, fn: ([payload]) => payload, target: tick, }); @@ -121,6 +138,10 @@ export function debounce( return tick as any; } +export const debounce = Object.assign(_debounce, { + timerFx +}); + function toStoreNumber(value: number | Store | unknown): Store { if (is.store(value)) return value; if (typeof value === 'number') { diff --git a/src/debounce/readme.md b/src/debounce/readme.md index 4182cfb9..e4bfd6a1 100644 --- a/src/debounce/readme.md +++ b/src/debounce/readme.md @@ -197,3 +197,25 @@ someHappened(4); // someHappened now 4 ``` + +### [Tests] Exposed timers API example + +```ts +const timerFx = createEffect(({ timeoutId, rejectPromise, saveCancel, timeout }: DebounceTimerFxProps) => { + if (timeoutId) myClearTimeout(timeoutId); + if (rejectPromise) rejectPromise(); + return new Promise((resolve, reject) => { + saveCancel([mySetTimeout(resolve, timeout), reject]); + }); +}); + +const scope = fork({ + handlers: [[debounce.timerFx, timerFx]], +}); + +const clock = createEvent(); +const tick = debounce(clock, 200); + +// important! call from scope +allSettled(clock, { scope }); +``` diff --git a/src/delay/delay.fork.test.ts b/src/delay/delay.fork.test.ts index d5f62c8b..ccbe709b 100644 --- a/src/delay/delay.fork.test.ts +++ b/src/delay/delay.fork.test.ts @@ -1,7 +1,14 @@ import 'regenerator-runtime/runtime'; -import { createDomain, fork, serialize, allSettled } from 'effector'; +import { + createDomain, + fork, + serialize, + allSettled, + createEffect, createEvent, createWatch, UnitValue +} from 'effector' -import { delay } from './index'; +import { delay, DelayTimerFxProps } from './index' +import { wait } from '../../test-library' test('throttle works in forked scope', async () => { const app = createDomain(); @@ -127,3 +134,37 @@ test('throttle do not affect original store value', async () => { expect($counter.getState()).toMatchInlineSnapshot(`0`); }); + +test('exposed timers api', async () => { + const timerFx = createEffect>( + ({ payload, milliseconds }) => + new Promise((resolve) => { + setTimeout(resolve, milliseconds / 2, payload); + }), + ) + + const scope = fork({ + handlers: [[delay.timerFx, timerFx]], + }); + + const mockedFn = jest.fn(); + + const clock = createEvent(); + const tick = delay(clock, 50); + + createWatch({ + unit: tick, + fn: mockedFn, + scope, + }); + + allSettled(clock, { scope }); + + await wait(20); + + expect(mockedFn).not.toBeCalled(); + + await wait(5); + + expect(mockedFn).toBeCalled(); +}); diff --git a/src/delay/index.ts b/src/delay/index.ts index 543a4e31..37ea37fb 100644 --- a/src/delay/index.ts +++ b/src/delay/index.ts @@ -11,27 +11,36 @@ import { MultiTarget, UnitValue, UnitTargetable, + attach, } from 'effector'; type TimeoutType = ((payload: Payload) => number) | Store | number; +export type DelayTimerFxProps = { payload: UnitValue; milliseconds: number }; -export function delay>( +const timerFx = createEffect>( + ({ payload, milliseconds }) => + new Promise((resolve) => { + setTimeout(resolve, milliseconds, payload); + }), +) + +export function _delay>( source: Source, timeout: TimeoutType>, ): EventAsReturnType>; -export function delay, Target extends TargetType>(config: { +export function _delay, Target extends TargetType>(config: { source: Source; timeout: TimeoutType>; target: MultiTarget>; }): Target; -export function delay>(config: { +export function _delay>(config: { source: Source; timeout: TimeoutType>; }): EventAsReturnType>; -export function delay< +export function _delay< Source extends Unit, Target extends TargetType = TargetType, >( @@ -57,15 +66,9 @@ export function delay< const ms = validateTimeout(timeout); - const timerFx = createEffect< - { payload: UnitValue; milliseconds: number }, - UnitValue - >( - ({ payload, milliseconds }) => - new Promise((resolve) => { - setTimeout(resolve, milliseconds, payload); - }), - ); + const innerTimerFx = attach({ + effect: timerFx + }); sample({ // ms can be Store | number @@ -77,14 +80,18 @@ export function delay< milliseconds: typeof milliseconds === 'function' ? milliseconds(payload) : milliseconds, }), - target: timerFx, + target: innerTimerFx, }); - sample({ clock: timerFx.doneData, target: targets as UnitTargetable[] }); + sample({ clock: innerTimerFx.doneData, target: targets as UnitTargetable[] }); return target as any; } +export const delay = Object.assign(_delay, { + timerFx +}); + function validateTimeout( timeout: number | ((_: T) => number) | Store | unknown, ) { diff --git a/src/delay/readme.md b/src/delay/readme.md index 70ac7a8d..c6f8c5dd 100644 --- a/src/delay/readme.md +++ b/src/delay/readme.md @@ -284,3 +284,24 @@ update('Hello'); // after 500ms // => log Hello ``` + +### [Tests] Exposed timers API example + +```ts +const timerFx = createEffect>( + ({ payload, milliseconds }) => + new Promise((resolve) => { + mySetTimeout(resolve, milliseconds, payload); + }), +) + +const scope = fork({ + handlers: [[delay.timerFx, timerFx]], +}); + +const clock = createEvent(); +const tick = delay(clock, 200); + +// important! call from scope +allSettled(clock, { scope }); +``` diff --git a/src/interval/index.ts b/src/interval/index.ts index 6460661c..c202143e 100644 --- a/src/interval/index.ts +++ b/src/interval/index.ts @@ -7,9 +7,42 @@ import { sample, attach, is, -} from 'effector'; + createEffect +} from 'effector' -export function interval(config: { +type SaveTimeoutEventProps = { + timeoutId: NodeJS.Timeout; + reject: () => void; +}; + +export type IntervalTimeoutFxProps = { + timeout: number; + running: boolean; + saveTimeout: EventCallable; +}; + +export type IntervalCleanupFxProps = { + timeoutId: NodeJS.Timeout | null; + rejecter: () => void; +}; + +const timeoutFx = createEffect(({ timeout, running, saveTimeout }: IntervalTimeoutFxProps) => { + if (!running) { + return Promise.reject(); + } + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, timeout); + saveTimeout({ timeoutId, reject }); + }); +}) + +const cleanupFx = createEffect(({ rejecter, timeoutId }: IntervalCleanupFxProps) => { + rejecter(); + if (timeoutId) clearTimeout(timeoutId); +}); + +function _interval(config: { timeout: number | Store; start: Event; stop?: Event; @@ -17,13 +50,13 @@ export function interval(config: { trailing?: boolean; }): { tick: Event; isRunning: Store }; -export function interval(config: { +function _interval(config: { timeout: number | Store; leading?: boolean; trailing?: boolean; }): TriggerProtocol; -export function interval({ +function _interval({ timeout, start, stop, @@ -37,6 +70,7 @@ export function interval({ trailing?: boolean; }): { tick: Event; isRunning: Store } & TriggerProtocol { const setup = createEvent(); + if (start) { sample({ clock: start, @@ -45,6 +79,7 @@ export function interval({ } const teardown = createEvent(); + if (stop) { sample({ clock: stop, @@ -62,6 +97,7 @@ export function interval({ timeoutId: NodeJS.Timeout; reject: () => void; }>(); + const $timeoutId = createStore(null).on( saveTimeout, (_, { timeoutId }) => timeoutId, @@ -72,33 +108,22 @@ export function interval({ (_, { reject }) => reject, ); - const timeoutFx = attach({ + const innerTimeoutFx = attach({ source: { timeout: $timeout, running: $isRunning }, - effect: ({ timeout, running }) => { - if (!running) { - return Promise.reject(); - } - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(resolve, timeout); - saveTimeout({ timeoutId, reject }); - }); - }, + mapParams: (_, source) => ({ saveTimeout, ...source }), + effect: timeoutFx, }); - const cleanupFx = attach({ + const innerCleanupFx = attach({ source: { timeoutId: $timeoutId, rejecter: $rejecter }, - effect: ({ timeoutId, rejecter }) => { - rejecter(); - if (timeoutId) clearTimeout(timeoutId); - }, + effect: cleanupFx, }); sample({ clock: setup, source: $timeout, filter: $notRunning, - target: timeoutFx, + target: innerTimeoutFx, }); if (leading) { @@ -113,14 +138,14 @@ export function interval({ }); sample({ - clock: timeoutFx.done, + clock: innerTimeoutFx.done, source: $timeout, filter: $isRunning, - target: timeoutFx, + target: innerTimeoutFx, }); sample({ - clock: timeoutFx.done, + clock: innerTimeoutFx.done, filter: $isRunning, target: tick.prepend(() => { /* to be sure, nothing passed to tick */ @@ -138,7 +163,7 @@ export function interval({ sample({ clock: teardown, - target: cleanupFx, + target: innerCleanupFx, }); return { @@ -152,6 +177,11 @@ export function interval({ }; } +export const interval = Object.assign(_interval, { + timeoutFx, + cleanupFx +}); + function toStoreNumber(value: number | Store | unknown): Store { if (is.store(value)) return value; if (typeof value === 'number') { diff --git a/src/interval/interval.fork.test.ts b/src/interval/interval.fork.test.ts index 7123cd36..ea18eb20 100644 --- a/src/interval/interval.fork.test.ts +++ b/src/interval/interval.fork.test.ts @@ -4,10 +4,10 @@ import { fork, createStore, sample, - createWatch, -} from 'effector'; + createWatch, createEffect, +} from 'effector' import { argumentHistory, wait, watch } from '../../test-library'; -import { interval } from '.'; +import { interval, IntervalCleanupFxProps, IntervalTimeoutFxProps } from '.' test('works in forked scope', async () => { const start = createEvent(); @@ -130,3 +130,50 @@ describe('@@trigger', () => { unwatch(); }); }); + +test('exposed timers api', async () => { + const timeoutFx = createEffect(({ timeout, running, saveTimeout }: IntervalTimeoutFxProps) => { + if (!running) { + return Promise.reject(); + } + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, timeout / 2); + saveTimeout({ timeoutId, reject }); + }); + }) + + const cleanupFx = createEffect(({ rejecter, timeoutId }: IntervalCleanupFxProps) => { + rejecter(); + if (timeoutId) clearTimeout(timeoutId); + }); + + const scope = fork({ + handlers: [ + [interval.timeoutFx, timeoutFx], + [interval.cleanupFx, cleanupFx] + ], + }); + + const start = createEvent(); + const stop = createEvent(); + + const { tick } = interval({ start, stop, timeout: 50 }); + + const mockedFn = jest.fn(); + createWatch({ + unit: tick, + fn: mockedFn, + scope, + }); + + allSettled(start, { scope }); + + await wait(20); + + expect(mockedFn).not.toBeCalled(); + + await wait(5); + + expect(mockedFn).toBeCalled(); +}); diff --git a/src/interval/readme.md b/src/interval/readme.md index d5ea42c4..31871539 100644 --- a/src/interval/readme.md +++ b/src/interval/readme.md @@ -102,3 +102,38 @@ keepFresh(someQuery, { triggers: [interval({ timeout: 1000 })], }); ``` + +### [Tests] Exposed timers API example + +```ts +const timeoutFx = createEffect(({ timeout, running, saveTimeout }: IntervalTimeoutFxProps) => { + if (!running) { + return Promise.reject(); + } + + return new Promise((resolve, reject) => { + const timeoutId = mySetTimeout(resolve, timeout); + saveTimeout({ timeoutId, reject }); + }); +}) + +const cleanupFx = createEffect(({ rejecter, timeoutId }: IntervalCleanupFxProps) => { + rejecter(); + if (timeoutId) myClearTimeout(timeoutId); +}); + +const scope = fork({ + handlers: [ + [interval.timeoutFx, timeoutFx], + [interval.cleanupFx, cleanupFx] + ], +}); + +const start = createEvent(); +const stop = createEvent(); + +const { tick } = interval({ start, stop, timeout: 1000 }); + +// important! call from scope +allSettled(start, { scope }); +``` diff --git a/src/throttle/index.ts b/src/throttle/index.ts index 042189b8..31f2263a 100644 --- a/src/throttle/index.ts +++ b/src/throttle/index.ts @@ -1,4 +1,5 @@ import { + attach, createEffect, createEvent, createStore, @@ -8,26 +9,30 @@ import { Store, Unit, UnitTargetable, -} from 'effector'; +} from 'effector' type EventAsReturnType = any extends Payload ? Event : never; -export function throttle( +const timerFx = createEffect({ + handler: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)), +}); + +export function _throttle( source: Unit, timeout: number | Store, ): EventAsReturnType; -export function throttle(_: { +export function _throttle(_: { source: Unit; timeout: number | Store; name?: string; }): EventAsReturnType; -export function throttle>(_: { +export function _throttle>(_: { source: Unit; timeout: number | Store; target: Target; name?: string; }): Target; -export function throttle( +export function _throttle( ...args: | [ { @@ -46,9 +51,10 @@ export function throttle( const $timeout = toStoreNumber(timeout); - const timerFx = createEffect({ + const innerTimerFx = attach({ name: `throttle(${(source as Event).shortName || source.kind}) effect`, - handler: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)), + mapParams: (params: number) => params, + effect: timerFx, }); // It's ok - nothing will ever start unless source is triggered @@ -72,18 +78,22 @@ export function throttle( sample({ source: $timeout, clock: triggerTick as Unit, - target: timerFx, + target: innerTimerFx, }); sample({ source: $payload, - clock: timerFx.done, + clock: innerTimerFx.done, target, }); return target as any; } +export const throttle = Object.assign(_throttle, { + timerFx, +}); + function toStoreNumber(value: number | Store | unknown): Store { if (is.store(value)) return value; if (typeof value === 'number') { diff --git a/src/throttle/readme.md b/src/throttle/readme.md index 3211ae77..d2840d99 100644 --- a/src/throttle/readme.md +++ b/src/throttle/readme.md @@ -242,6 +242,24 @@ const throttledStore: Event = throttle({ }); ``` +### [Tests] Exposed timers API example + +```ts +const timerFx = createEffect({ + handler: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)), +}); + +const scope = fork({ + handlers: [[throttle.timerFx, timerFx]], +}); + +const clock = createEvent(); +const tick = throttle(clock, 200); + +// important! call from scope +allSettled(clock, { scope }); +``` + [_`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 7d43bcc6..d78639c3 100644 --- a/src/throttle/throttle.fork.test.ts +++ b/src/throttle/throttle.fork.test.ts @@ -6,8 +6,8 @@ import { allSettled, createEvent, createStore, - sample, -} from 'effector'; + sample, createEffect, createWatch, +} from 'effector' import { throttle } from './index'; import { wait } from '../../test-library'; @@ -157,3 +157,35 @@ describe('edge cases', () => { expect(listener).toBeCalledWith('two'); }); }) + +test('exposed timers api', async () => { + const timerFx = createEffect({ + handler: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout / 2)), + }); + + const scope = fork({ + handlers: [[throttle.timerFx, timerFx]], + }); + + const clock = createEvent(); + const tick = throttle(clock, 50); + + const mockedFn = jest.fn(); + + createWatch({ + unit: tick, + fn: mockedFn, + scope, + }); + + allSettled(clock, { scope }); + + + await wait(20); + + expect(mockedFn).not.toBeCalled(); + + await wait(5); + + expect(mockedFn).toBeCalled(); +}) diff --git a/src/time/index.ts b/src/time/index.ts index d0291189..4e5e5be7 100644 --- a/src/time/index.ts +++ b/src/time/index.ts @@ -1,16 +1,26 @@ -import { createEffect, Unit, restore, sample, Store, is } from 'effector'; +import { + createEffect, + Unit, + restore, + sample, + Store, + is, + attach, Effect +} from 'effector' const defaultNow =