diff --git a/src/once/index.ts b/src/once/index.ts new file mode 100644 index 00000000..e529b70c --- /dev/null +++ b/src/once/index.ts @@ -0,0 +1,24 @@ +import { + Unit, + Store, + Event, + Effect, + sample, + createStore, + EventAsReturnType, +} from 'effector'; + +export function once( + unit: Event | Effect | Store, +): EventAsReturnType { + const $canTrigger = createStore(true); + + const trigger: Event = sample({ + clock: unit as Unit, + filter: $canTrigger, + }); + + $canTrigger.on(trigger, () => false); + + return sample({ clock: trigger }); +} diff --git a/src/once/once.fork.test.ts b/src/once/once.fork.test.ts new file mode 100644 index 00000000..4b2e6a39 --- /dev/null +++ b/src/once/once.fork.test.ts @@ -0,0 +1,19 @@ +import { allSettled, createEvent, fork, serialize } from 'effector'; +import { once } from './index'; + +it('persists state between scopes', async () => { + const fn = jest.fn(); + + const trigger = createEvent(); + const derived = once(trigger); + + derived.watch(fn); + + const scope1 = fork(); + await allSettled(trigger, { scope: scope1 }); + + const scope2 = fork({ values: serialize(scope1) }); + await allSettled(trigger, { scope: scope2 }); + + expect(fn).toHaveBeenCalledTimes(1); +}); diff --git a/src/once/once.test.ts b/src/once/once.test.ts new file mode 100644 index 00000000..8fd63d01 --- /dev/null +++ b/src/once/once.test.ts @@ -0,0 +1,88 @@ +import { createEffect, createEvent, createStore, is, launch } from 'effector'; +import { once } from './index'; + +it('should source only once', () => { + const fn = jest.fn(); + + const source = createEvent(); + const derived = once(source); + + derived.watch(fn); + expect(fn).toHaveBeenCalledTimes(0); + + source(); + source(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +it('supports effect as an argument', () => { + const fn = jest.fn(); + + const triggerFx = createEffect(jest.fn()); + const derived = once(triggerFx); + + derived.watch(fn); + expect(fn).toHaveBeenCalledTimes(0); + + triggerFx(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +it('supports store as an argument', () => { + const fn = jest.fn(); + + const increment = createEvent(); + const $source = createStore(0).on(increment, (n) => n + 1); + const derived = once($source); + + derived.watch(fn); + expect(fn).toHaveBeenCalledTimes(0); + + increment(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +it('always returns event', () => { + const event = createEvent(); + const effect = createEffect(); + const $store = createStore(''); + + expect(is.event(once(event))).toBe(true); + expect(is.event(once(effect))).toBe(true); + expect(is.event(once($store))).toBe(true); +}); + +it('only triggers once in race conditions', () => { + const fn = jest.fn(); + + const source = createEvent(); + const derived = once(source); + + derived.watch(fn); + expect(fn).toHaveBeenCalledTimes(0); + + launch({ + target: [source, source], + params: ['a', 'b'], + }); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +it('calling derived event does not lock once', () => { + const fn = jest.fn(); + + const source = createEvent(); + const derived = once(source); + + derived.watch(fn); + expect(fn).toHaveBeenCalledTimes(0); + + derived(); + source(); + + expect(fn).toHaveBeenCalledTimes(2); +}); diff --git a/src/once/readme.md b/src/once/readme.md new file mode 100644 index 00000000..712b7cce --- /dev/null +++ b/src/once/readme.md @@ -0,0 +1,55 @@ +# once + +```ts +import { once } from 'patronum'; +// or +import { once } from 'patronum/once'; +``` + +### Motivation + +The method allows to do something only on the first ever trigger of `source`. +It is useful to trigger effects or other logic only once per application's lifetime. + +### Formulae + +```ts +target = once(source); +``` + +- When `source` is triggered, launch `target` with data from `source`, but only once. + +### Arguments + +- `source` `(Event` | `Effect` | `Store)` — Source unit, data from this unit is used by `target`. + +### Returns + +- `target` `Event` — The event that will be triggered exactly once after `source` is triggered. + +### Example + +```ts +const messageReceived = createEvent(); +const firstMessageReceived = once(messageReceived); + +firstMessageReceived.watch((message) => + console.log('First message received:', message), +); + +messageReceived('Hello'); // First message received: Hello +messageReceived('World'); +``` + +#### Alternative + +```ts +import { createGate } from 'effector-react'; + +const PageGate = createGate(); + +sample({ + source: once(PageGate.open), + target: fetchDataFx, +}); +``` diff --git a/test-typings/once.ts b/test-typings/once.ts new file mode 100644 index 00000000..b7780392 --- /dev/null +++ b/test-typings/once.ts @@ -0,0 +1,31 @@ +import { expectType } from 'tsd'; +import { + Event, + createStore, + createEvent, + createEffect, + createDomain, + fork, +} from 'effector'; +import { once } from '../src/once'; + +// Supports Event, Effect and Store as an argument +{ + expectType>(once(createEvent())); + expectType>(once(createEffect())); + expectType>(once(createStore(''))); +} + +// Does not allow scope or domain as a first argument +{ + // @ts-expect-error + once(createDomain()); + // @ts-expect-error + once(fork()); +} + +// Correctly passes through complex types +{ + const source = createEvent<'string' | false>(); + expectType>(once(source)); +}