From c390d52a945183a870ff032e7dcb462a4183988f Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:26:55 +0100 Subject: [PATCH] feat: add new utils to mobx kit --- .../widget-plugin-mobx-kit/package.json | 1 + .../widget-plugin-mobx-kit/src/SetupHost.ts | 2 +- .../src/disposeBatch.ts | 23 +-- .../src/interfaces/ComputedAtom.ts | 3 + .../src/lib/atomFactory.ts | 10 + .../src/{ => lib}/autoEffect.ts | 0 .../src/lib/createEmitter.ts | 28 +++ .../src/lib/disposeBatch.ts | 17 ++ .../shared/widget-plugin-mobx-kit/src/main.ts | 8 +- .../src/react/useSubscribe.ts | 20 -- .../test/atomFactory.test.ts | 179 ++++++++++++++++++ .../test/autoEffect.test.ts | 2 +- .../test/createEmitter.test.ts | 88 +++++++++ .../test/useSubscribe.test.ts | 65 ------- pnpm-lock.yaml | 8 + 15 files changed, 347 insertions(+), 107 deletions(-) create mode 100644 packages/shared/widget-plugin-mobx-kit/src/interfaces/ComputedAtom.ts create mode 100644 packages/shared/widget-plugin-mobx-kit/src/lib/atomFactory.ts rename packages/shared/widget-plugin-mobx-kit/src/{ => lib}/autoEffect.ts (100%) create mode 100644 packages/shared/widget-plugin-mobx-kit/src/lib/createEmitter.ts create mode 100644 packages/shared/widget-plugin-mobx-kit/src/lib/disposeBatch.ts delete mode 100644 packages/shared/widget-plugin-mobx-kit/src/react/useSubscribe.ts create mode 100644 packages/shared/widget-plugin-mobx-kit/test/atomFactory.test.ts create mode 100644 packages/shared/widget-plugin-mobx-kit/test/createEmitter.test.ts delete mode 100644 packages/shared/widget-plugin-mobx-kit/test/useSubscribe.test.ts diff --git a/packages/shared/widget-plugin-mobx-kit/package.json b/packages/shared/widget-plugin-mobx-kit/package.json index a5a09ccec5..7d4845b574 100644 --- a/packages/shared/widget-plugin-mobx-kit/package.json +++ b/packages/shared/widget-plugin-mobx-kit/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@types/minimatch": "^3.0.5", + "mitt": "^3.0.1", "mobx": "6.12.3" }, "devDependencies": { diff --git a/packages/shared/widget-plugin-mobx-kit/src/SetupHost.ts b/packages/shared/widget-plugin-mobx-kit/src/SetupHost.ts index ae39a60938..1430f84911 100644 --- a/packages/shared/widget-plugin-mobx-kit/src/SetupHost.ts +++ b/packages/shared/widget-plugin-mobx-kit/src/SetupHost.ts @@ -1,6 +1,6 @@ -import { disposeBatch } from "./disposeBatch"; import { SetupComponent } from "./interfaces/SetupComponent"; import { SetupComponentHost } from "./interfaces/SetupComponentHost"; +import { disposeBatch } from "./lib/disposeBatch"; export abstract class SetupHost implements SetupComponentHost { private components: Set = new Set(); diff --git a/packages/shared/widget-plugin-mobx-kit/src/disposeBatch.ts b/packages/shared/widget-plugin-mobx-kit/src/disposeBatch.ts index 7b0caf4fe4..af705eb66a 100644 --- a/packages/shared/widget-plugin-mobx-kit/src/disposeBatch.ts +++ b/packages/shared/widget-plugin-mobx-kit/src/disposeBatch.ts @@ -1,19 +1,6 @@ -type MaybeFn = (() => void) | void; +import { disposeBatch } from "./lib/disposeBatch"; -export function disposeBatch(): [add: (fn: MaybeFn) => void, disposeAll: () => void] { - const disposers = new Set<() => void>(); - - const add = (fn: MaybeFn): void => { - if (fn) { - disposers.add(fn); - } - }; - - const disposeAll = (): void => { - for (const fn of disposers) { - fn(); - } - disposers.clear(); - }; - return [add, disposeAll]; -} +export { + /** @deprecated import `disposeBatch` from `@mendix/widget-plugin-mobx-kit/main` instead */ + disposeBatch +}; diff --git a/packages/shared/widget-plugin-mobx-kit/src/interfaces/ComputedAtom.ts b/packages/shared/widget-plugin-mobx-kit/src/interfaces/ComputedAtom.ts new file mode 100644 index 0000000000..1a43cbe891 --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/src/interfaces/ComputedAtom.ts @@ -0,0 +1,3 @@ +export interface ComputedAtom { + get(): T; +} diff --git a/packages/shared/widget-plugin-mobx-kit/src/lib/atomFactory.ts b/packages/shared/widget-plugin-mobx-kit/src/lib/atomFactory.ts new file mode 100644 index 0000000000..fdf28f58df --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/src/lib/atomFactory.ts @@ -0,0 +1,10 @@ +import { computed } from "mobx"; +import { ComputedAtom } from "../interfaces/ComputedAtom"; + +/** Creates a computed atom factory by composing a map function with a computation function. */ +export function atomFactory

