diff --git a/packages/devextreme/js/__internal/core/reactive/core.ts b/packages/devextreme/js/__internal/core/reactive/core.ts new file mode 100644 index 000000000000..e0874385e623 --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/core.ts @@ -0,0 +1,88 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-classes-per-file */ + +import { type Subscription, SubscriptionBag } from './subscription'; +import type { + Callback, Gettable, Subscribable, Updatable, +} from './types'; + +export class Observable implements Subscribable, Updatable, Gettable { + private readonly callbacks: Set> = new Set(); + + constructor(private value: T) {} + + update(value: T): void { + if (this.value === value) { + return; + } + this.value = value; + + this.callbacks.forEach((c) => { + c(value); + }); + } + + updateFunc(func: (oldValue: T) => T): void { + this.update(func(this.value)); + } + + subscribe(callback: Callback): Subscription { + this.callbacks.add(callback); + callback(this.value); + + return { + unsubscribe: () => this.callbacks.delete(callback), + }; + } + + unreactive_get(): T { + return this.value; + } + + dispose(): void { + this.callbacks.clear(); + } +} + +export class InterruptableComputed< + TArgs extends readonly any[], TValue, +> extends Observable { + private readonly depValues: [...TArgs]; + + private readonly depInitialized: boolean[]; + + private isInitialized = false; + + private readonly subscriptions = new SubscriptionBag(); + + constructor( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, + ) { + super(undefined as any); + + this.depValues = deps.map(() => undefined) as any; + this.depInitialized = deps.map(() => false); + + deps.forEach((dep, i) => { + this.subscriptions.add(dep.subscribe((v) => { + this.depValues[i] = v; + + if (!this.isInitialized) { + this.depInitialized[i] = true; + this.isInitialized = this.depInitialized.every((e) => e); + } + + if (this.isInitialized) { + this.update(compute(...this.depValues)); + } + })); + }); + } + + dispose(): void { + super.dispose(); + this.subscriptions.unsubscribe(); + } +} diff --git a/packages/devextreme/js/__internal/core/reactive/index.ts b/packages/devextreme/js/__internal/core/reactive/index.ts new file mode 100644 index 000000000000..e2e8474530df --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/index.ts @@ -0,0 +1,3 @@ +export * from './subscription'; +export * from './types'; +export * from './utilities'; diff --git a/packages/devextreme/js/__internal/core/reactive/subscription.ts b/packages/devextreme/js/__internal/core/reactive/subscription.ts new file mode 100644 index 000000000000..d3bc303311df --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/subscription.ts @@ -0,0 +1,17 @@ +export interface Subscription { + unsubscribe: () => void; +} + +export class SubscriptionBag implements Subscription { + private readonly subscriptions: Subscription[] = []; + + add(subscription: Subscription): void { + this.subscriptions.push(subscription); + } + + unsubscribe(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } +} diff --git a/packages/devextreme/js/__internal/core/reactive/types.ts b/packages/devextreme/js/__internal/core/reactive/types.ts new file mode 100644 index 000000000000..176361809ac4 --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/types.ts @@ -0,0 +1,28 @@ +/* eslint-disable spellcheck/spell-checker */ +import type { Subscription } from './subscription'; + +export interface Subscribable { + subscribe: (callback: Callback) => Subscription; +} + +export type MaybeSubscribable = T | Subscribable; + +export type MapMaybeSubscribable = { [K in keyof T]: MaybeSubscribable }; + +export function isSubscribable(value: unknown): value is Subscribable { + return typeof value === 'object' && !!value && 'subscribe' in value; +} + +export type Callback = (value: T) => void; + +export interface Updatable { + update: (value: T) => void; + updateFunc: (func: (oldValue: T) => T) => void; +} + +export interface Gettable { + unreactive_get: () => T; +} + +export type SubsGets = Subscribable & Gettable; +export type SubsGetsUpd = Subscribable & Gettable & Updatable; diff --git a/packages/devextreme/js/__internal/core/reactive/utilities.test.ts b/packages/devextreme/js/__internal/core/reactive/utilities.test.ts new file mode 100644 index 000000000000..c2faab3d23c4 --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/utilities.test.ts @@ -0,0 +1,217 @@ +/* eslint-disable spellcheck/spell-checker */ +import { + beforeEach, describe, expect, it, jest, +} from '@jest/globals'; + +import { + computed, interruptableComputed, state, toSubscribable, +} from './utilities'; + +describe('state', () => { + let myState = state('some value'); + + beforeEach(() => { + myState = state('some value'); + }); + + describe('unreactive_get', () => { + it('should return value', () => { + expect(myState.unreactive_get()).toBe('some value'); + }); + + it('should return current value if it was updated', () => { + myState.update('new value'); + expect(myState.unreactive_get()).toBe('new value'); + }); + }); + + describe('subscribe', () => { + it('should call callback on initial set', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value'); + }); + + it('should call callback on update', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + myState.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value'); + }); + + it('should not trigger update if value is not changed', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + + myState.update('some value'); + + expect(callback).toBeCalledTimes(1); + }); + }); + + describe('dispose', () => { + it('should prevent all updates', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value'); + + // @ts-expect-error + myState.dispose(); + myState.update('new value'); + + expect(callback).toBeCalledTimes(1); + }); + }); +}); + +describe('computed', () => { + let myState1 = state('some value'); + let myState2 = state('other value'); + let myComputed = computed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + + beforeEach(() => { + myState1 = state('some value'); + myState2 = state('other value'); + myComputed = computed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + }); + + describe('unreactive_get', () => { + it('should calculate initial value', () => { + expect(myComputed.unreactive_get()).toBe('some value other value'); + }); + + it('should return current value if it dependency is updated', () => { + myState1.update('new value'); + expect(myComputed.unreactive_get()).toBe('new value other value'); + }); + }); + + describe('subscribe', () => { + it('should call callback on initial set', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value other value'); + }); + + it('should call callback on update of dependency', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + myState1.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value other value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value other value'); + }); + }); +}); + +describe('interruptableComputed', () => { + let myState1 = state('some value'); + let myState2 = state('other value'); + let myComputed = interruptableComputed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + + beforeEach(() => { + myState1 = state('some value'); + myState2 = state('other value'); + myComputed = interruptableComputed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + }); + + describe('unreactive_get', () => { + it('should calculate initial value', () => { + expect(myComputed.unreactive_get()).toBe('some value other value'); + }); + + it('should return current value if it was updated', () => { + myComputed.update('new value'); + expect(myComputed.unreactive_get()).toBe('new value'); + }); + + it('should return current value if it dependency is updated', () => { + myState1.update('new value'); + expect(myComputed.unreactive_get()).toBe('new value other value'); + }); + }); + + describe('subscribe', () => { + it('should call callback on initial set', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value other value'); + }); + + it('should call callback on update', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + myComputed.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value other value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value'); + }); + + it('should call callback on update of dependency', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + myState1.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value other value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value other value'); + }); + + it('should not trigger update if value is not changed', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + + myComputed.update('some value other value'); + + expect(callback).toBeCalledTimes(1); + }); + }); +}); + +describe('toSubscribable', () => { + it('should wrap value if it is not subscribable', () => { + const callback = jest.fn(); + toSubscribable('some value').subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value'); + }); + + it('should return value as is if subscribable', () => { + const myState = state(1); + expect(toSubscribable(myState)).toBe(myState); + }); +}); diff --git a/packages/devextreme/js/__internal/core/reactive/utilities.ts b/packages/devextreme/js/__internal/core/reactive/utilities.ts new file mode 100644 index 000000000000..4c1538242e2b --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/utilities.ts @@ -0,0 +1,211 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable spellcheck/spell-checker */ +import { InterruptableComputed, Observable } from './core'; +import { type Subscription, SubscriptionBag } from './subscription'; +import type { + Gettable, MapMaybeSubscribable, MaybeSubscribable, Subscribable, SubsGets, SubsGetsUpd, Updatable, +} from './types'; +import { isSubscribable } from './types'; + +/** + * Creates new reactive state atom. + * @example + * ``` + * const myState = state(0); + * myState.update(1); + * ``` + * @param value initial value of state + */ +export function state(value: T): Subscribable & Updatable & Gettable { + return new Observable(value); +} + +/** + * Creates computed atom based on other atoms. + * @example + * ``` + * const myState = state(0); + * const myComputed = computed( + * (value) => value + 1, + * [myState] + * ); + * ``` + * @param compute computation func + * @param deps dependency atoms + */ +export function computed( + compute: (t1: T1) => TValue, + deps: [Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2) => TValue, + deps: [Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3,) => TValue, + deps: [Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4) => TValue, + deps: [Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5) => TValue, + deps: [Subscribable, Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5, t6: T6) => TValue, + // eslint-disable-next-line max-len + deps: [Subscribable, Subscribable, Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, +): SubsGets; +export function computed( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, +): SubsGets { + return new InterruptableComputed(compute, deps); +} + +/** + * Computed, with ability to override value using `.update(...)` method. + * @see {@link computed} + */ +export function interruptableComputed( + compute: (t1: T1) => TValue, + deps: [Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (t1: T1, t2: T2) => TValue, + deps: [Subscribable, Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (t1: T1, t2: T2, t3: T3,) => TValue, + deps: [Subscribable, Subscribable, Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4) => TValue, + deps: [Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, +): SubsGetsUpd { + return new InterruptableComputed(compute, deps); +} + +/** + * Allows to subscribe function with some side effects to changes of dependency atoms. + * @param callback function which is executed each time any dependency is updated + * @param deps dependencies + */ +export function effect( + callback: (t1: T1) => ((() => void) | void), + deps: [Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2) => ((() => void) | void), + deps: [Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2, t3: T3,) => ((() => void) | void), + deps: [Subscribable, Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2, t3: T3, t4: T4) => ((() => void) | void), + deps: [Subscribable, Subscribable, Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5) => ((() => void) | void), + deps: [Subscribable, Subscribable, Subscribable, Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (...args: TArgs) => ((() => void) | void), + deps: { [I in keyof TArgs]: Subscribable }, +): Subscription { + const depValues: [...TArgs] = deps.map(() => undefined) as any; + const depInitialized = deps.map(() => false); + let isInitialized = false; + + const subscription = new SubscriptionBag(); + + deps.forEach((dep, i) => { + subscription.add(dep.subscribe((v) => { + depValues[i] = v; + + if (!isInitialized) { + depInitialized[i] = true; + isInitialized = depInitialized.every((e) => e); + } + + if (isInitialized) { + callback(...depValues); + } + })); + }); + + return subscription; +} + +export function toSubscribable(v: MaybeSubscribable): Subscribable { + if (isSubscribable(v)) { + return v; + } + + return new Observable(v); +} + +/** + * Condition atom, basing whether `cond` is true or false, + * returns value of `ifTrue` or `ifFalse` param. + * @param cond + * @param ifTrue + * @param ifFalse + */ +export function iif( + cond: MaybeSubscribable, + ifTrue: MaybeSubscribable, + ifFalse: MaybeSubscribable, +): Subscribable { + const obs = state(undefined as any); + // eslint-disable-next-line @typescript-eslint/init-declarations + let subscription: Subscription | undefined; + + // eslint-disable-next-line @typescript-eslint/no-shadow + toSubscribable(cond).subscribe((cond) => { + subscription?.unsubscribe(); + const newSource = cond ? ifTrue : ifFalse; + subscription = toSubscribable(newSource).subscribe(obs.update.bind(obs)); + }); + + return obs; +} + +/** + * Combines object of Subscribables to Subscribable of object. + * @example + * ``` + * const myValueA = state(0); + * const myValueB = state(1); + * const obj = combine({ + * myValueA, myValueB + * }); + * + * obj.unreactive_get(); // {myValueA: 0, myValueB: 1} + * @returns + */ +export function combined( + obj: MapMaybeSubscribable, +): SubsGets { + const entries = Object.entries(obj) as any as [string, Subscribable][]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return computed( + (...args) => Object.fromEntries( + args.map((v, i) => [entries[i][0], v]), + ), + entries.map(([, v]) => toSubscribable(v)), + ) as any; +}