Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose timers API #327

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions src/debounce/debounce.fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -186,19 +186,23 @@ describe('edge cases', () => {
test('does not call target twice for sample chain doubles', async () => {
const trigger = createEvent();

const scope = fork();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope should be created AFTER the model initialization.

const db = debounce({ source: trigger, timeout: 100 });

const listener = jest.fn();
db.watch(listener);

createWatch({
unit: db,
fn: listener,
scope,
})
Comment on lines +194 to +198
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved just before await allSettled(start, { scope })


const start = createEvent();
const secondTrigger = createEvent();

sample({ clock: start, fn: () => 'one', target: [secondTrigger, trigger] });
sample({ clock: secondTrigger, fn: () => 'two', target: [trigger] });

const scope = fork();

await allSettled(start, { scope });

expect(listener).toBeCalledTimes(1);
Expand Down Expand Up @@ -232,3 +236,44 @@ describe('edge cases', () => {
expect(triggerListener).toBeCalledTimes(0);
})
});

test('exposed timers api', async () => {
const timerFx = createEffect(({ canceller, timeout }: DebounceTimerFxProps) => {
const { timeoutId, rejectPromise } = canceller;

if (timeoutId) clearTimeout(timeoutId);
if (rejectPromise) rejectPromise();

return new Promise((resolve, reject) => {
canceller.timeoutId = setTimeout(resolve, timeout / 2);
canceller.rejectPromise = 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();
});
69 changes: 48 additions & 21 deletions src/debounce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,38 @@ import {
merge,
UnitTargetable,
EventAsReturnType,
createEffect
} from 'effector';

export function debounce<T>(
type DebounceCanceller = { timeoutId?: NodeJS.Timeout; rejectPromise?: () => void; };

export type DebounceTimerFxProps = {
canceller: DebounceCanceller;
timeout: number;
};

const timerFx = createEffect(({ canceller, timeout }: DebounceTimerFxProps) => {
const { timeoutId, rejectPromise } = canceller;

if (timeoutId) clearTimeout(timeoutId);
if (rejectPromise) rejectPromise();

return new Promise((resolve, reject) => {
canceller.timeoutId = setTimeout(resolve, timeout);
canceller.rejectPromise = reject;
});
});

export function _debounce<T>(
source: Unit<T>,
timeout: number | Store<number>,
): EventAsReturnType<T>;
export function debounce<T>(_: {
export function _debounce<T>(_: {
source: Unit<T>;
timeout: number | Store<number>;
name?: string;
}): EventAsReturnType<T>;
export function debounce<
export function _debounce<
T,
Target extends UnitTargetable<T> | UnitTargetable<void>,
>(_: {
Expand All @@ -29,7 +49,7 @@ export function debounce<
target: Target;
name?: string;
}): Target;
export function debounce<T>(
export function _debounce<T>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this export of _debounce is necessary?

...args:
| [
{
Expand All @@ -50,25 +70,20 @@ export function debounce<T>(

const $timeout = toStoreNumber(timeout);

const saveCancel = createEvent<[NodeJS.Timeout, () => void]>();
const $canceller = createStore<[NodeJS.Timeout, () => void] | []>([], {
const $canceller = createStore<DebounceCanceller | null>(null, {
serialize: 'ignore',
}).on(saveCancel, (_, payload) => payload);
});

const tick = (target as UnitTargetable<T>) ?? 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]);
});
},
source: $canceller as Store<DebounceCanceller>,
mapParams: (timeout: number, source: DebounceCanceller) => ({ canceller: source, timeout }),
effect: timerFx,
});
$canceller.reset(timerFx.done);

$canceller.reset(innerTimerFx.done);

// It's ok - nothing will ever start unless source is triggered
const $payload = createStore<T[]>([], { serialize: 'ignore', skipVoid: false }).on(
Expand All @@ -88,15 +103,15 @@ export function debounce<T>(
// debounce timeout should be restarted on timeout change
$timeout,
// debounce timeout can be restarted in later ticks
timerFx,
innerTimerFx,
],
() => true,
);

const requestTick = merge([
source,
// debounce timeout is restarted on timeout change
sample({ clock: $timeout, filter: timerFx.pending }),
sample({ clock: $timeout, filter: innerTimerFx.pending }),
]);

sample({
Expand All @@ -106,21 +121,33 @@ export function debounce<T>(
});

sample({
clock: triggerTick,
source: $timeout,
fn: (timeout) => timeout,
target: innerTimerFx,
});

sample({
clock: triggerTick,
target: timerFx,
source: $canceller,
fn: (canceller) => canceller ?? {},
target: $canceller,
});

sample({
source: $payload,
clock: timerFx.done,
clock: innerTimerFx.done,
fn: ([payload]) => payload,
target: tick,
});

return tick as any;
}

export const debounce = Object.assign(_debounce, {
timerFx
});

function toStoreNumber(value: number | Store<number> | unknown): Store<number> {
if (is.store(value)) return value;
if (typeof value === 'number') {
Expand Down
29 changes: 29 additions & 0 deletions src/debounce/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,32 @@ someHappened(4);

// someHappened now 4
```

### [Tests] Exposed timers API example

```ts
/**
* `canceller` - is object, which contains previous timeout id and previous effect promise reject
*/
const timerFx = createEffect(({ canceller, timeout }: DebounceTimerFxProps) => {
const { timeoutId, rejectPromise } = canceller;

if (timeoutId) clearTimeout(timeoutId);
if (rejectPromise) rejectPromise();

return new Promise((resolve, reject) => {
canceller.timeoutId = setTimeout(resolve, timeout);
canceller.rejectPromise = reject;
});
});

const scope = fork({
handlers: [[debounce.timerFx, timerFx]],
});
Comment on lines +207 to +221
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const timerFx = createEffect(({ canceller, timeout }: DebounceTimerFxProps) => {
const { timeoutId, rejectPromise } = canceller;
if (timeoutId) clearTimeout(timeoutId);
if (rejectPromise) rejectPromise();
return new Promise((resolve, reject) => {
canceller.timeoutId = setTimeout(resolve, timeout);
canceller.rejectPromise = reject;
});
});
const scope = fork({
handlers: [[debounce.timerFx, timerFx]],
});
const handleTimer = ({ canceller, timeout }): DebounceTimerFxProps => {
const { timeoutId, rejectPromise } = canceller;
if (timeoutId) clearTimeout(timeoutId);
if (rejectPromise) rejectPromise();
return new Promise((resolve, reject) => {
canceller.timeoutId = setTimeout(resolve, timeout);
canceller.rejectPromise = reject;
});
});
const scope = fork({
handlers: [[debounce.timerFx, handleTimer]],
});

handler should be function


const clock = createEvent();
const tick = debounce(clock, 200);

// important! call from scope
allSettled(clock, { scope });
```
45 changes: 43 additions & 2 deletions src/delay/delay.fork.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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<DelayTimerFxProps, UnitValue<any>>(
({ 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();
});
37 changes: 22 additions & 15 deletions src/delay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,36 @@ import {
MultiTarget,
UnitValue,
UnitTargetable,
attach,
} from 'effector';

type TimeoutType<Payload> = ((payload: Payload) => number) | Store<number> | number;
export type DelayTimerFxProps = { payload: UnitValue<any>; milliseconds: number };

export function delay<Source extends Unit<any>>(
const timerFx = createEffect<DelayTimerFxProps, UnitValue<any>>(
({ payload, milliseconds }) =>
new Promise((resolve) => {
setTimeout(resolve, milliseconds, payload);
}),
)

export function _delay<Source extends Unit<any>>(
source: Source,
timeout: TimeoutType<UnitValue<Source>>,
): EventAsReturnType<UnitValue<Source>>;

export function delay<Source extends Unit<any>, Target extends TargetType>(config: {
export function _delay<Source extends Unit<any>, Target extends TargetType>(config: {
source: Source;
timeout: TimeoutType<UnitValue<Source>>;
target: MultiTarget<Target, UnitValue<Source>>;
}): Target;

export function delay<Source extends Unit<any>>(config: {
export function _delay<Source extends Unit<any>>(config: {
source: Source;
timeout: TimeoutType<UnitValue<Source>>;
}): EventAsReturnType<UnitValue<Source>>;

export function delay<
export function _delay<
Source extends Unit<any>,
Target extends TargetType = TargetType,
>(
Expand All @@ -57,15 +66,9 @@ export function delay<

const ms = validateTimeout(timeout);

const timerFx = createEffect<
{ payload: UnitValue<Source>; milliseconds: number },
UnitValue<Source>
>(
({ payload, milliseconds }) =>
new Promise((resolve) => {
setTimeout(resolve, milliseconds, payload);
}),
);
const innerTimerFx = attach({
effect: timerFx
});

sample({
// ms can be Store<number> | number
Expand All @@ -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<any>[] });
sample({ clock: innerTimerFx.doneData, target: targets as UnitTargetable<any>[] });

return target as any;
}

export const delay = Object.assign(_delay, {
timerFx
});

function validateTimeout<T>(
timeout: number | ((_: T) => number) | Store<number> | unknown,
) {
Expand Down
Loading
Loading