diff --git a/__tests__/make-fetching.ts b/__tests__/make-fetching.ts index 5ab66be..f51a0f3 100644 --- a/__tests__/make-fetching.ts +++ b/__tests__/make-fetching.ts @@ -130,4 +130,140 @@ describe('makeFetching', () => { expect(instance.isLoading).to.equal(false); }); + + it('should toggle multiple flags from a single method when given an array', () => { + const observed: { isLoading: boolean; isBusy: boolean }[] = []; + const instance = { + isLoading: false, + isBusy: false, + run() { + observed.push({ isLoading: instance.isLoading, isBusy: instance.isBusy }); + + return 'done'; + }, + }; + + makeFetching(instance, { run: ['isLoading', 'isBusy'] }); + + const result = instance.run(); + + expect(result).to.equal('done'); + expect(observed).to.deep.equal([{ isLoading: true, isBusy: true }]); + expect(instance.isLoading).to.equal(false); + expect(instance.isBusy).to.equal(false); + }); + + it('should flip all flags true on call and false on settle for async multi-flag methods', async () => { + let resolvePromise: ((value: string) => void) | undefined; + const instance = { + isLoading: false, + isRefreshing: false, + run() { + return new Promise((resolve) => { + resolvePromise = resolve; + }); + }, + }; + + makeFetching(instance, { run: ['isLoading', 'isRefreshing'] }); + + const promise = instance.run(); + + expect(instance.isLoading).to.equal(true); + expect(instance.isRefreshing).to.equal(true); + + resolvePromise?.('done'); + await promise; + + expect(instance.isLoading).to.equal(false); + expect(instance.isRefreshing).to.equal(false); + }); + + it('should ignore an empty flag array without wrapping the method', () => { + let callCount = 0; + const instance = { + isLoading: false, + run() { + callCount += 1; + + return 'done'; + }, + }; + const originalRun = instance.run; + + makeFetching(instance, { run: [] }); + + expect(instance.run).to.equal(originalRun); + + const result = instance.run(); + + expect(result).to.equal('done'); + expect(callCount).to.equal(1); + expect(instance.isLoading).to.equal(false); + }); + + it('should block repeated calls only when every lock flag is already set in multi-flag mode', async () => { + const resolvers: ((value: string) => void)[] = []; + let callCount = 0; + const instance = { + isLoading: false, + isBusy: false, + run() { + callCount += 1; + + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }, + }; + + makeFetching(instance, { run: ['isLoading', 'isBusy'] }, true); + + const firstPromise = instance.run(); + + expect(callCount).to.equal(1); + expect(instance.isLoading).to.equal(true); + expect(instance.isBusy).to.equal(true); + + // Every lock flag is set — the second call must be dropped. + const secondResult = instance.run(); + + expect(secondResult).to.be.undefined; + expect(callCount).to.equal(1); + + resolvers[0]?.('done'); + await firstPromise; + + expect(instance.isLoading).to.equal(false); + expect(instance.isBusy).to.equal(false); + + // After settle, both flags are false — new call goes through. + const thirdPromise = instance.run(); + + expect(callCount).to.equal(2); + + resolvers[1]?.('done-again'); + await thirdPromise; + }); + + it('should not block when only some lock flags are already set in multi-flag mode', () => { + const observed: { isLoading: boolean; isBusy: boolean }[] = []; + let callCount = 0; + const instance = { + isLoading: false, + isBusy: true, + run() { + callCount += 1; + observed.push({ isLoading: instance.isLoading, isBusy: instance.isBusy }); + }, + }; + + makeFetching(instance, { run: ['isLoading', 'isBusy'] }, true); + + // `isBusy` is true but `isLoading` is not — `.every()` is false, call proceeds. + instance.run(); + + expect(callCount).to.equal(1); + expect(observed).to.deep.equal([{ isLoading: true, isBusy: true }]); + }); }); diff --git a/docs/ai-usage.md b/docs/ai-usage.md index 1d4d89c..db80e0c 100644 --- a/docs/ai-usage.md +++ b/docs/ai-usage.md @@ -401,7 +401,8 @@ The root export is the safest contract. Subpath imports are available in the pub - Purpose: wrap methods so they toggle boolean loading flags around sync or async execution - Notes: - supports multiple concurrent async calls - - with `hasLock = true`, it drops calls while the flag is already `true` + - each method may map to a single flag (`'isLoading'`) or to a `readonly` tuple of flags (`['isLoading', 'isRefreshing'] as const`); tuple flags flip together inside one `runInAction` + - with `hasLock = true`, it drops calls while **every** mapped flag is already `true` `makeExported` diff --git a/docs/api/helpers.md b/docs/api/helpers.md index 369e6f1..2712f64 100644 --- a/docs/api/helpers.md +++ b/docs/api/helpers.md @@ -39,6 +39,19 @@ makeFetching(this, { }); ``` +Each method maps to one flag — or to several flags via a `readonly` tuple. All +flags in the tuple flip in a single `runInAction`, so observers see them change +atomically: + +```ts +makeFetching(this, { + verify: ['isVerifying', 'hasStartedBrowserFromModal'] as const, +}); +``` + +When `hasLock` is `true`, the method call is dropped only while **every** mapped +flag is already `true`, so a partially-set state still lets the call through. + ## `makeExported(store, params)` Controls what parts of the store become exportable or serializable. diff --git a/src/make-fetching.ts b/src/make-fetching.ts index 7b25864..c0e453d 100644 --- a/src/make-fetching.ts +++ b/src/make-fetching.ts @@ -10,8 +10,10 @@ type BooleanKeys = { [K in keyof T]-?: T[K] extends boolean ? K : never; }[keyof T]; -type StrictParams = Partial, BooleanKeys>>; -type UnsafeParams = Record; +type AtLeastTwo = readonly [T, T, ...T[]]; +type StrictParamValue = BooleanKeys | AtLeastTwo>; +type StrictParams = Partial, StrictParamValue>>; +type UnsafeParams = Record; function makeFetching>( instance: T, @@ -29,13 +31,23 @@ function makeFetching>( params: StrictParams | UnsafeParams = {}, hasLock = false, ): void { - Object.entries(params).forEach(([funcName, propName]) => { + Object.entries(params).forEach(([funcName, propNameOrNames]) => { + const propNames = (Array.isArray(propNameOrNames) ? propNameOrNames : [propNameOrNames]).filter( + Boolean, + ) as string[]; + + if (propNames.length === 0) { + return; + } + const callback = instance[funcName] as (...arg: any[]) => any; let inFlight = 0; const setValue = (value: boolean): void => { runInAction(() => { - // @ts-expect-error not necessary - instance[propName] = value; + for (const propName of propNames) { + // @ts-expect-error not necessary + instance[propName] = value; + } }); }; const increment = (): void => { @@ -55,7 +67,7 @@ function makeFetching>( // @ts-expect-error not necessary instance[funcName] = (...args: any[]) => { - if (hasLock && instance[propName]) { + if (hasLock && propNames.every((name) => instance[name])) { return; }