diff options
| author | Asko Nõmm <asko@nmm.ee> | 2025-04-16 19:42:31 +0300 |
|---|---|---|
| committer | Asko Nõmm <asko@nmm.ee> | 2025-04-16 19:42:31 +0300 |
| commit | 1da1d5eb9d33db6cc0b59c1259888046aa0c3752 (patch) | |
| tree | 53ac6808310ea886428608f64e0d621e98f2cf55 /src | |
| parent | e12ca91f863f9c1fddd50924b2c555a5d528cd44 (diff) | |
Refactors state diffing which is now much improved.
Diffstat (limited to 'src')
| -rw-r--r-- | src/shapex.test.ts | 89 | ||||
| -rw-r--r-- | src/shapex.ts | 93 |
2 files changed, 129 insertions, 53 deletions
diff --git a/src/shapex.test.ts b/src/shapex.test.ts index aadeea7..d605c3a 100644 --- a/src/shapex.test.ts +++ b/src/shapex.test.ts @@ -6,6 +6,7 @@ describe("EventX", () => { test("subscribes to an event", () => { const $ = ShapeX({ counter: 1 }); const id = $.subscribe("test-event", (state) => ({ state })); + expect(id).toBe(1); expect($.subscriptionCount("test-event")).toBe(1); }); @@ -13,16 +14,17 @@ describe("EventX", () => { test("subscribes to an event once", () => { const $ = ShapeX({ counter: 1 }); const id = $.subscribeOnce("test-event", (state) => ({ state })); + expect(id).toBe(1); expect($.subscriptionCount("test-event")).toBe(1); - // Dispatch the event to trigger the one-time subscription $.dispatch("test-event"); expect($.subscriptionCount("test-event")).toBe(0); }); test("unsubscribes from an event", () => { const $ = ShapeX({ counter: 1 }); + $.subscribe("test-event", (state) => ({ state })); expect($.subscriptionCount("test-event")).toBe(1); @@ -58,7 +60,8 @@ describe("EventX", () => { const $ = ShapeX({ counter: 1 }); const stateChangeSpy = vi.fn((state) => ({ state })); - $.subscribe("$counter", stateChangeSpy); + $.subscribe("$.counter", stateChangeSpy); + $.subscribe("increment", (state) => ({ state: { ...state, counter: state.counter + 1 }, })); @@ -74,6 +77,7 @@ describe("EventX", () => { const nestedEventSpy = vi.fn((state) => ({ state })); $.subscribe("nested-event", nestedEventSpy); + $.subscribe("parent-event", (state) => ({ state, dispatch: { eventName: "nested-event" }, @@ -90,7 +94,9 @@ describe("EventX", () => { const nestedEvent2Spy = vi.fn((state) => ({ state })); $.subscribe("nested-event-1", nestedEvent1Spy); + $.subscribe("nested-event-2", nestedEvent2Spy); + $.subscribe("parent-event", (state) => ({ state, dispatch: [{ eventName: "nested-event-1" }, { eventName: "nested-event-2" }], @@ -107,6 +113,7 @@ describe("EventX", () => { const nestedEventSpy = vi.fn((state, arg) => ({ state })); $.subscribe("nested-event", nestedEventSpy); + $.subscribe("parent-event", (state) => ({ state, dispatch: { eventName: "nested-event", args: ["arg-value"] }, @@ -124,7 +131,8 @@ describe("EventX", () => { const $ = ShapeX({ counter: 1, nested: { value: "test" } }); const counterChangeSpy = vi.fn((state) => ({ state })); - $.subscribe("$counter", counterChangeSpy); + $.subscribe("$.counter", counterChangeSpy); + $.subscribe("change-counter", (state) => ({ state: { ...state, counter: 2 }, })); @@ -139,28 +147,90 @@ describe("EventX", () => { const $ = ShapeX({ counter: 1, nested: { value: "test" } }); const nestedValueChangeSpy = vi.fn((state) => ({ state })); - $.subscribe("$nested.value", nestedValueChangeSpy); + $.subscribe("$.nested.value", nestedValueChangeSpy); + $.subscribe("change-nested-value", (state) => ({ state: { ...state, - nested: { ...state.nested, value: "changed" }, + nested: { ...state.nested, value: "new value" }, + }, + })); + + $.subscribe("change-nested-value-again", (state) => ({ + state: { + ...state, + nested: { ...state.nested, value: "new value again" }, }, })); $.dispatch("change-nested-value"); + $.dispatch("change-nsted-value-again"); expect(nestedValueChangeSpy).toHaveBeenCalledTimes(1); expect(nestedValueChangeSpy).toHaveBeenCalledWith({ counter: 1, - nested: { value: "changed" }, + nested: { value: "new value" }, + }); + }); + + test("detects addition in state", () => { + const $ = ShapeX({} as { view?: string }); + const additionChangeSpy = vi.fn((state) => ({ state })); + + $.subscribe("$.view", additionChangeSpy); + + $.subscribe("set-view", (state) => { + return { + state: { + ...state, + view: "test", + }, + }; + }); + + $.dispatch("set-view"); + + expect(additionChangeSpy).toHaveBeenCalledTimes(1); + }); + + test("detects nested addition in state", () => { + const $ = ShapeX({} as { nested?: { value?: string } }); + const additionChangeSpy = vi.fn((state) => ({ state })); + const additionChangeSpy2 = vi.fn((state) => ({ state })); + + $.subscribe("$.nested", additionChangeSpy); + $.subscribe("$.nested.value", additionChangeSpy2); + + $.subscribe("set-nested-value", (state) => { + return { + state: { + ...state, + nested: { value: "test" }, + }, + }; + }); + + $.subscribe("set-nested-value-again", (state) => { + return { + state: { + ...state, + nested: { value: "test-again" }, + }, + }; }); + + $.dispatch("set-nested-value"); + $.dispatch("set-nested-value-again"); + + expect(additionChangeSpy).toHaveBeenCalledTimes(2); + expect(additionChangeSpy2).toHaveBeenCalledTimes(2); }); test("detects deleted properties in state", () => { const $ = ShapeX({ counter: 1, toDelete: "value" } as { counter: number; toDelete?: string }); const deleteChangeSpy = vi.fn((state) => ({ state })); - $.subscribe("$toDelete", deleteChangeSpy); + $.subscribe("$.toDelete", deleteChangeSpy); $.subscribe("delete-property", (state) => { const newState = { counter: state.counter }; return { state: newState }; @@ -175,7 +245,7 @@ describe("EventX", () => { const $ = ShapeX({ counter: 1 } as { counter: string | number }); const counterChangeSpy = vi.fn((state) => ({ state })); - $.subscribe("$counter", counterChangeSpy); + $.subscribe("$.counter", counterChangeSpy); $.subscribe("change-counter-type", (state) => ({ state: { ...state, counter: "string now" }, })); @@ -189,10 +259,12 @@ describe("EventX", () => { describe("utility methods", () => { test("returns all subscription names", () => { const $ = ShapeX({ counter: 1 }); + $.subscribe("event1", (state) => ({ state })); $.subscribe("event2", (state) => ({ state })); const subs = $.subscriptions(); + expect(subs).toContain("event1"); expect(subs).toContain("event2"); expect(subs.length).toBe(2); @@ -200,6 +272,7 @@ describe("EventX", () => { test("returns subscription count for specific event", () => { const $ = ShapeX({ counter: 1 }); + $.subscribe("event1", (state) => ({ state })); $.subscribe("event1", (state) => ({ state })); $.subscribe("event2", (state) => ({ state })); diff --git a/src/shapex.ts b/src/shapex.ts index 80a6b11..2a0a3cc 100644 --- a/src/shapex.ts +++ b/src/shapex.ts @@ -20,7 +20,7 @@ type Subscription<T> = { once: boolean; }; -type StateChange = "deleted" | "changed-type" | "changed-value"; +type StateChange = "added" | "deleted" | "changed-type" | "changed-value"; type ShapeXInstance<T> = { subscribe: (listener: string, callback: EventCallback<T>) => number; @@ -95,54 +95,57 @@ export default function ShapeX<T extends object>(initialState: T): ShapeXInstanc * * @param {T extends object} oldState * @param {T extends object} newState - * @param {string} path - * @returns {StateChange[]} The list of changes. + * @returns {string[]} The list of changes as array of paths. */ - const changedState = <T extends object>( - oldState: T, - newState: T, - path: string = "", - ): Map<string, StateChange> => { - let changes: Map<string, StateChange> = new Map(); - - for (const k in oldState) { - const currentPath = path ? `${path}.${k}` : k; - - // Missing? - if (!(k in newState)) { - if (!changes.has(currentPath)) { - changes.set(currentPath, "deleted"); - } - } + const changedState = <T extends object>(oldState: T, newState: T): string[] => { + const paths = <R extends object>( + state: R, + path: string, + ): { path: string; value: unknown }[] => { + let _paths = []; + + for (const key in state) { + const currentPath = `${path}.${key}`; + _paths.push({ + path: currentPath, + value: state[key], + }); - // Type changed? - if (typeof oldState[k] !== typeof newState[k]) { - if (!changes.has(currentPath)) { - changes.set(currentPath, "changed-type"); + if (typeof state[key] === "object" && state[key] !== null) { + _paths.push(...paths(state[key], currentPath)); } } - // Recursive object check - if ( - typeof oldState[k] === "object" && - typeof newState[k] === "object" && - oldState[k] !== null && - newState[k] !== null - ) { - changedState(oldState[k], newState[k], currentPath).forEach((v, k) => { - changes.set(k, v); - }); - } + return _paths; + }; - // Value changed? - if (JSON.stringify(oldState[k]) !== JSON.stringify(newState[k])) { - if (!changes.has(currentPath)) { - changes.set(currentPath, "changed-value"); - } - } - } + const differ = <S extends object>(oldState: S, newState: S): string[] => { + const oldPaths = paths(oldState, "$"); + const oldPathKeys = oldPaths.map((x) => x.path); + const newPaths = paths(newState, "$"); + const newPathKeys = newPaths.map((x) => x.path); - return changes; + // All new paths + const added = newPathKeys.filter((path) => !oldPathKeys.includes(path)); + + // All removed paths + const removed = oldPathKeys.filter((path) => !newPathKeys.includes(path)); + + // Paths that remained + const same = oldPathKeys.filter((path) => newPathKeys.includes(path)); + + // 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; + + return oldValue !== newValue; + }); + + return [...new Set([...added, ...removed, ...changed])]; + }; + + return differ(oldState, newState); }; /** @@ -170,9 +173,9 @@ export default function ShapeX<T extends object>(initialState: T): ShapeXInstanc const changes = changedState(_state, response.state); _state = response.state; - changes.forEach((_, v) => { - dispatch(`\$${v}`); - }); + for (let i = 0; i < changes.length; i++) { + dispatch(changes[i]); + } } // Dispatches events |
