Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/d2ea1a918e08faea.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
---

fix(async-await-in-loop): recognize local Promise.allSettled wrappers

The rule no longer reports false positives when async map callbacks are passed to a local wrapper around `Promise.allSettled`, `Promise.all`, `Promise.race`, or `Promise.any`. The rule now correctly recognizes that operations are running in parallel when the mapped promises flow into a local function that internally uses promise concurrency.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const waitForAll = async (operations) => {
const settled = await Promise.allSettled(operations);
const values = [];
for (const result of settled) {
if (result.status === "rejected") throw result.reason;
values.push(result.value);
}
return values;
};

export async function runBatch(ids, operation) {
await waitForAll(
ids.map(async (id) => {
await operation(id);
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -871,4 +871,78 @@ describe("js-performance/async-await-in-loop — regressions", () => {
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("stays silent on .map(async) passed to a local Promise.allSettled wrapper", () => {
const result = runRule(
asyncAwaitInLoop,
`const waitForAll = async (operations) => {
const settled = await Promise.allSettled(operations);
const values = [];
for (const result of settled) {
if (result.status === 'rejected') throw result.reason;
values.push(result.value);
}
return values;
};
async function runBatch(ids, operation) {
await waitForAll(ids.map(async (id) => {
await operation(id);
}));
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent on .map(async) passed to a local Promise.all wrapper", () => {
const result = runRule(
asyncAwaitInLoop,
`const runAll = async (tasks) => {
return await Promise.all(tasks);
};
async function execute(items) {
await runAll(items.map(async (item) => {
await process(item);
}));
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent on .map(async) passed to a local Promise.race wrapper", () => {
const result = runRule(
asyncAwaitInLoop,
`const firstToComplete = async (operations) => {
return await Promise.race(operations);
};
async function compete(items) {
return await firstToComplete(items.map(async (item) => {
return await load(item);
}));
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("still flags .map(async) passed to a local wrapper that does not use promise concurrency", () => {
const result = runRule(
asyncAwaitInLoop,
`const processSequentially = async (operations) => {
const results = [];
for (const op of operations) {
results.push(await op);
}
return results;
};
async function runBatch(ids, operation) {
await processSequentially(ids.map(async (id) => {
await operation(id);
}));
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1250,7 +1250,21 @@ const isBindingCombinedWithPromiseConcurrency = (
return isCombined;
};

const isWrappedInPromiseConcurrency = (mapCall: EsTreeNode): boolean => {
const doesLocalFunctionUsePromiseConcurrency = (localFunction: EsTreeNode): boolean => {
if (!isFunctionLike(localFunction)) return false;
let usesPromiseConcurrency = false;
walkAst(localFunction.body, (child: EsTreeNode): boolean | void => {
if (usesPromiseConcurrency) return false;
if (child !== localFunction.body && isFunctionLike(child)) return false;
if (isPromiseConcurrencyCall(child)) {
usesPromiseConcurrency = true;
return false;
}
});
return usesPromiseConcurrency;
};

const isWrappedInPromiseConcurrency = (mapCall: EsTreeNode, context: RuleContext): boolean => {
const flowNode = resolvePromiseFlowNode(mapCall);
const parent = flowNode.parent;
if (
Expand All @@ -1260,6 +1274,15 @@ const isWrappedInPromiseConcurrency = (mapCall: EsTreeNode): boolean => {
) {
return true;
}
if (
isNodeOfType(parent, "CallExpression") &&
(parent.arguments ?? []).some((argument) => argument === flowNode)
) {
const localFunction = resolveStaticLocalCallFunction(parent, context.scopes);
if (localFunction && doesLocalFunctionUsePromiseConcurrency(localFunction)) {
return true;
}
}
let bindingName: string | null = null;
if (
isNodeOfType(parent, "VariableDeclarator") &&
Expand Down Expand Up @@ -1390,7 +1413,7 @@ export const asyncAwaitInLoop = defineRule({

if (
(methodName === "map" || methodName === "flatMap") &&
isWrappedInPromiseConcurrency(node)
isWrappedInPromiseConcurrency(node, context)
) {
return;
}
Expand Down
Loading