( + map: (...args: P) => A, + fn: (...args: A) => B +): (...args: P) => ComputedAtom { + return (...args: P) => computed(() => fn(...map(...args))); +} diff --git a/packages/shared/widget-plugin-mobx-kit/src/autoEffect.ts b/packages/shared/widget-plugin-mobx-kit/src/lib/autoEffect.ts similarity index 100% rename from packages/shared/widget-plugin-mobx-kit/src/autoEffect.ts rename to packages/shared/widget-plugin-mobx-kit/src/lib/autoEffect.ts diff --git a/packages/shared/widget-plugin-mobx-kit/src/lib/createEmitter.ts b/packages/shared/widget-plugin-mobx-kit/src/lib/createEmitter.ts new file mode 100644 index 0000000000..6d400d2adc --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/src/lib/createEmitter.ts @@ -0,0 +1,28 @@ +import mitt, { Emitter as MittEmitter } from "mitt"; + +export type Handler = (event: T) => void; + +export type WildcardHandler> = (type: keyof T, event: T[keyof T]) => void; + +export interface Emitter> extends MittEmitter { + on(type: Key, handler: Handler): () => void; + on(type: "*", handler: WildcardHandler): () => void; +} + +export function createEmitter>(): Emitter { + const emitter = mitt(); + + return { + ...emitter, + on(type: Key, handler: Handler | WildcardHandler): () => void { + if (type === "*") { + const fn = handler as WildcardHandler; + emitter.on(type, fn); + return () => emitter.off(type, fn); + } + const fn = handler as Handler; + emitter.on(type, fn); + return () => emitter.off(type, fn); + } + }; +} diff --git a/packages/shared/widget-plugin-mobx-kit/src/lib/disposeBatch.ts b/packages/shared/widget-plugin-mobx-kit/src/lib/disposeBatch.ts new file mode 100644 index 0000000000..984468836c --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/src/lib/disposeBatch.ts @@ -0,0 +1,17 @@ +export function disposeBatch(): [add: (fn: void | (() => void)) => void, disposeAll: () => void] { + const disposers = new Set<() => void>(); + + const add = (fn: void | (() => void)): void => { + if (fn) { + disposers.add(fn); + } + }; + + const disposeAll = (): void => { + for (const fn of disposers) { + fn(); + } + disposers.clear(); + }; + return [add, disposeAll]; +} diff --git a/packages/shared/widget-plugin-mobx-kit/src/main.ts b/packages/shared/widget-plugin-mobx-kit/src/main.ts index 5605426c3e..75711e90fd 100644 --- a/packages/shared/widget-plugin-mobx-kit/src/main.ts +++ b/packages/shared/widget-plugin-mobx-kit/src/main.ts @@ -1,9 +1,13 @@ -export { autoEffect } from "./autoEffect"; export { DerivedGate } from "./DerivedGate"; -export { disposeBatch } from "./disposeBatch"; export { GateProvider } from "./GateProvider"; +export type { ComputedAtom } from "./interfaces/ComputedAtom"; export * from "./interfaces/DerivedPropsGate"; export * from "./interfaces/DerivedPropsGateProvider"; export * from "./interfaces/SetupComponent"; export * from "./interfaces/SetupComponentHost"; +export { atomFactory } from "./lib/atomFactory"; +export { autoEffect } from "./lib/autoEffect"; +export { createEmitter } from "./lib/createEmitter"; +export type { Emitter } from "./lib/createEmitter"; +export { disposeBatch } from "./lib/disposeBatch"; export { SetupHost } from "./SetupHost"; diff --git a/packages/shared/widget-plugin-mobx-kit/src/react/useSubscribe.ts b/packages/shared/widget-plugin-mobx-kit/src/react/useSubscribe.ts deleted file mode 100644 index fe4c6bdab5..0000000000 --- a/packages/shared/widget-plugin-mobx-kit/src/react/useSubscribe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IReactionOptions, reaction } from "mobx"; -import { useEffect, useState } from "react"; - -export function useSubscribe( - init: Store | (() => Store), - selector: (store: Store) => T, - options?: IReactionOptions -): [T, Store] { - const [store] = useState(() => (typeof init === "function" ? init() : init)); - const [value, setValue] = useState(() => selector(store)); - - function setup(): () => void { - return reaction(() => selector(store), setValue, options); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(setup, []); - - return [value, store]; -} diff --git a/packages/shared/widget-plugin-mobx-kit/test/atomFactory.test.ts b/packages/shared/widget-plugin-mobx-kit/test/atomFactory.test.ts new file mode 100644 index 0000000000..dfe7668970 --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/test/atomFactory.test.ts @@ -0,0 +1,179 @@ +import { configure, observable, runInAction } from "mobx"; +import { atomFactory } from "../src/lib/atomFactory"; +import { ComputedAtom } from "../src/main"; + +describe("atomFactory", () => { + configure({ + enforceActions: "never" + }); + + it("should create a computed atom factory that composes map and computation functions", () => { + const map = (x: number, y: number) => [x + 1, y + 1] as const; + const fn = (a: number, b: number): number => a * b; + const factory = atomFactory(map, fn); + + const atom = factory(2, 3); + expect(atom.get()).toBe(12); // (2+1) * (3+1) = 3 * 4 = 12 + }); + + it("should return a function that creates computed atoms", () => { + const map = (x: number) => [x * 2] as const; + const fn = (a: number): number => a + 10; + const factory = atomFactory(map, fn); + + const atom1 = factory(5); + const atom2 = factory(3); + + expect(atom1.get()).toBe(20); // (5*2) + 10 = 20 + expect(atom2.get()).toBe(16); // (3*2) + 10 = 16 + }); + + it("should create reactive computed atoms that update when observables change", () => { + const observableValue = observable.box(5); + const map = (x: number) => [x, observableValue.get()] as const; + const fn = (a: number, b: number): number => a + b; + const factory = atomFactory(map, fn); + + const atom = factory(10); + expect(atom.get()).toBe(15); // 10 + 5 = 15 + + observableValue.set(20); + expect(atom.get()).toBe(30); // 10 + 20 = 30 + }); + + it("should handle multiple parameters in map function", () => { + const map = (a: number, b: number, c: number) => [a + b, c] as const; + const fn = (x: number, y: number): number => x * y; + const factory = atomFactory(map, fn); + + const atom = factory(2, 3, 4); + expect(atom.get()).toBe(20); // (2+3) * 4 = 5 * 4 = 20 + }); + + it("should handle complex objects and transformations", () => { + interface Input { + value: number; + } + interface Output { + doubled: number; + } + + const map = (input: Input) => [input.value] as const; + const fn = (value: number): Output => ({ doubled: value * 2 }); + const factory = atomFactory(map, fn); + + const atom = factory({ value: 7 }); + expect(atom.get()).toEqual({ doubled: 14 }); + }); + + it("should cache computed values and only recompute when dependencies change", () => { + const box = observable.box(1); + const mapFn = jest.fn((x: ComputedAtom) => [x.get()] as const); + const computeFn = jest.fn((a: number): number => a * 6); + const factory = atomFactory(mapFn, computeFn); + + const atom = factory(box); + + runInAction(() => { + // First access + expect(atom.get()).toBe(6); + expect(mapFn).toHaveBeenCalledTimes(1); + expect(computeFn).toHaveBeenCalledTimes(1); + + expect(atom.get()).toBe(6); + expect(mapFn).toHaveBeenCalledTimes(1); + expect(computeFn).toHaveBeenCalledTimes(1); + + box.set(2); + expect(atom.get()).toBe(12); + expect(mapFn).toHaveBeenCalledTimes(2); + expect(computeFn).toHaveBeenCalledTimes(2); + }); + }); + + it("should handle zero parameters", () => { + const map = () => [42] as const; + const fn = (x: number): number => x * 2; + const factory = atomFactory(map, fn); + + const atom = factory(); + expect(atom.get()).toBe(84); + }); + + it("should handle string transformations", () => { + const map = (str: string, prefix: string) => [prefix, str] as const; + const fn = (prefix: string, str: string): string => `${prefix}${str}`; + const factory = atomFactory(map, fn); + + const atom = factory("World", "Hello "); + expect(atom.get()).toBe("Hello World"); + }); + + it("should handle array transformations", () => { + const map = (arr: number[]) => [arr] as const; + const fn = (arr: number[]): number => arr.reduce((sum, n) => sum + n, 0); + const factory = atomFactory(map, fn); + + const atom = factory([1, 2, 3, 4, 5]); + expect(atom.get()).toBe(15); + }); + + it("should allow multiple atoms created from the same factory to be independent", () => { + const map = (x: number) => [x] as const; + const fn = (x: number): number => x * x; + const factory = atomFactory(map, fn); + + const atom1 = factory(3); + const atom2 = factory(4); + + expect(atom1.get()).toBe(9); + expect(atom2.get()).toBe(16); + }); + + it("should work with observables in the computation function", () => { + const observableValue = observable.box(10); + const map = (multiplier: number) => [multiplier] as const; + const fn = (multiplier: number): number => observableValue.get() * multiplier; + const factory = atomFactory(map, fn); + + const atom = factory(2); + expect(atom.get()).toBe(20); + + observableValue.set(15); + expect(atom.get()).toBe(30); + }); + + it("should recompute when multiple boxed observable values change", () => { + const box1 = observable.box(5); + const box2 = observable.box(10); + const box3 = observable.box(2); + + const map = = ComputedAtom>(a: T, b: T, c: T) => + [a.get(), b.get(), c.get()] as const; + const fn = (a: number, b: number, c: number): number => (a + b) * c; + const factory = atomFactory(map, fn); + + const atom = factory(box1, box2, box3); + + // Initial computation: (5 + 10) * 2 = 30 + expect(atom.get()).toBe(30); + + // Change first box: (8 + 10) * 2 = 36 + box1.set(8); + expect(atom.get()).toBe(36); + + // Change second box: (8 + 15) * 2 = 46 + box2.set(15); + expect(atom.get()).toBe(46); + + // Change third box: (8 + 15) * 3 = 69 + box3.set(3); + expect(atom.get()).toBe(69); + + // Change multiple boxes: (20 + 5) * 4 = 100 + box1.set(20); + box2.set(5); + box3.set(4); + expect(atom.get()).toBe(100); + }); +}); diff --git a/packages/shared/widget-plugin-mobx-kit/test/autoEffect.test.ts b/packages/shared/widget-plugin-mobx-kit/test/autoEffect.test.ts index 84a2b2c15b..b00a4b6ba8 100644 --- a/packages/shared/widget-plugin-mobx-kit/test/autoEffect.test.ts +++ b/packages/shared/widget-plugin-mobx-kit/test/autoEffect.test.ts @@ -1,5 +1,5 @@ import { configure, observable } from "mobx"; -import { autoEffect } from "../src/autoEffect"; +import { autoEffect } from "../src/lib/autoEffect"; describe("autoEffect", () => { configure({ diff --git a/packages/shared/widget-plugin-mobx-kit/test/createEmitter.test.ts b/packages/shared/widget-plugin-mobx-kit/test/createEmitter.test.ts new file mode 100644 index 0000000000..2499339fa1 --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/test/createEmitter.test.ts @@ -0,0 +1,88 @@ +import { createEmitter } from "../src/lib/createEmitter"; + +describe("createEmitter", () => { + it("should emit and handle events", () => { + type Events = { + click: { x: number; y: number }; + change: string; + }; + + const emitter = createEmitter(); + const handler = jest.fn(); + + emitter.on("click", handler); + emitter.emit("click", { x: 10, y: 20 }); + + expect(handler).toHaveBeenCalledWith({ x: 10, y: 20 }); + }); + + it("should return unsubscribe function", () => { + type Events = { + update: number; + }; + + const emitter = createEmitter(); + const handler = jest.fn(); + + const unsubscribe = emitter.on("update", handler); + emitter.emit("update", 42); + expect(handler).toHaveBeenCalledTimes(1); + + unsubscribe(); + emitter.emit("update", 99); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("should handle wildcard events", () => { + type Events = { + foo: string; + bar: number; + }; + + const emitter = createEmitter(); + const wildcard = jest.fn(); + + emitter.on("*", wildcard); + emitter.emit("foo", "test"); + emitter.emit("bar", 123); + + expect(wildcard).toHaveBeenCalledTimes(2); + expect(wildcard).toHaveBeenCalledWith("foo", "test"); + expect(wildcard).toHaveBeenCalledWith("bar", 123); + }); + + it("should support multiple handlers for same event", () => { + type Events = { + save: boolean; + }; + + const emitter = createEmitter(); + const handler1 = jest.fn(); + const handler2 = jest.fn(); + + emitter.on("save", handler1); + emitter.on("save", handler2); + emitter.emit("save", true); + + expect(handler1).toHaveBeenCalledWith(true); + expect(handler2).toHaveBeenCalledWith(true); + }); + + it("should unsubscribe wildcard handler", () => { + type Events = { + a: number; + b: string; + }; + + const emitter = createEmitter(); + const wildcard = jest.fn(); + + const unsubscribe = emitter.on("*", wildcard); + emitter.emit("a", 1); + expect(wildcard).toHaveBeenCalledTimes(1); + + unsubscribe(); + emitter.emit("b", "test"); + expect(wildcard).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/widget-plugin-mobx-kit/test/useSubscribe.test.ts b/packages/shared/widget-plugin-mobx-kit/test/useSubscribe.test.ts deleted file mode 100644 index 345f267d5a..0000000000 --- a/packages/shared/widget-plugin-mobx-kit/test/useSubscribe.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { configure, isObservable, observable } from "mobx"; -import { useSubscribe } from "../src/react/useSubscribe"; - -describe("useSubscribe", () => { - configure({ - enforceActions: "never" - }); - - it("should initialize with the correct value", () => { - const store = observable({ count: 0 }); - const { result } = renderHook(() => useSubscribe(store, s => s.count)); - - const [value, returnedStore] = result.current; - expect(value).toBe(0); - expect(returnedStore).toBe(store); - }); - - it("should call init function, if given", () => { - const { result } = renderHook(() => - useSubscribe( - () => observable({ count: 0 }), - s => s.count - ) - ); - - const [value, store] = result.current; - expect(value).toBe(0); - expect(isObservable(store)).toBe(true); - }); - - it("should update the value when the observable changes", () => { - const store = observable({ count: 0 }); - const { result } = renderHook(() => useSubscribe(store, s => s.count)); - - act(() => { - store.count = 1; - }); - - const [value] = result.current; - expect(value).toBe(1); - }); - - it("should clean up the reaction on unmount", () => { - const store = observable({ count: 0 }); - const selector = jest.fn(s => s.count); - const { unmount } = renderHook(() => useSubscribe(() => store, selector)); - - expect(selector).toHaveBeenCalledTimes(2); - - act(() => { - ++store.count; - }); - - expect(selector).toHaveBeenCalledTimes(3); - - unmount(); - - act(() => { - ++store.count; - }); - - expect(selector).toHaveBeenCalledTimes(3); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a66b2b5cce..7774b684ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2884,6 +2884,9 @@ importers: '@types/minimatch': specifier: ^3.0.5 version: 3.0.5 + mitt: + specifier: ^3.0.1 + version: 3.0.1 mobx: specifier: 6.12.3 version: 6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9) @@ -8441,6 +8444,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -17733,6 +17739,8 @@ snapshots: minipass@7.1.2: {} + mitt@3.0.1: {} + mkdirp-classic@0.5.3: optional: true