Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions __tests__/make-fetching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((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<string>((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 }]);
});
});
3 changes: 2 additions & 1 deletion docs/ai-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
13 changes: 13 additions & 0 deletions docs/api/helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 18 additions & 6 deletions src/make-fetching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ type BooleanKeys<T> = {
[K in keyof T]-?: T[K] extends boolean ? K : never;
}[keyof T];

type StrictParams<T> = Partial<Record<MethodKeys<T>, BooleanKeys<T>>>;
type UnsafeParams = Record<string, string>;
type AtLeastTwo<T> = readonly [T, T, ...T[]];
type StrictParamValue<T> = BooleanKeys<T> | AtLeastTwo<BooleanKeys<T>>;
type StrictParams<T> = Partial<Record<MethodKeys<T>, StrictParamValue<T>>>;
type UnsafeParams = Record<string, string | readonly string[]>;

function makeFetching<T extends Record<any, any>>(
instance: T,
Expand All @@ -29,13 +31,23 @@ function makeFetching<T extends Record<any, any>>(
params: StrictParams<T> | 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 => {
Expand All @@ -55,7 +67,7 @@ function makeFetching<T extends Record<any, any>>(

// @ts-expect-error not necessary
instance[funcName] = (...args: any[]) => {
if (hasLock && instance[propName]) {
if (hasLock && propNames.every((name) => instance[name])) {
return;
}

Expand Down
Loading