diff options
| author | Asko Nõmm <asko@nmm.ee> | 2025-07-16 11:41:19 +0300 |
|---|---|---|
| committer | Asko Nõmm <asko@nmm.ee> | 2025-07-16 11:41:19 +0300 |
| commit | f04e847f5b38bbfe5a404cab25e1235329b2234b (patch) | |
| tree | 83a2778ee8492dc54dc2e8898879a65a613cbf38 | |
| parent | bd2889c8acaf95b79266c8c88b76a4af9a6bae95 (diff) | |
Refactor to not use return objects for state changed or dispatches which leads to dual way of doing things, and adds needless complexity.
| -rw-r--r-- | README.md | 169 | ||||
| -rw-r--r-- | src/shapex.test.ts | 416 | ||||
| -rw-r--r-- | src/shapex.ts | 292 |
3 files changed, 311 insertions, 566 deletions
@@ -1,13 +1,17 @@ # ShapeX -Create scalable event-driven applications with ShapeX, inspired by [re-frame](https://github.com/day8/re-frame/). ShapeX uses zero dependencies and is runtime agnostic, meaning that you can use it in Node, Deno, Bun, browsers, or really anywhere where JavaScript runs. +Create scalable event-driven applications with ShapeX, inspired by [re-frame](https://github.com/day8/re-frame/). ShapeX +uses zero dependencies and is runtime agnostic, meaning that you can use it in Node, Deno, Bun, browsers, or really +anywhere where JavaScript runs. ## Example application -This is an example application that demonstrates how to use the ShapeX library. It has a single starting point event called `request`, which returns an updated state, which changes the `counter`. When that state changes, the subscriber for the `counter` state fires. +This is an example application that demonstrates how to use the ShapeX library. It has a single starting point event +called `request`, which returns an updated state, which changes the `counter`. When that state changes, the subscriber +for the `counter` state fires. ```typescript -import { ShapeX } from "shapex"; +import {ShapeX} from "shapex"; type AppState = { counter: number; @@ -19,19 +23,13 @@ const app = ShapeX<AppState>({ app.subscribe("$.counter", (state) => { console.log("counter changed", state); - - return { - state, - }; }); app.subscribe("request", (state) => { - return { - state: { - ...state, - counter: state.counter + 1; - } - } + app.setState({ + ...state, + counter: state.counter + 1 + }); }); // Dispatch an event somewhere. @@ -51,7 +49,7 @@ npm install shapex At the core of your application is state. You start by initiating ShapeX with some initial state, like so: ```typescript -import { ShapeX } from "shapex"; +import {ShapeX} from "shapex"; type AppState = { counter: number; @@ -69,162 +67,85 @@ You can model your `AppState` however you like. It does not have to be called `A Events set things in motion. You can dispatch events like so: ```typescript -app.dispatch("some-event-name"); +app.dispatch("some-topic-name"); ``` -And, if there's a subscription for that event name, that subscription will then fire. The above example is a data-less event, but you can also dispatch events with data, like so: +And, if there's any subscriptions for that topic, those subscriptions will then fire their event listeners. +The above example is an event with no payload, but you can also dispatch events with payload, like so: ```typescript -app.dispatch("some-event-name", { +app.dispatch("some-topic-name", { hello: "world", }); ``` ### Subscriptions -Subscriptions listen to events or changes to state. Each subscription must return a `SubscriptionResponse` object, which looks like this: - -```typescript -{ - state: T, // optional - dispatch: { - to: "event-to-dispatch", - with: {} // optional - } // optional -} -``` - -You can also dispatch multiple events by passing an array of objects, like so: - -```typescript -{ - state: T, - dispatch: [{ - to: "event-to-dispatch", - with: {} - },{ - to: "another-event-to-dispatch", - with: {} - }] -} -``` - -#### Event subscriptions - You can listen to events like so: ```typescript -app.subscribe("some-event-name", (state, data: <{hello: string}>) => { - return { - state, - }; +app.subscribe("some-event-name", (state, payload) => { + // do something with the payload }); ``` -Each subscription has a callback function which gets passed to it the app state and whatever data was passed -when the event was dispatched. Subscription callbacks must return an `Response` which consists of updated state and/or further event dispatches. If you don't want to update state, just return the same state that the callback got in the first place. +Each subscription has a callback function (event listener) which gets passed to it the app state and whatever payload +was passed when the event was dispatched. In other words, subscriptions take a `EventListener<TState, TPayload>` +function where `TState` is the app state, `TPayload` is the data sent via the `dispatch` method. #### State change subscriptions -You can also listen to state changes with subscriptions, which will fire when the listened state changes. You can listen to state changes like so: +You can also listen to state changes with subscriptions, which will fire when the listened state changes. You can listen +to state changes like so: ```typescript app.subscribe("$.counter", (state) => { - return { - state, - }; + // state.counter changed }); ``` -Notable difference here is the `$.` prefix in the subscription listener name, which tells ShapeX what state to look for. Here `$.counter` will look for the root-level `counter` key in state. To look for nested state, simply add a dot (`.`) followed by the key name, i.e: `$.counter.nestedKey`. Additionally, state change subscriptions do not get any additional data passed to them, only state. +Notable difference here is the `$.` prefix in the subscription listener name, which tells ShapeX what state to look for. +Here `$.counter` will look for the root-level `counter` key in state. To look for nested state, simply add a dot (`.`) +followed by the key name, i.e: `$.counter.nestedKey`. Additionally, state change subscriptions do not get any additional +data passed to them, only state, or in other words they are of `EventListener<TState>` type. #### Subscribe only once -If you want to subscribe to an event or state change only once, you can use the `subscribeOnce` method. This method works similarly to `subscribe`, but it will automatically unsubscribe after the first event or state change. +If you want to subscribe to an event or state change only once, you can use the `subscribeOnce` method. This method +works similarly to `subscribe`, but it will automatically unsubscribe after the first event or state change. ```typescript app.subscribeOnce("$.counter", (state) => { - return { - state, - }; + // This will run only once. }); ``` #### Unsubscribe -If you want to unsubscribe from an event or state change, you can use the `unsubscribe` method. This method takes the event or state change name as its argument and removes the subscription. +If you want to unsubscribe from an event or state change, you can use the `unsubscribe` method. This method takes the +event or state change name as its argument and removes the subscription. ```typescript -app.unsubscribe("counter++"); +app.unsubscribe("some-topic-name"); ``` -#### Change state +### Updating state -You can change state by returning a new state object, like so: +You can update state with the `setState` method: ```typescript app.subscribe("counter++", (state) => { - return { - state: { - ...state, - counter: state.counter + 1, - }, - }; + app.setState({ + ...state, + counter: state.counter + 1 + }); }); ``` -#### Dispatch events from subscriptions - -You can also dispatch events from within subscriptions, like so: - -```typescript -app.subscribe("counter++", (state) => { - return { - state: { - ...state, - counter: state.counter + 1, - }, - }; -}); - -app.subscribe("some-event-name", (state) => { - return { - state, - dispatch: { - to: "counter++", - }, - }; -}); -``` - -Now if `some-event-name` is dispatched, it also dispatches `counter++`. You can also pass data along, like so: - -```typescript -app.subscribe("counter-increase", (state, increase: number) => { - return { - state: { - ...state, - counter: state.counter + increase, - }, - }; -}); - -app.subscribe("some-event-name", (state) => { - return { - state, - dispatch: { - to: "counter-increase", - with: 5, - }, - }; -}); -``` - -So now if `some-event-name` is dispatched, it also dispatches `counter-increase` with an increase of 5. - #### Get the subscription count -If you want to get the number of subscriptions for a specific event or state change, you can use the `subscriptionCount` method. This method takes the event or state change name as its argument and returns the number of subscriptions. +If you want to get the number of subscriptions for a specific event or state change, you can use the `subscriptionCount` +method. This method takes the event or state change name as its argument and returns the number of subscriptions. ```typescript // State change subscriptions @@ -236,7 +157,8 @@ app.subscriptionCount("some-event-name"); #### Get all subscriptions -If you want to get all subscriptions, you can use the `subscriptions` method. This method returns an array of all the subscription names. +If you want to get all subscriptions, you can use the `subscriptions` method. This method returns an array of all the +subscription names. ```typescript app.subscriptions(); @@ -244,7 +166,8 @@ app.subscriptions(); #### Get current app state -If you want to get the current state of the app, you can use the `state` method. This method returns the current state of the app. +If you want to get the current state of the app, you can use the `state` method. This method returns the current state +of the app. ```typescript app.state(); diff --git a/src/shapex.test.ts b/src/shapex.test.ts index 00cf1e6..83715c2 100644 --- a/src/shapex.test.ts +++ b/src/shapex.test.ts @@ -1,27 +1,30 @@ -import { describe, it, expect, vi } from "vitest"; -import { ShapeX, type EventCallback } from "./shapex.ts"; +import {describe, it, expect, vi} from "vitest"; +import {ShapeX, EventListener} from "./shapex.ts"; describe("subscribe", () => { it("subscribes to an event", () => { - const $ = ShapeX({ counter: 1 }); - const id = $.subscribe("test-event", (state) => ({ state })); + const $ = ShapeX({counter: 1}); + const id = $.subscribe("test-event", () => { + }); expect(id).toBe(1); expect($.subscriptionCount("test-event")).toBe(1); }); it("subscribes to an event once", () => { - const $ = ShapeX({ counter: 1 }); - const id = $.subscribeOnce("test-event", (state) => ({ state })); + const $ = ShapeX({counter: 1}); + const id = $.subscribeOnce("test-event", () => { + }); expect(id).toBe(1); expect($.subscriptionCount("test-event")).toBe(1); }); it("unsubscribes from an event", () => { - const $ = ShapeX({ counter: 1 }); + const $ = ShapeX({counter: 1}); - $.subscribe("test-event", (state) => ({ state })); + $.subscribe("test-event", () => { + }); expect($.subscriptionCount("test-event")).toBe(1); $.unsubscribe("test-event"); @@ -31,29 +34,28 @@ describe("subscribe", () => { describe("subscribe: async", () => { it("subscribes to an event", () => { - const $ = ShapeX({ counter: 1 }); - const id = $.subscribe("test-event", async (state) => - Promise.resolve({ state }), - ); + const $ = ShapeX({counter: 1}); + const id = $.subscribe("test-event", async () => { + }); expect(id).toBe(1); expect($.subscriptionCount("test-event")).toBe(1); }); it("subscribes to an event once", () => { - const $ = ShapeX({ counter: 1 }); - const id = $.subscribeOnce("test-event", async (state) => - Promise.resolve({ state }), - ); + const $ = ShapeX({counter: 1}); + const id = $.subscribeOnce("test-event", async () => { + }); expect(id).toBe(1); expect($.subscriptionCount("test-event")).toBe(1); }); it("unsubscribes from an event", () => { - const $ = ShapeX({ counter: 1 }); + const $ = ShapeX({counter: 1}); - $.subscribe("test-event", async (state) => Promise.resolve({ state })); + $.subscribe("test-event", async () => { + }); expect($.subscriptionCount("test-event")).toBe(1); $.unsubscribe("test-event"); @@ -67,14 +69,14 @@ describe("dispatch", () => { counter: number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(() => { + }); $.subscribe("test-event", spyCb); $.dispatch("test-event"); - expect(spyCb).toHaveBeenCalledWith({ counter: 1 }); + expect(spyCb).toHaveBeenCalledWith({counter: 1}); }); it("dispatches an event with arguments", () => { @@ -82,18 +84,14 @@ describe("dispatch", () => { counter: number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - - const testEventCb: EventCallback<AppState, string> = (state, data) => ({ - state, + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(() => { }); - const callback = vi.fn(testEventCb); - - $.subscribe("test-event", callback); + $.subscribe("test-event", spyCb); $.dispatch("test-event", "arg1-value"); - expect(callback).toHaveBeenCalledWith({ counter: 1 }, "arg1-value"); + expect(spyCb).toHaveBeenCalledWith({counter: 1}, "arg1-value"); }); it("updates state when event handler returns new state", () => { @@ -101,19 +99,18 @@ describe("dispatch", () => { counter: number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(() => { + }); $.subscribe("$.counter", spyCb); - - $.subscribe("increment", (state) => ({ - state: { ...state, counter: state.counter + 1 }, - })); + $.subscribe("increment", (state) => { + $.setState({...state, counter: state.counter + 1}) + }); $.dispatch("increment"); - expect(spyCb).toHaveBeenCalledWith({ counter: 2 }); + expect(spyCb).toHaveBeenCalledWith({counter: 2}); }); it("dispatches nested events", () => { @@ -121,16 +118,15 @@ describe("dispatch", () => { counter: number; }; - const $ = ShapeX({ counter: 1 }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(() => { + }); $.subscribe("nested-event", spyCb); - $.subscribe("parent-event", (state) => ({ - state, - dispatch: { to: "nested-event" }, - })); + $.subscribe("parent-event", () => { + $.dispatch("nested-event") + }); $.dispatch("parent-event"); @@ -142,8 +138,9 @@ describe("dispatch", () => { counter: number; }; - const $ = ShapeX({ counter: 1 }); - const cb: EventCallback<AppState> = (state) => ({ state }); + const $ = ShapeX({counter: 1}); + const cb: EventListener<AppState> = () => { + }; const spyCb = vi.fn(cb); const spyCb2 = vi.fn(cb); @@ -151,15 +148,10 @@ describe("dispatch", () => { $.subscribe("nested-event-2", spyCb2); - $.subscribe("parent-event", (state) => ({ - state, - dispatch: [ - { to: "nested-event-1" }, - { - to: "nested-event-2", - }, - ], - })); + $.subscribe("parent-event", () => { + $.dispatch("nested-event-1"); + $.dispatch("nested-event-2"); + }); $.dispatch("parent-event"); @@ -172,20 +164,19 @@ describe("dispatch", () => { counter: number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - const cb: EventCallback<AppState, string> = (state, arg) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(() => { + }); $.subscribe("nested-event", spyCb); - $.subscribe("parent-event", (state) => ({ - state, - dispatch: { to: "nested-event", with: "arg-value" }, - })); + $.subscribe("parent-event", () => { + $.dispatch("nested-event", "arg-value"); + }); $.dispatch("parent-event"); - expect(spyCb).toHaveBeenCalledWith({ counter: 1 }, "arg-value"); + expect(spyCb).toHaveBeenCalledWith({counter: 1}, "arg-value"); }); it("supports different data types for event callback and dispatch", () => { @@ -193,50 +184,47 @@ describe("dispatch", () => { counter: number; }; - type ParentEventData = { + type ParentEventPayload = { id: number; }; - type ChildEventData = { + type ChildEventPayload = { message: string; }; - const $ = ShapeX<AppState>({ counter: 1 }); + const $ = ShapeX<AppState>({counter: 1}); - // This callback receives ChildEventData - const childEventCb: EventCallback<AppState, ChildEventData> = ( + // This callback receives ChildEventPayload + const childEventCb: EventListener<AppState, ChildEventPayload> = ( state, - data, - ) => ({ - state: data ? { ...state, counter: data.message.length } : state, - }); + payload, + ) => { + if (payload) { + $.setState({...state, counter: payload.message.length}); + } + }; const spyChildCb = vi.fn(childEventCb); $.subscribe("child-event", spyChildCb); - // This callback receives ParentEventData but dispatches ChildEventData - const parentEventCb: EventCallback< + // This callback receives ParentEventPayload + const parentEventCb: EventListener< AppState, - ParentEventData, - ChildEventData - > = (state, data) => ({ - state, - dispatch: { - to: "child-event", - with: { message: `ID ${data?.id ?? 0} processed` }, - }, - }); + ParentEventPayload + > = (_state, payload) => { + $.dispatch("child-event", {message: `ID ${payload?.id ?? 0} processed`}); + }; $.subscribe("parent-event", parentEventCb); // Dispatch with parent event data - $.dispatch("parent-event", { id: 123 }); + $.dispatch("parent-event", {id: 123}); // Child event should be called with the child event data expect(spyChildCb).toHaveBeenCalledWith( - { counter: 1 }, - { message: "ID 123 processed" }, + {counter: 1}, + {message: "ID 123 processed"}, ); // State should be updated based on the message length @@ -250,15 +238,14 @@ describe("dispatch: async", () => { counter: number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - const cb: EventCallback<AppState> = async (state) => - Promise.resolve({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(async () => { + }); $.subscribe("test-event", spyCb); $.dispatch("test-event"); - expect(spyCb).toHaveBeenCalledWith({ counter: 1 }); + expect(spyCb).toHaveBeenCalledWith({counter: 1}); }); it("dispatches an event with arguments", () => { @@ -266,118 +253,14 @@ describe("dispatch: async", () => { counter: number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - - const testEventCb: EventCallback<AppState, string> = async (state, _) => - Promise.resolve({ - state, - }); - - const callback = vi.fn(testEventCb); + const $ = ShapeX<AppState>({counter: 1}); + const callback = vi.fn(() => { + }); $.subscribe("test-event", callback); $.dispatch("test-event", "arg1-value"); - expect(callback).toHaveBeenCalledWith({ counter: 1 }, "arg1-value"); - }); - - it("updates state when event handler returns new state", async () => { - type AppState = { - counter: number; - }; - - const $ = ShapeX<AppState>({ counter: 1 }); - - const state = await vi.waitFor( - () => { - return new Promise((resolve) => { - $.subscribe("$.counter", (state) => { - resolve(state); - return { state }; - }); - - $.subscribe("increment", async (state) => - Promise.resolve({ - state: { ...state, counter: state.counter + 1 }, - }), - ); - - $.dispatch("increment"); - }); - }, - { - timeout: 1000, - interval: 100, - }, - ); - - expect(state).toStrictEqual({ counter: 2 }); - }); - - it("dispatches nested events", async () => { - type AppState = { - counter: number; - }; - - const $ = ShapeX({ counter: 1 }); - - const state = await vi.waitFor(() => { - return new Promise((resolve) => { - $.subscribe("nested-event", (state) => { - resolve(true); - return { state }; - }); - - $.subscribe("parent-event", (state) => ({ - state, - dispatch: { to: "nested-event" }, - })); - - $.dispatch("parent-event"); - }); - }); - - expect(state).toBe(true); - }); - - it("dispatches multiple nested events", async () => { - type AppState = { - counter: number; - }; - - const $ = ShapeX({ counter: 1 }); - - const state = await vi.waitFor(() => { - return new Promise((resolve) => { - let count = 0; - - $.subscribe("nested-event-1", (state) => { - count++; - return { state }; - }); - - $.subscribe("nested-event-2", (state) => { - resolve(count + 1); - return { state }; - }); - - $.subscribe("parent-event", async (state) => - Promise.resolve({ - state, - dispatch: [ - { to: "nested-event-1" }, - { - to: "nested-event-2", - }, - ], - }), - ); - - $.dispatch("parent-event"); - }); - }); - - expect(state).toBe(2); + expect(callback).toHaveBeenCalledWith({counter: 1}, "arg1-value"); }); }); @@ -390,21 +273,21 @@ describe("state change detection", () => { }; }; - const $ = ShapeX<AppState>({ counter: 1, nested: { value: "test" } }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1, nested: {value: "test"}}); + const spyCb = vi.fn(() => { + }); $.subscribe("$.counter", spyCb); - $.subscribe("change-counter", (state) => ({ - state: { ...state, counter: 2 }, - })); + $.subscribe("change-counter", (state) => { + $.setState({...state, counter: 2}); + }); $.dispatch("change-counter"); expect(spyCb).toHaveBeenCalledWith({ counter: 2, - nested: { value: "test" }, + nested: {value: "test"}, }); }); @@ -416,32 +299,26 @@ describe("state change detection", () => { }; }; - const $ = ShapeX<AppState>({ counter: 1, nested: { value: "test" } }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1, nested: {value: "test"}}); + const spyCb = vi.fn(() => { + }); $.subscribe("$.nested.value", spyCb); - $.subscribe("change-nested-value", (state) => ({ - state: { - ...state, - nested: { ...state.nested, value: "new value" }, - }, - })); + $.subscribe("change-nested-value", (state) => { + $.setState({...state, nested: {...state.nested, value: "new value"}}); + }); - $.subscribe("change-nested-value-again", (state) => ({ - state: { - ...state, - nested: { ...state.nested, value: "new value again" }, - }, - })); + $.subscribe("change-nested-value-again", (state) => { + $.setState({...state, nested: {...state.nested, value: "new value again"}}); + }); $.dispatch("change-nested-value"); $.dispatch("change-nested-value-again"); expect(spyCb).toHaveBeenCalledWith({ counter: 1, - nested: { value: "new value" }, + nested: {value: "new value"}, }); }); @@ -451,18 +328,13 @@ describe("state change detection", () => { }; const $ = ShapeX<AppState>({}); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const spyCb = vi.fn(() => { + }); $.subscribe("$.view", spyCb); $.subscribe("set-view", (state) => { - return { - state: { - ...state, - view: "test", - }, - }; + $.setState({...state, view: "test",}) }); $.dispatch("set-view"); @@ -478,7 +350,8 @@ describe("state change detection", () => { }; const $ = ShapeX<AppState>({}); - const cb: EventCallback<AppState> = (state) => ({ state }); + const cb: EventListener<AppState> = () => { + }; const spyCb = vi.fn(cb); const spyCb2 = vi.fn(cb); @@ -486,21 +359,21 @@ describe("state change detection", () => { $.subscribe("$.nested.value", spyCb2); $.subscribe("set-nested-value", (state) => { - return { - state: { - ...state, - nested: { value: "test" }, - }, - }; + $.setState({ + ...state, + nested: { + value: "test" + } + }) }); $.subscribe("set-nested-value-again", (state) => { - return { - state: { - ...state, - nested: { value: "test-again" }, - }, - }; + $.setState({ + ...state, + nested: { + value: "test-again" + } + }) }); $.dispatch("set-nested-value"); @@ -516,14 +389,15 @@ describe("state change detection", () => { toDelete?: string; }; - const $ = ShapeX<AppState>({ counter: 1, toDelete: "value" }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1, toDelete: "value"}); + const spyCb = vi.fn(() => { + }); $.subscribe("$.toDelete", spyCb); $.subscribe("delete-property", (state) => { - const newState = { counter: state.counter }; - return { state: newState }; + $.setState({ + counter: state.counter + }) }); $.dispatch("delete-property"); @@ -536,14 +410,17 @@ describe("state change detection", () => { counter: string | number; }; - const $ = ShapeX<AppState>({ counter: 1 }); - const cb: EventCallback<AppState> = (state) => ({ state }); - const spyCb = vi.fn(cb); + const $ = ShapeX<AppState>({counter: 1}); + const spyCb = vi.fn(() => { + }); $.subscribe("$.counter", spyCb); - $.subscribe("change-counter-type", (state) => ({ - state: { ...state, counter: "string now" }, - })); + $.subscribe("change-counter-type", (state) => { + $.setState({ + ...state, + counter: "string now" + }) + }); $.dispatch("change-counter-type"); @@ -553,10 +430,12 @@ describe("state change detection", () => { describe("utility methods", () => { it("returns all subscription names", () => { - const $ = ShapeX({ counter: 1 }); + const $ = ShapeX({counter: 1}); - $.subscribe("event1", (state) => ({ state })); - $.subscribe("event2", (state) => ({ state })); + $.subscribe("event1", () => { + }); + $.subscribe("event2", () => { + }); const subs = $.subscriptions(); @@ -566,22 +445,27 @@ describe("utility methods", () => { }); it("returns subscription count for specific event", () => { - const $ = ShapeX({ counter: 1 }); + const $ = ShapeX({counter: 1}); - $.subscribe("event1", (state) => ({ state })); - $.subscribe("event1", (state) => ({ state })); - $.subscribe("event2", (state) => ({ state })); + $.subscribe("event1", () => { + }); + $.subscribe("event1", () => { + }); + $.subscribe("event2", () => { + }); expect($.subscriptionCount("event1")).toBe(2); expect($.subscriptionCount("event2")).toBe(1); }); it("returns updated state", () => { - const $ = ShapeX({ counter: 1 }); + const $ = ShapeX({counter: 1}); - $.subscribe("event1", (state) => ({ - state: { counter: state.counter + 1 }, - })); + $.subscribe("event1", (state) => { + $.setState({ + counter: state.counter + 1 + }) + }); $.dispatch("event1"); diff --git a/src/shapex.ts b/src/shapex.ts index 3ff67a8..fa9047f 100644 --- a/src/shapex.ts +++ b/src/shapex.ts @@ -1,78 +1,55 @@ /** - * Dispatches an event with a given name and passes on - * given arguments to it. - */ -export type SubscriptionResponseDispatch<W extends unknown = undefined> = { - to: string; - with?: W; -}; - -/** - * A response of the subscription callback. Should return new state - * if you want to update state, and/or optionally also any events you - * might want to dispatch. - */ -export type SubscriptionResponse<T, D extends unknown = undefined> = { - state?: T; - dispatch?: - | SubscriptionResponseDispatch<D> - | SubscriptionResponseDispatch<D>[]; -}; - -const isSubscriptionResponseList = <W extends unknown = undefined>( - dispatch: SubscriptionResponseDispatch<W> | SubscriptionResponseDispatch<W>[], -): dispatch is SubscriptionResponseDispatch<W>[] => Array.isArray(dispatch); - -/** * A callback passed to subscriptions, called when the event * that the subscription is listening to is called. */ -export type EventCallback< - T, - W extends unknown = undefined, - D extends unknown = undefined, +export type EventListener< + TState, + TPayload extends unknown = undefined, > = ( - state: T, - data?: W, -) => - | SubscriptionResponse<T, D> - | Promise<SubscriptionResponse<T, D>> - | void - | Promise<void>; + state: TState, + payload?: TPayload, +) => void | Promise<void>; type Subscription< - T, - W extends unknown = undefined, - D extends unknown = undefined, + TState, + TPayload extends unknown = undefined, > = { - listener: string; - callback: EventCallback<T, W, D>; + callback: EventListener<TState, TPayload>; once: boolean; }; /** * An instance of the ShapeX object. */ -export type ShapeXInstance<T> = { +export type ShapeXInstance<TState> = { /** * Subscribe to an event. + * + * @param topic + * @param callback */ - subscribe: <W extends unknown = undefined, D extends unknown = undefined>( - listener: string, - callback: EventCallback<T, W, D>, + subscribe: <TPayload extends unknown = undefined>( + topic: string, + callback: EventListener<TState, TPayload>, ) => number; + /** * Subscribe to an event once. + * + * @param topic + * @param callback */ - subscribeOnce: <W extends unknown = undefined, D extends unknown = undefined>( - listener: string, - callback: EventCallback<T, W, D>, + subscribeOnce: <TPayload extends unknown = undefined>( + topic: string, + callback: EventListener<TState, TPayload>, ) => number; /** * Unsubscribe from an event. + * + * @param topic */ - unsubscribe: (listener: string) => void; + unsubscribe: (topic: string) => void; /** * Get the number of subscriptions for an event. @@ -86,102 +63,109 @@ export type ShapeXInstance<T> = { /** * Dispatch an event. + * + * @param to + * @param payload */ - dispatch: <W extends unknown>(to: string, withData?: W) => void; + dispatch: <TPayload extends unknown>(to: string, payload?: TPayload) => void; /** * Get the current state. */ - state: () => T; + state: () => TState; + + /** + * Update state. + * + * @param updatedState + */ + setState: (updatedState: TState) => void; }; /** * A function that creates an EventX object. * - * @param {T extends object} initialState The initial application state. + * @param {TState extends object} initialState The initial application state. * @returns {ShapeXInstance} The ShapeX object. */ -export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> { +export function ShapeX<TState extends object>(initialState: TState): ShapeXInstance<TState> { let _state = initialState; - const _subscriptions: Map< - string, - Array<Subscription<T, unknown, unknown>> - > = new Map(); - let subscriptionId = 0; + let _subscriptionId = 0; + const _subscriptions: Map<string, Array<Subscription<TState, unknown>>> = new Map(); /** * Subscribe to an event. * - * @param {string} listener - * @param {EventCallback} callback + * @param topic + * @param {EventListener} callback * @returns */ const subscribe = < - W extends unknown = undefined, - D extends unknown = undefined, + TPayload extends unknown = undefined, >( - listener: string, - callback: EventCallback<T, W, D>, + topic: string, + callback: EventListener<TState, TPayload>, ): number => { - if (!_subscriptions.has(listener)) { - _subscriptions.set(listener, []); + // there are no listeners, set as empty array + if (!_subscriptions.has(topic)) { + _subscriptions.set(topic, []); } - const subscriptions = _subscriptions.get(listener); - if (subscriptions) { - subscriptions.push({ - listener, - callback: callback as unknown as EventCallback<T, unknown, unknown>, - once: false, - }); - } + // add a listener + _subscriptions.get(topic)?.push({ + callback: callback as unknown as EventListener<TState, unknown>, + once: false, + }); - return ++subscriptionId; + return ++_subscriptionId; }; /** * Subscribe to an event, once. * - * @param {string} listener - * @param {EventCallback} callback + * @param {string} topic + * @param {EventListener} callback * @returns */ - const subscribeOnce = <W extends unknown = undefined, D extends unknown = W>( - listener: string, - callback: EventCallback<T, W, D>, + const subscribeOnce = <TPayload extends unknown = undefined>( + topic: string, + callback: EventListener<TState, TPayload>, ): number => { - if (!_subscriptions.has(listener)) { - _subscriptions.set(listener, []); + // there are no listeners, set as empty array + if (!_subscriptions.has(topic)) { + _subscriptions.set(topic, []); } - const subscriptions = _subscriptions.get(listener); - if (subscriptions) { - subscriptions.push({ - listener, - callback: callback as unknown as EventCallback<T, unknown, unknown>, - once: true, - }); - } + // add a listener + _subscriptions.get(topic)?.push({ + callback: callback as unknown as EventListener<TState, unknown>, + once: true, + }); - return ++subscriptionId; + return ++_subscriptionId; }; - const unsubscribe = (listener: string): void => { - if (_subscriptions.has(listener)) { - _subscriptions.delete(listener); + /** + * Removes all listeners for a topic. + * + * @param topic + */ + const unsubscribe = (topic: string): void => { + if (_subscriptions.has(topic)) { + _subscriptions.delete(topic); } }; /** * Composes a list of changes between two states. * - * @param {T extends object} oldState - * @param {T extends object} newState + * @param {TState extends object} oldState + * @param {TState extends object} newState * @returns {string[]} The list of changes as array of paths. */ - const changedState = <T extends object>( - oldState: T, - newState: T, + const changedState = <TState extends object>( + oldState: TState, + newState: TState, ): string[] => { const paths = <R extends object>( state: R, @@ -210,16 +194,16 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> { const newPaths = paths(newState, "$"); const newPathKeys = newPaths.map((x) => x.path); - // All new paths + // all new paths const added = newPathKeys.filter((path) => !oldPathKeys.includes(path)); - // All removed paths + // all removed paths const removed = oldPathKeys.filter((path) => !newPathKeys.includes(path)); - // Paths that remained + // paths that remained const same = oldPathKeys.filter((path) => newPathKeys.includes(path)); - // Paths that changed + // paths that changed const changed = same.filter((path) => { const oldValue = oldPaths.find((x) => x.path === path)?.value; const newValue = newPaths.find((x) => x.path === path)?.value; @@ -233,94 +217,33 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> { return differ(oldState, newState); }; - const dispatcher = ( - response: SubscriptionResponse<T, unknown>, - subscription: Subscription<T, unknown, unknown>, - callbackCount: number, - remainingSubscriptions: Subscription<T, unknown, unknown>[], - ) => { - // Updates state, and checks for state changes, and if any changes present, - // fires a dispatch for all the state listeners (if there are any). - if (response?.state !== undefined) { - const changes = changedState(_state, response.state); - _state = response.state; - - for (let i = 0; i < changes.length; i++) { - dispatch(changes[i]); - } - } - - // Dispatches events - if (response?.dispatch !== undefined) { - if (isSubscriptionResponseList(response.dispatch)) { - for (const dispatchee of response.dispatch) { - if (dispatchee?.with) { - dispatch(dispatchee.to, dispatchee.with); - } else { - dispatch(dispatchee.to); - } - } - } else { - if (response.dispatch?.with) { - dispatch(response.dispatch.to, response.dispatch.with); - } else { - dispatch(response.dispatch.to); - } - } - } - - callbackCount++; - - if (!subscription.once) { - remainingSubscriptions.push(subscription); - } - }; - /** * Dispatches an event with the given name and arguments. * * @param {string} to The name of the event to dispatch. - * @param {unknown[]} withData The arguments to pass to the event listeners. + * @param payload * @returns {void} */ - const dispatch = <W extends unknown = undefined>( + const dispatch = <TPayload extends unknown = undefined>( to: string, - withData?: W, + payload?: TPayload, ): void => { if (!_subscriptions.has(to)) return; const scopedSubscriptions = _subscriptions.get(to) ?? []; - const remainingSubscriptions = [] as Array<Subscription<T, unknown, unknown>>; - let callbackCount = 0; + const remainingSubscriptions = [] as Array<Subscription<TState, unknown>>; for (const subscription of scopedSubscriptions) { - const callback = subscription.callback as unknown as EventCallback<T, W, unknown>; - let response = withData ? callback(_state, withData) : callback(_state); - - // Async response - if (response instanceof Promise) { - response.then((result) => { - if (!result) return; - - dispatcher( - result, - subscription, - callbackCount, - remainingSubscriptions, - ); - }); - } + const callback = subscription.callback as unknown as EventListener<TState, TPayload>; - // Sync response - else { - if (!response) return; + if (payload) { + callback(_state, payload); + } else { + callback(_state); + } - dispatcher( - response, - subscription, - callbackCount, - remainingSubscriptions, - ); + if (!subscription.once) { + remainingSubscriptions.push(subscription); } } @@ -355,10 +278,24 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> { * * @returns {} The current state. */ - const state = (): T => { + const state = (): TState => { return _state; }; + /** + * Updates state. + * + * @param updatedState updated state + */ + const setState = (updatedState: TState): void => { + const changes = changedState(_state, updatedState); + _state = updatedState; + + for (let i = 0; i < changes.length; i++) { + dispatch(changes[i]); + } + } + return { subscribe, subscribeOnce, @@ -367,5 +304,6 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> { subscriptions, dispatch, state, + setState, }; } |
