diff options
| -rw-r--r-- | src/shapex.test.ts | 173 | ||||
| -rw-r--r-- | src/shapex.ts | 103 |
2 files changed, 243 insertions, 33 deletions
diff --git a/src/shapex.test.ts b/src/shapex.test.ts index c57e676..00cf1e6 100644 --- a/src/shapex.test.ts +++ b/src/shapex.test.ts @@ -29,6 +29,38 @@ describe("subscribe", () => { }); }); +describe("subscribe: async", () => { + it("subscribes to an event", () => { + const $ = ShapeX({ counter: 1 }); + const id = $.subscribe("test-event", async (state) => + Promise.resolve({ state }), + ); + + 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 }), + ); + + expect(id).toBe(1); + expect($.subscriptionCount("test-event")).toBe(1); + }); + + it("unsubscribes from an event", () => { + const $ = ShapeX({ counter: 1 }); + + $.subscribe("test-event", async (state) => Promise.resolve({ state })); + expect($.subscriptionCount("test-event")).toBe(1); + + $.unsubscribe("test-event"); + expect($.subscriptionCount("test-event")).toBe(0); + }); +}); + describe("dispatch", () => { it("dispatches an event without arguments", () => { type AppState = { @@ -174,7 +206,7 @@ describe("dispatch", () => { // This callback receives ChildEventData const childEventCb: EventCallback<AppState, ChildEventData> = ( state, - data + data, ) => ({ state: data ? { ...state, counter: data.message.length } : state, }); @@ -204,7 +236,7 @@ describe("dispatch", () => { // Child event should be called with the child event data expect(spyChildCb).toHaveBeenCalledWith( { counter: 1 }, - { message: "ID 123 processed" } + { message: "ID 123 processed" }, ); // State should be updated based on the message length @@ -212,6 +244,143 @@ describe("dispatch", () => { }); }); +describe("dispatch: async", () => { + it("dispatches an event without arguments", () => { + type AppState = { + counter: number; + }; + + const $ = ShapeX<AppState>({ counter: 1 }); + const cb: EventCallback<AppState> = async (state) => + Promise.resolve({ state }); + const spyCb = vi.fn(cb); + + $.subscribe("test-event", spyCb); + $.dispatch("test-event"); + + expect(spyCb).toHaveBeenCalledWith({ counter: 1 }); + }); + + it("dispatches an event with arguments", () => { + type AppState = { + counter: number; + }; + + const $ = ShapeX<AppState>({ counter: 1 }); + + const testEventCb: EventCallback<AppState, string> = async (state, _) => + Promise.resolve({ + state, + }); + + const callback = vi.fn(testEventCb); + + $.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); + }); +}); + describe("state change detection", () => { it("detects value changes in state", () => { type AppState = { diff --git a/src/shapex.ts b/src/shapex.ts index ca7ffaa..09f0c0c 100644 --- a/src/shapex.ts +++ b/src/shapex.ts @@ -31,7 +31,14 @@ export type EventCallback< T, W extends unknown = undefined, D extends unknown = undefined, -> = (state: T, data?: W) => SubscriptionResponse<T, D> | void; +> = ( + state: T, + data?: W, +) => + | SubscriptionResponse<T, D> + | Promise<SubscriptionResponse<T, D>> + | void + | Promise<void>; type Subscription< T, @@ -226,6 +233,49 @@ 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. * @@ -253,42 +303,33 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> { W, unknown >; - const response = withData ? callback(_state, withData) : callback(_state); - // 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; + let response = withData ? callback(_state, withData) : callback(_state); - for (let i = 0; i < changes.length; i++) { - dispatch(changes[i]); - } - } + // Async response + if (response instanceof Promise) { + response.then((result) => { + if (!result) return; - // 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); - } - } + dispatcher( + result, + subscription, + callbackCount, + remainingSubscriptions, + ); + }); } - callbackCount++; + // Sync response + else { + if (!response) return; - if (!subscription.once) { - remainingSubscriptions.push(subscription); + dispatcher( + response, + subscription, + callbackCount, + remainingSubscriptions, + ); } } |
