diff --git a/demo/store.ts b/demo/store.ts index d37c6aa..d792696 100644 --- a/demo/store.ts +++ b/demo/store.ts @@ -1,12 +1,4 @@ -import { - Effect, - Event, - PersistState, - State, - ComputeState, - DebouncedEvent, - ThrottledEvent, -} from "../src"; +import { Event, State, StateManager } from "../src"; type Store = { changeFirstName: Event; @@ -22,13 +14,21 @@ type Store = { }; const createStore = (): Store => { - const changeFirstName = new Event(); - const changeSecondName = new Event(); - const changeAge = new Event(); - const submitted = new Event(); - const resetEvent = new Event(); - const debouncedSubmitEvent = new DebouncedEvent(500); - const throttledSubmitEvent = new ThrottledEvent(500); + const demoFormModule = StateManager.initModule("demo form"); + + const changeFirstName = demoFormModule + .initEvent() + .applyMiddleware((context, next) => { + context.value = context.value.toUpperCase(); + next(); + }); + + const changeSecondName = demoFormModule.initEvent(); + const changeAge = demoFormModule.initEvent(); + const submitted = demoFormModule.initEvent(); + const resetEvent = demoFormModule.initEvent(); + const debouncedSubmitEvent = demoFormModule.initDebouncedEvent(500); + const throttledSubmitEvent = demoFormModule.initThrottledEvent(500); debouncedSubmitEvent.subscribe(() => { console.log("debouncedSubmitEvent called"); @@ -37,7 +37,7 @@ const createStore = (): Store => { console.log("throttledSubmitEvent called"); }); - const submitEffect = new Effect( + const submitEffect = demoFormModule.initEffect( ( data: { firstName: State; @@ -82,23 +82,27 @@ const createStore = (): Store => { console.log(`subscribe | ${data.state}`, data); }); - const firstNameState = new PersistState( + const firstNameState = demoFormModule.initPersistState( "", "firstName", window.sessionStorage ); - const secondNameState = new PersistState( + const secondNameState = demoFormModule.initPersistState( "", "lastName", window.sessionStorage ); - const ageState = new PersistState(1, "age", window.sessionStorage); + const ageState = demoFormModule.initPersistState( + 1, + "age", + window.sessionStorage + ); changeFirstName.subscribe((value) => firstNameState.set(value)); changeSecondName.subscribe((value) => secondNameState.set(value)); changeAge.subscribe((value) => ageState.set(value)); - const symbolsCountState = new ComputeState( + const symbolsCountState = demoFormModule.initComputedState( firstNameState, secondNameState, (firstName, secondName) => @@ -113,18 +117,14 @@ const createStore = (): Store => { }); debouncedSubmitEvent.dispatch(); throttledSubmitEvent.dispatch(); - - // setTimeout(() => { - // submitEffect.release(); - // }, 1000); }); resetEvent.subscribe(() => { - firstNameState.reset(); - secondNameState.reset(); - ageState.reset(); + demoFormModule.resetState(); }); + console.log({ StateManager }); + return { changeFirstName, changeSecondName, diff --git a/package.json b/package.json index af45170..d736e96 100644 --- a/package.json +++ b/package.json @@ -48,4 +48,4 @@ ], "repository": "https://github.com/vitlolik/svitore", "license": "MIT" -} +} \ No newline at end of file diff --git a/src/shared/constants.ts b/src/constants.ts similarity index 100% rename from src/shared/constants.ts rename to src/constants.ts diff --git a/src/compute-state.test.ts b/src/entities/compute-state.test.ts similarity index 66% rename from src/compute-state.test.ts rename to src/entities/compute-state.test.ts index 2d656a9..e612dd4 100644 --- a/src/compute-state.test.ts +++ b/src/entities/compute-state.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from "vitest"; -import { ComputeState } from "./compute-state"; +import { ComputedState } from "./computed-state"; import { State } from "./state"; -import { SvitoreError } from "./shared"; +import { SvitoreError } from "../utils"; describe("computeState", () => { it("type", () => { const state1 = new State(5); const state2 = new State(5); - const mergedState = new ComputeState( + const mergedState = new ComputedState( state1, state2, (value1, value2) => value1 + value2 @@ -21,7 +21,7 @@ describe("computeState", () => { it("initial state", () => { const state1 = new State("hello"); const state2 = new State("!"); - const mergedState = new ComputeState( + const mergedState = new ComputedState( state1, state2, (value1, value2) => value1 + " world" + value2 @@ -33,7 +33,7 @@ describe("computeState", () => { it("subscribe to state list", () => { const state1 = new State("hello"); const state2 = new State("world"); - const computed = new ComputeState( + const computed = new ComputedState( state1, state2, (state1, state2) => `${state1} ${state2}` @@ -49,7 +49,7 @@ describe("computeState", () => { it("should be readonly, can not change the state", () => { const state = new State("world"); - const computed = new ComputeState(state, (state) => state.toUpperCase()); + const computed = new ComputedState(state, (state) => state.toUpperCase()); try { computed.set(""); @@ -63,26 +63,11 @@ describe("computeState", () => { } }); - it("clone - should clone state with others state list", () => { - const state1 = new State("hello"); - const state2 = new State("world"); - const computed = new ComputeState(state1, state2, (value1, value2) => - `${value1} ${value2}`.toUpperCase() - ); - - const newState1 = new State("foo"); - const newState2 = new State("bar"); - - const cloned = computed.clone([newState1, newState2]); - - expect(cloned.get()).toBe("FOO BAR"); - }); - it("release - should unsubscribe from the state list", () => { const state1 = new State("hello"); const state2 = new State("world"); - const computed = new ComputeState(state1, state2, (value1, value2) => + const computed = new ComputedState(state1, state2, (value1, value2) => `${value1} ${value2}`.toUpperCase() ); diff --git a/src/compute-state.ts b/src/entities/computed-state.ts similarity index 52% rename from src/compute-state.ts rename to src/entities/computed-state.ts index 929e6e1..6576d16 100644 --- a/src/compute-state.ts +++ b/src/entities/computed-state.ts @@ -1,16 +1,15 @@ -import { SelectorCallback, SvitoreError } from "./shared"; +import { SelectorCallback } from "../types"; +import { SvitoreError } from "../utils"; import { State } from "./state"; -const throwComputeStateError = (): never => { - throw new SvitoreError("ComputeState is read-only, you must not change it"); +const throwComputedStateError = (): never => { + throw new SvitoreError("ComputedState is read-only, you must not change it"); }; -class ComputeState< +class ComputedState< StateList extends ReadonlyArray>, Data > extends State { - private stateList: StateList; - private selector: SelectorCallback; private unsubscribeList: (() => void)[] = []; constructor(...args: [...StateList, SelectorCallback]) { @@ -21,8 +20,6 @@ class ComputeState< selector(...(stateList.map((state) => state.get()) as any)); super(getStateData()); - this.selector = selector; - this.stateList = stateList; stateList.forEach((state) => { this.unsubscribeList.push( @@ -31,17 +28,12 @@ class ComputeState< }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - set(newState: Data): void { - throwComputeStateError(); + set(_newState: Data): never { + return throwComputedStateError(); } - reset(): void { - throwComputeStateError(); - } - - clone(stateList = this.stateList): ComputeState { - return new ComputeState(...[...stateList, this.selector]); + reset(): never { + return throwComputedStateError(); } release(): void { @@ -50,4 +42,4 @@ class ComputeState< } } -export { ComputeState }; +export { ComputedState }; diff --git a/src/debounced-event.ts b/src/entities/debounced-event.ts similarity index 70% rename from src/debounced-event.ts rename to src/entities/debounced-event.ts index 53ac15c..b3c1ac5 100644 --- a/src/debounced-event.ts +++ b/src/entities/debounced-event.ts @@ -1,6 +1,10 @@ -import { DelayedEvent } from "./delayed-event"; +import { DelayedEvent } from "./services"; class DebouncedEvent extends DelayedEvent { + constructor(timeout: number) { + super(timeout); + } + dispatch(payload: Payload): void { this.clearTimer(); this.timeoutId = setTimeout(() => super.dispatch(payload), this.timeout); diff --git a/src/effect.test.ts b/src/entities/effect.test.ts similarity index 95% rename from src/effect.test.ts rename to src/entities/effect.test.ts index ea1d2fb..ab1ed8a 100644 --- a/src/effect.test.ts +++ b/src/entities/effect.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { Effect } from "./effect"; -import { Entity } from "./shared"; +import { Entity } from "./services"; describe("effect", () => { it("type", () => { @@ -111,12 +111,6 @@ describe("effect", () => { }); }); - it("clone - should clone an effect with existing effect function", () => { - const effect = new Effect(() => Promise.resolve()); - - expect(effect.clone({ isAutoAbort: true })).instanceOf(Entity); - }); - it("abort - should abort effect and set change pending state", () => { const abortListener = vi.fn(); const effect = new Effect(async (_data, abortController) => { diff --git a/src/effect.ts b/src/entities/effect.ts similarity index 80% rename from src/effect.ts rename to src/entities/effect.ts index 16af3e2..e31a9f0 100644 --- a/src/effect.ts +++ b/src/entities/effect.ts @@ -1,4 +1,4 @@ -import { Entity } from "./shared"; +import { Entity } from "./services"; import { State } from "./state"; type EffectOptions = { @@ -10,16 +10,16 @@ type EffectFunction = ( abortController: AbortController ) => Promise; -type NotifyPayload = +type NotifyPayload = | { state: "fulfilled"; params: Params; result: Result; } - | { state: "rejected"; params: Params; error: ErrorType }; + | { state: "rejected"; params: Params; error: Error }; -class Effect extends Entity< - NotifyPayload +class Effect extends Entity< + NotifyPayload > { private abortController: AbortController | null = null; @@ -32,10 +32,6 @@ class Effect extends Entity< super(); } - clone(options = this.options): Effect { - return new Effect(this.effectFunction, options); - } - implement(effectFunction: EffectFunction): void { this.effectFunction = effectFunction; } diff --git a/src/entities/event.test.ts b/src/entities/event.test.ts new file mode 100644 index 0000000..8fc020b --- /dev/null +++ b/src/entities/event.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from "vitest"; + +import { Event } from "./event"; +import { Entity } from "./services"; + +describe("event", () => { + it("type", () => { + const event = new Event(); + + expect(event).instanceOf(Entity); + }); + + describe("dispatch - call event with payload", () => { + it("should notify subscribers", () => { + const event = new Event(); + const subscriber = vi.fn(); + event.subscribe(subscriber); + + expect(subscriber).toBeCalledTimes(0); + + event.dispatch(1); + expect(subscriber).toBeCalledTimes(1); + expect(subscriber).toHaveBeenCalledWith(1, event); + + event.dispatch(2); + expect(subscriber).toBeCalledTimes(2); + expect(subscriber).toHaveBeenCalledWith(2, event); + + event.dispatch(0); + expect(subscriber).toBeCalledTimes(3); + expect(subscriber).toHaveBeenCalledWith(0, event); + }); + }); +}); diff --git a/src/entities/event.ts b/src/entities/event.ts new file mode 100644 index 0000000..37a9a60 --- /dev/null +++ b/src/entities/event.ts @@ -0,0 +1,5 @@ +import { AbstractEvent } from "./services"; + +class Event extends AbstractEvent {} + +export { Event }; diff --git a/src/entities/index.ts b/src/entities/index.ts new file mode 100644 index 0000000..9207bb2 --- /dev/null +++ b/src/entities/index.ts @@ -0,0 +1,11 @@ +export { State } from "./state"; +export { ComputedState } from "./computed-state"; +export { PersistState } from "./persist-state"; +export { Event } from "./event"; +export { DebouncedEvent } from "./debounced-event"; +export { ThrottledEvent } from "./throttled-event"; +export { Effect } from "./effect"; +export { Reaction } from "./reaction"; + +export type { EffectFunction } from "./effect"; +export type { Middleware } from "./services"; diff --git a/src/persist-state.test.ts b/src/entities/persist-state.test.ts similarity index 58% rename from src/persist-state.test.ts rename to src/entities/persist-state.test.ts index 8835d89..97418a5 100644 --- a/src/persist-state.test.ts +++ b/src/entities/persist-state.test.ts @@ -2,7 +2,7 @@ import { vi, it, expect, describe } from "vitest"; import { PersistState } from "./persist-state"; import { State } from "./state"; -import { SvitoreError } from "./shared"; +import { SvitoreError } from "../utils"; class MockStorage implements Storage { length: number; @@ -65,63 +65,6 @@ describe("persist state", () => { ); }); - describe("clone - should clone state with default data", () => { - it("should clone state with default data", () => { - const persistState = new PersistState( - "test state", - "test-key", - new MockStorage() - ); - const cloned = persistState.clone(); - - expect(cloned.get()).toBe("test state"); - }); - - it("should change storage key", async () => { - const storage = new MockStorage(); - storage.getItem = vi.fn((_key: string) => - JSON.stringify({ _: "value in storage" }) - ); - const persistState = new PersistState("test state", "test-key", storage); - const cloned = persistState.clone("new-test-key"); - - cloned.set("new test value for new key"); - - // because storage updated as microtask - await Promise.resolve(); - - expect(storage.setItem).toHaveBeenCalledTimes(1); - expect(storage.setItem).toHaveBeenCalledWith( - "[svitore]-new-test-key", - JSON.stringify({ _: "new test value for new key" }) - ); - }); - - it("should change storage object", async () => { - const persistState = new PersistState( - "test state", - "test-key", - new MockStorage() - ); - const newStorage = new MockStorage(); - newStorage.getItem = vi.fn((_key: string) => - JSON.stringify({ _: "value in new storage" }) - ); - const cloned = persistState.clone(undefined, newStorage); - - cloned.set("new test value for new storage"); - - // because storage updated as microtask - await Promise.resolve(); - - expect(newStorage.setItem).toHaveBeenCalledTimes(1); - expect(newStorage.setItem).toHaveBeenCalledWith( - "[svitore]-test-key", - JSON.stringify({ _: "new test value for new storage" }) - ); - }); - }); - it("clearStorage - should remove item from storage", () => { const storage = new MockStorage(); const persistState = new PersistState("test state", "test-key", storage); diff --git a/src/persist-state.ts b/src/entities/persist-state.ts similarity index 78% rename from src/persist-state.ts rename to src/entities/persist-state.ts index cfdf652..977327d 100644 --- a/src/persist-state.ts +++ b/src/entities/persist-state.ts @@ -1,5 +1,6 @@ +import { PACKAGE_LABEL } from "../constants"; +import { createBatchFunction, SvitoreError } from "../utils"; import { State } from "./state"; -import { createBatchFunction, PACKAGE_LABEL, SvitoreError } from "./shared"; const STORAGE_KEY_PREFIX = `${PACKAGE_LABEL}-` as const; const NESTED_KEY = "_" as const; @@ -36,13 +37,6 @@ class PersistState extends State { } } - clone( - storageKey = this.storageKey.replace(STORAGE_KEY_PREFIX, ""), - storage = this.storage - ): PersistState { - return new PersistState(this.defaultState, storageKey, storage); - } - clearStorage(): void { this.storage.removeItem(this.storageKey); } diff --git a/src/reaction.test.ts b/src/entities/reaction.test.ts similarity index 98% rename from src/reaction.test.ts rename to src/entities/reaction.test.ts index a461e0b..e1a2dad 100644 --- a/src/reaction.test.ts +++ b/src/entities/reaction.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { Entity } from "./shared"; +import { Entity } from "./services"; import { Reaction } from "./reaction"; import { State } from "./state"; diff --git a/src/reaction.ts b/src/entities/reaction.ts similarity index 84% rename from src/reaction.ts rename to src/entities/reaction.ts index a310dc2..f4e4998 100644 --- a/src/reaction.ts +++ b/src/entities/reaction.ts @@ -1,5 +1,7 @@ -import { Entity, SelectorCallback, createBatchFunction } from "./shared"; +import { Entity } from "./services"; import { State } from "./state"; +import { SelectorCallback } from "../types"; +import { createBatchFunction } from "../utils"; class Reaction>> extends Entity { private unsubscribeList: (() => void)[] = []; diff --git a/src/entities/services/abstract-event.ts b/src/entities/services/abstract-event.ts new file mode 100644 index 0000000..6f35019 --- /dev/null +++ b/src/entities/services/abstract-event.ts @@ -0,0 +1,45 @@ +import { Entity } from "./entity"; + +type MiddlewareContext = { + value: T; +}; + +type Middleware = (context: MiddlewareContext, next: () => void) => void; + +abstract class AbstractEvent extends Entity { + private middlewares: Middleware[] = []; + + private invokeMiddlewares( + value: Payload, + dispatch: (context: MiddlewareContext) => void + ): void { + const context: MiddlewareContext = { value }; + + const invoke = (index: number): void => { + if (index < this.middlewares.length) { + this.middlewares[index](context, () => { + invoke(index + 1); + }); + } else { + dispatch(context); + } + }; + + invoke(0); + } + + dispatch(payload: Payload): void { + this.invokeMiddlewares(payload, ({ value }) => { + this.notify(value); + }); + } + + applyMiddleware(middleware: Middleware): this { + this.middlewares.push(middleware); + + return this; + } +} + +export { AbstractEvent }; +export type { Middleware }; diff --git a/src/delayed-event.ts b/src/entities/services/delayed-event.ts similarity index 50% rename from src/delayed-event.ts rename to src/entities/services/delayed-event.ts index d20ee0c..d4696e0 100644 --- a/src/delayed-event.ts +++ b/src/entities/services/delayed-event.ts @@ -1,10 +1,10 @@ -import { Event, EventOptions } from "./event"; +import { AbstractEvent } from "./abstract-event"; -abstract class DelayedEvent extends Event { +abstract class DelayedEvent extends AbstractEvent { protected timeoutId: NodeJS.Timeout | number; - constructor(protected timeout: number, options?: EventOptions) { - super(options); + constructor(protected readonly timeout: number) { + super(); } protected clearTimer(): void { diff --git a/src/shared/entity.test.ts b/src/entities/services/entity.test.ts similarity index 84% rename from src/shared/entity.test.ts rename to src/entities/services/entity.test.ts index 74309bc..06be8cc 100644 --- a/src/shared/entity.test.ts +++ b/src/entities/services/entity.test.ts @@ -9,7 +9,7 @@ describe("entity", () => { it("type", () => { const entity = new TestEntity(); - Entity.createdEntities = []; + Entity.ENTITIES = []; expect(entity).instanceOf(Observable); }); @@ -19,8 +19,8 @@ describe("entity", () => { const entity2 = new TestEntity(); const entity3 = new TestEntity(); - expect(Entity.createdEntities).toHaveLength(3); - expect(Entity.createdEntities).toEqual([entity1, entity2, entity3]); + expect(Entity.ENTITIES).toHaveLength(3); + expect(Entity.ENTITIES).toEqual([entity1, entity2, entity3]); }); it("subscribe - should call observe from parent class", () => { diff --git a/src/shared/entity.ts b/src/entities/services/entity.ts similarity index 61% rename from src/shared/entity.ts rename to src/entities/services/entity.ts index 1c66035..d23e422 100644 --- a/src/shared/entity.ts +++ b/src/entities/services/entity.ts @@ -1,12 +1,14 @@ import { Observable, Observer } from "./observable"; +import { generateId } from "../../utils"; abstract class Entity extends Observable { - static createdEntities: Entity[] = []; + id: number; + static ENTITIES: Entity[] = []; constructor() { super(); - - Entity.createdEntities.push(this); + this.id = generateId.next().value; + Entity.ENTITIES.push(this); } subscribe(subscriber: Observer): () => void { diff --git a/src/entities/services/index.ts b/src/entities/services/index.ts new file mode 100644 index 0000000..e380d0b --- /dev/null +++ b/src/entities/services/index.ts @@ -0,0 +1,5 @@ +export { Entity } from "./entity"; +export { AbstractEvent } from "./abstract-event"; +export { DelayedEvent } from "./delayed-event"; + +export type { Middleware } from "./abstract-event"; diff --git a/src/shared/observable.test.ts b/src/entities/services/observable.test.ts similarity index 100% rename from src/shared/observable.test.ts rename to src/entities/services/observable.test.ts diff --git a/src/shared/observable.ts b/src/entities/services/observable.ts similarity index 85% rename from src/shared/observable.ts rename to src/entities/services/observable.ts index 8b020c0..a613dcc 100644 --- a/src/shared/observable.ts +++ b/src/entities/services/observable.ts @@ -1,4 +1,4 @@ -import { logError } from "./error"; +import { logError } from "../../utils"; type Observer = (data: T, instance: Observable) => void; @@ -28,4 +28,5 @@ abstract class Observable { } } -export { Observable, Observer }; +export { Observable }; +export type { Observer }; diff --git a/src/state.test.ts b/src/entities/state.test.ts similarity index 78% rename from src/state.test.ts rename to src/entities/state.test.ts index 6e7fb41..34d20c5 100644 --- a/src/state.test.ts +++ b/src/entities/state.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { Entity } from "./shared"; +import { Entity } from "./services"; import { State } from "./state"; describe("state", () => { @@ -17,15 +17,6 @@ describe("state", () => { expect(state.get()).toBe("test"); }); - it("clone - create new state object with same data", () => { - const state = new State({ name: "vit", age: 20 }); - const clonedState = state.clone(); - expect(clonedState.get()).toEqual({ name: "vit", age: 20 }); - - clonedState.set({ name: "alex", age: 21 }); - expect(state.get()).toEqual({ name: "vit", age: 20 }); - }); - describe("set - set new state", () => { it("should do nothing if state is equal", () => { const subscriber = vi.fn(); diff --git a/src/state.ts b/src/entities/state.ts similarity index 83% rename from src/state.ts rename to src/entities/state.ts index 6669976..91cbb4d 100644 --- a/src/state.ts +++ b/src/entities/state.ts @@ -1,4 +1,4 @@ -import { Entity } from "./shared"; +import { Entity } from "./services"; class State extends Entity { protected defaultState: Data; @@ -10,10 +10,6 @@ class State extends Entity { this.prevState = state; } - clone(): State { - return new State(this.defaultState); - } - set(newState: Data): void { if (this.state === newState) return; diff --git a/src/throttled-event.ts b/src/entities/throttled-event.ts similarity index 84% rename from src/throttled-event.ts rename to src/entities/throttled-event.ts index 18ac028..a799c8d 100644 --- a/src/throttled-event.ts +++ b/src/entities/throttled-event.ts @@ -1,9 +1,13 @@ -import { DelayedEvent } from "./delayed-event"; +import { DelayedEvent } from "./services"; class ThrottledEvent extends DelayedEvent { private isThrottled = false; private savedParams: Payload | null = null; + constructor(timeout: number) { + super(timeout); + } + dispatch(payload: Payload): void { if (this.isThrottled) { this.savedParams = payload; diff --git a/src/event.test.ts b/src/event.test.ts deleted file mode 100644 index b8373b7..0000000 --- a/src/event.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; - -import { Event } from "./event"; -import { Entity } from "./shared"; -import { Middleware } from "./middleware"; - -describe("event", () => { - it("type", () => { - const event = new Event(); - - expect(event).instanceOf(Entity); - }); - - it("initial calls", () => { - const event = new Event(); - - expect(event.calls).toBe(0); - }); - - it("initial options", () => { - const event = new Event(); - - expect(event.options).toEqual({}); - }); - - describe("dispatch - call event with payload", () => { - it("should notify subscribers", () => { - const event = new Event(); - const subscriber = vi.fn(); - event.subscribe(subscriber); - - expect(subscriber).toBeCalledTimes(0); - - event.dispatch(1); - expect(subscriber).toBeCalledTimes(1); - expect(subscriber).toHaveBeenCalledWith(1, event); - - event.dispatch(2); - expect(subscriber).toBeCalledTimes(2); - expect(subscriber).toHaveBeenCalledWith(2, event); - - event.dispatch(0); - expect(subscriber).toBeCalledTimes(3); - expect(subscriber).toHaveBeenCalledWith(0, event); - }); - - it("should increase calls count", () => { - const event = new Event(); - - expect(event.calls).toBe(0); - - event.dispatch(); - expect(event.calls).toBe(1); - - event.dispatch(); - event.dispatch(); - expect(event.calls).toBe(3); - - event.dispatch(); - event.dispatch(); - event.dispatch(); - expect(event.calls).toBe(6); - }); - - it("can past custom condition for dispatch", () => { - const event = new Event({ shouldDispatch: (): boolean => false }); - const subscriber = vi.fn(); - event.subscribe(subscriber); - - event.dispatch(); - expect(subscriber).toHaveBeenCalledTimes(0); - - event.dispatch(); - event.dispatch(); - event.dispatch(); - expect(subscriber).toHaveBeenCalledTimes(0); - expect(event.calls).toBe(0); - }); - }); - - describe("middlewares", () => { - it("should call middleware chain", () => { - const middlewareFunc1 = vi.fn((payload) => payload); - const middlewareFunc2 = vi.fn((payload) => payload); - - const event = new Event().setMiddlewareChain([ - new Middleware(middlewareFunc1), - new Middleware(middlewareFunc2), - ]); - - event.dispatch(10); - - expect(middlewareFunc1).toHaveBeenCalledTimes(1); - expect(middlewareFunc2).toHaveBeenCalledTimes(1); - expect(middlewareFunc1).toHaveBeenCalledWith(10); - expect(middlewareFunc2).toHaveBeenCalledWith(10); - }); - - it("should mutate payload inside chain", () => { - const middlewareFunc1 = vi.fn((payload) => payload + 1); - const middlewareFunc2 = vi.fn((payload) => payload); - - const event = new Event().setMiddlewareChain([ - new Middleware(middlewareFunc1), - new Middleware(middlewareFunc2), - ]); - - event.dispatch(10); - - expect(middlewareFunc1).toHaveBeenCalledWith(10); - expect(middlewareFunc2).toHaveBeenCalledWith(11); - }); - - it("should break the chain if there is an error", () => { - const middlewareFunc1 = vi.fn((_payload) => { - throw new Error(); - }); - const middlewareFunc2 = vi.fn((payload) => payload); - - const event = new Event().setMiddlewareChain([ - new Middleware(middlewareFunc1), - new Middleware(middlewareFunc2), - ]); - - event.dispatch(10); - - expect(middlewareFunc1).toHaveBeenCalledTimes(1); - expect(middlewareFunc2).toHaveBeenCalledTimes(0); - }); - - it("should not notify subscribers if there is an error inside chain", () => { - const middlewareFunc1 = (_payload: number): number => { - throw new Error(); - }; - const middlewareFunc2 = (payload: number): number => payload; - const mockSubscriber = vi.fn(); - - const event = new Event().setMiddlewareChain([ - new Middleware(middlewareFunc1), - new Middleware(middlewareFunc2), - ]); - event.subscribe(mockSubscriber); - - event.dispatch(10); - - expect(mockSubscriber).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/src/event.ts b/src/event.ts deleted file mode 100644 index c0b5105..0000000 --- a/src/event.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Entity } from "./shared"; -import { Middleware } from "./middleware"; - -type EventOptions = { - [x: string]: any; - shouldDispatch?: (event: Event) => boolean; -}; - -class Event extends Entity { - private middlewareChain: Middleware[] = []; - calls = 0; - - constructor(public options: EventOptions = {}) { - super(); - } - - private callMiddlewares(payload: Payload): { - hasError: boolean; - payload: Payload; - } { - let result: Payload = payload; - let hasError = false; - - for (const middleware of this.middlewareChain) { - const middlewareResult = middleware.call(result); - hasError = middlewareResult.hasError; - if (hasError) break; - - result = middlewareResult.payload; - } - - return { hasError, payload: result }; - } - - private shouldDispatch(): boolean { - return this.options.shouldDispatch?.(this) ?? true; - } - - dispatch(payload: Payload): void { - if (!this.shouldDispatch()) return; - - const result = this.callMiddlewares(payload); - if (result.hasError) return; - - this.calls++; - this.notify(result.payload); - } - - setMiddlewareChain(middlewareChain: Middleware[]): this { - this.middlewareChain = middlewareChain; - return this; - } -} - -export { Event, EventOptions }; diff --git a/src/index.ts b/src/index.ts index bc45ce7..ec4dcf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,3 @@ -export { State } from "./state"; -export { ComputeState } from "./compute-state"; -export { PersistState } from "./persist-state"; -export { Event } from "./event"; -export { Middleware } from "./middleware"; -export { DebouncedEvent } from "./debounced-event"; -export { ThrottledEvent } from "./throttled-event"; -export { Effect } from "./effect"; -export { Reaction } from "./reaction"; -export { allEffectsFinished } from "./tools"; +export { StateManager } from "./state-manager"; +export type { EffectFunction, Middleware } from "./entities"; +export * from "./entities"; diff --git a/src/middleware.test.ts b/src/middleware.test.ts deleted file mode 100644 index bf84977..0000000 --- a/src/middleware.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; - -import { Middleware } from "./middleware"; - -describe("middleware", () => { - it("call - should call function with payload", () => { - const mockFn = vi.fn<[number]>((value) => value); - const middleware = new Middleware(mockFn); - middleware.call(10); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(mockFn).toHaveBeenCalledWith(10); - }); - - it("call - should return specific object", () => { - const middleware = new Middleware((value) => value); - const result = middleware.call(10); - - expect(result).toEqual({ hasError: false, payload: 10 }); - }); - - it("call - should return correct hasError", () => { - const middleware = new Middleware((value) => { - throw new Error(value); - }); - const result = middleware.call("test"); - - expect(result).toEqual({ hasError: true, payload: "test" }); - }); - - it("should call the onError handler if an error occurs", () => { - const mockErrorHandler = vi.fn(); - const middleware = new Middleware((value) => { - throw new Error(`Test error: ${value}`); - }); - middleware.onError(mockErrorHandler); - middleware.call("test"); - - expect(mockErrorHandler).toHaveBeenCalledTimes(1); - expect(mockErrorHandler).toHaveBeenCalledWith( - new Error("Test error: test") - ); - }); -}); diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 0f837cd..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,23 +0,0 @@ -type ErrorHandler = (error: ErrorType) => void; - -class Middleware { - private handleError?: ErrorHandler; - constructor(private middlewareFunction: (args: Payload) => Payload) {} - - call(payload: Payload): { hasError: boolean; payload: Payload } { - try { - return { hasError: false, payload: this.middlewareFunction(payload) }; - } catch (error) { - this.handleError?.(error as ErrorType); - return { hasError: true, payload }; - } - } - - onError(handleError: ErrorHandler): this { - this.handleError = handleError; - - return this; - } -} - -export { Middleware }; diff --git a/src/shared/index.ts b/src/shared/index.ts deleted file mode 100644 index 6ff6ee5..0000000 --- a/src/shared/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./entity"; -export * from "./observable"; -export * from "./batch"; -export * from "./error"; -export * from "./constants"; -export * from "./types"; diff --git a/src/state-manager-module.ts b/src/state-manager-module.ts new file mode 100644 index 0000000..0172742 --- /dev/null +++ b/src/state-manager-module.ts @@ -0,0 +1,84 @@ +import { + Event, + PersistState, + State, + DebouncedEvent, + ThrottledEvent, + ComputedState, + Effect, + Reaction, +} from "./entities"; +import { Entity } from "./entities/services"; +import { isComputedState, isState } from "./type-guard"; + +class StateManagerModule { + private entities: Entity[] = []; + + constructor(public name: T) {} + + private addEntity>(entity: T): T { + this.entities.push(entity); + + return entity; + } + + initState(...args: ConstructorParameters>): State { + return this.addEntity(new State(...args)); + } + + initComputedState>, T>( + ...args: ConstructorParameters> + ): ComputedState { + return this.addEntity(new ComputedState(...args)); + } + + initPersistState( + ...args: ConstructorParameters> + ): PersistState { + return this.addEntity(new PersistState(...args)); + } + + initEvent(): Event { + return this.addEntity(new Event()); + } + + initDebouncedEvent( + ...args: ConstructorParameters> + ): DebouncedEvent { + return this.addEntity(new DebouncedEvent(...args)); + } + + initThrottledEvent( + ...args: ConstructorParameters> + ): ThrottledEvent { + return this.addEntity(new ThrottledEvent(...args)); + } + + initEffect( + ...args: ConstructorParameters> + ): Effect { + return this.addEntity(new Effect(...args)); + } + + initReaction>>( + ...args: ConstructorParameters> + ): Reaction { + return this.addEntity(new Reaction(...args)); + } + + resetState(): void { + this.entities.forEach((entity) => { + if (isState(entity) && !isComputedState(entity)) { + entity.reset(); + } + }); + } + + release(): void { + this.entities.forEach((entity) => { + entity.release(); + }); + } +} + +export { StateManagerModule }; diff --git a/src/state-manager.test.ts b/src/state-manager.test.ts new file mode 100644 index 0000000..9a92300 --- /dev/null +++ b/src/state-manager.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from "vitest"; +import { StateManager } from "./state-manager"; + +describe("svitore", () => { + describe("allEffectsFinished", () => { + it("should wait pending effect", async () => { + const logic = StateManager.initModule("test allEffectsFinished"); + const effect = logic.initEffect(() => Promise.resolve()); + const testSubscribe = vi.fn(); + effect.subscribe(testSubscribe); + effect.run(); + + await StateManager.waitForEffects(); + + expect(testSubscribe).toHaveBeenCalled(); + }); + + it("should wait all pending effects", async () => { + const logic = StateManager.initModule("test allEffectsFinished"); + + const effect1 = logic.initEffect( + () => new Promise((resolve) => setTimeout(resolve, 3)) + ); + const effect2 = logic.initEffect( + () => new Promise((resolve) => setTimeout(resolve, 2)) + ); + const effect3 = logic.initEffect( + () => new Promise((resolve) => setTimeout(resolve, 1)) + ); + + const testSubscribe1 = vi.fn(); + effect1.subscribe(() => { + effect2.run(); + testSubscribe1(); + }); + + const testSubscribe2 = vi.fn(); + effect2.subscribe(() => { + effect3.run(); + testSubscribe2(); + }); + + const testSubscribe3 = vi.fn(); + effect3.subscribe(testSubscribe3); + + effect1.run(); + + await StateManager.waitForEffects(); + + expect(testSubscribe1).toHaveBeenCalled(); + expect(testSubscribe2).toHaveBeenCalled(); + expect(testSubscribe3).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/state-manager.ts b/src/state-manager.ts new file mode 100644 index 0000000..ffdcfce --- /dev/null +++ b/src/state-manager.ts @@ -0,0 +1,61 @@ +import { StateManagerModule } from "./state-manager-module"; +import { Entity } from "./entities/services"; +import { isEffect } from "./type-guard"; +import { SvitoreError } from "./utils"; + +class StateManager { + constructor() { + throw new SvitoreError("StateManager is singleton"); + } + + private static modules: StateManagerModule[] = []; + + static waitForEffects(): Promise { + const unsubscribeList: (() => void)[] = []; + + const waitIfNeeded = async (): Promise => { + const pendingEffects = Entity.ENTITIES.filter( + (entity) => isEffect(entity) && entity.isPending.get() + ); + + if (!pendingEffects.length) { + unsubscribeList.forEach((unsubscribe) => unsubscribe()); + return Promise.resolve(); + } + + await Promise.all( + pendingEffects.map( + (effect) => + new Promise((resolve) => { + unsubscribeList.push(effect.subscribe(resolve)); + }) + ) + ); + + return waitIfNeeded(); + }; + + return waitIfNeeded(); + } + + static initModule(name: T): StateManagerModule { + const newModule = new StateManagerModule(name); + this.modules.push(newModule); + + return newModule; + } + + static resetState(): void { + this.modules.forEach((module) => { + module.resetState(); + }); + } + + static release(): void { + this.modules.forEach((module) => { + module.release(); + }); + } +} + +export { StateManager }; diff --git a/src/tools/all-effects-finished.test.ts b/src/tools/all-effects-finished.test.ts deleted file mode 100644 index 9de6956..0000000 --- a/src/tools/all-effects-finished.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; - -import { Effect } from "../effect"; -import { allEffectsFinished } from "./all-effects-finished"; - -describe("allEffectsFinished", () => { - it("should wait pending effect", async () => { - const effect = new Effect(() => Promise.resolve()); - const testSubscribe = vi.fn(); - effect.subscribe(testSubscribe); - effect.run(); - - await allEffectsFinished(); - - expect(testSubscribe).toHaveBeenCalled(); - }); - - it("should wait all pending effects", async () => { - const effect1 = new Effect( - () => new Promise((resolve) => setTimeout(resolve, 3)) - ); - const effect2 = new Effect( - () => new Promise((resolve) => setTimeout(resolve, 2)) - ); - const effect3 = new Effect( - () => new Promise((resolve) => setTimeout(resolve, 1)) - ); - - const testSubscribe1 = vi.fn(); - effect1.subscribe(() => { - effect2.run(); - testSubscribe1(); - }); - - const testSubscribe2 = vi.fn(); - effect2.subscribe(() => { - effect3.run(); - testSubscribe2(); - }); - - const testSubscribe3 = vi.fn(); - effect3.subscribe(testSubscribe3); - - effect1.run(); - - await allEffectsFinished(); - - expect(testSubscribe1).toHaveBeenCalled(); - expect(testSubscribe2).toHaveBeenCalled(); - expect(testSubscribe3).toHaveBeenCalled(); - }); -}); diff --git a/src/tools/all-effects-finished.ts b/src/tools/all-effects-finished.ts deleted file mode 100644 index 430da8d..0000000 --- a/src/tools/all-effects-finished.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Entity } from "../shared"; -import { Effect } from "../effect"; - -const allEffectsFinished = async (): Promise => { - const unsubscribeList: (() => void)[] = []; - - const worker = async (): Promise => { - const pendingEffects = Entity.createdEntities.filter( - (entity) => entity instanceof Effect && entity.isPending.get() - ) as Effect[]; - - if (!pendingEffects.length) { - unsubscribeList.forEach((unsubscribe) => unsubscribe()); - return Promise.resolve(); - } - - await Promise.all( - pendingEffects.map( - (effect) => - new Promise((resolve) => { - unsubscribeList.push(effect.subscribe(resolve)); - }) - ) - ); - - return worker(); - }; - - return worker(); -}; - -export { allEffectsFinished }; diff --git a/src/tools/index.ts b/src/tools/index.ts deleted file mode 100644 index 21e054f..0000000 --- a/src/tools/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./all-effects-finished"; diff --git a/src/type-guard.ts b/src/type-guard.ts new file mode 100644 index 0000000..d086cdf --- /dev/null +++ b/src/type-guard.ts @@ -0,0 +1,21 @@ +import { ComputedState, Effect } from "./entities"; +import { Entity } from "./entities/services"; +import { State } from "./entities"; + +const isState = (entity: Entity): entity is State => { + return entity instanceof State; +}; + +const isComputedState = >, T>( + entity: Entity +): entity is ComputedState => { + return entity instanceof ComputedState; +}; + +const isEffect = ( + entity: Entity +): entity is Effect => { + return entity instanceof Effect; +}; + +export { isState, isEffect, isComputedState }; diff --git a/src/shared/types.ts b/src/types.ts similarity index 78% rename from src/shared/types.ts rename to src/types.ts index 354820c..d861ea9 100644 --- a/src/shared/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ -import { State } from "../state"; -import { Entity } from "./entity"; +import { State } from "./entities"; +import { Entity } from "./entities/services"; type ExtractEntitiesTypes>> = { [K in keyof T]: T[K] extends Entity ? U : never; diff --git a/src/shared/batch.ts b/src/utils/batch.ts similarity index 100% rename from src/shared/batch.ts rename to src/utils/batch.ts diff --git a/src/shared/error.ts b/src/utils/error.ts similarity index 83% rename from src/shared/error.ts rename to src/utils/error.ts index 4cd0c05..8d98582 100644 --- a/src/shared/error.ts +++ b/src/utils/error.ts @@ -1,4 +1,4 @@ -import { PACKAGE_LABEL } from "./constants"; +import { PACKAGE_LABEL } from "../constants"; class SvitoreError extends Error { constructor(message: string) { diff --git a/src/utils/generateId.ts b/src/utils/generateId.ts new file mode 100644 index 0000000..8b93dfa --- /dev/null +++ b/src/utils/generateId.ts @@ -0,0 +1,11 @@ +function* idGenerator(): Generator { + let id = 0; + + while (true) { + yield ++id; + } +} + +const generateId = idGenerator(); + +export { generateId }; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..90574ac --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export { SvitoreError, logError } from "./error"; +export { createBatchFunction } from "./batch"; +export { generateId } from "./generateId";