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
12 changes: 12 additions & 0 deletions .changeset/fix-trailing-reset-false-positive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"oxlint-plugin-react-doctor": patch
---

Fix false positive in `no-loading-flag-reset-outside-finally` for non-rethrowing catch with trailing reset

The rule now correctly recognizes that a trailing reset after a `try/catch` block is safe when the catch handler doesn't rethrow, even if it contains user code calls like `toast.show()`. The fix distinguishes between:
- Built-in calls that might throw (JSON.parse, Math.round with invalid args) - still flagged
- Known-throwing local functions - still flagged
- User code calls on non-built-in objects - now allowed

This resolves the conflict with React Compiler, which cannot handle `try/finally` and requires the `catch-without-rethrow + trailing-reset` pattern.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ describe("no-loading-flag-reset-outside-finally", () => {
expect(result.diagnostics).toHaveLength(0);
});

it("stays quiet with non-built-in method calls in catch with trailing reset (issue #1593)", () => {
const result = runRule(
noLoadingFlagResetOutsideFinally,
`import { useState } from "react";
const Component = () => {
const [isUploading, setIsUploading] = useState(false);
const handleUpload = async () => {
setIsUploading(true);
try {
const res = await fetch("/api/upload");
if (res.ok) {
console.log("success");
} else {
toast.show({ variant: "danger", label: "Upload error", duration: 3500 });
}
} catch {
toast.show({ variant: "danger", label: "Upload error", duration: 3500 });
}
setIsUploading(false);
};
};`,
);
expect(result.diagnostics).toHaveLength(0);
});

it("flags a trailing reset when the catch rethrows, so rejection still skips it", () => {
const result = runRule(
noLoadingFlagResetOutsideFinally,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1326,7 +1326,12 @@ const catchHandlerCanBypassReset = (
};
}
if (
subtreeHasAbruptSynchronousOperation(stripped, functionNode, context) &&
subtreeHasAbruptSynchronousOperation(
stripped,
functionNode,
context,
!doesContinuingPathReachReset,
) &&
states.some(
(state) => !state.isCleared && !(doesContinuingPathReachReset && state.isCancellationPath),
)
Expand Down Expand Up @@ -1600,6 +1605,7 @@ const subtreeHasAbruptSynchronousOperation = (
root: EsTreeNode,
functionBoundary: EsTreeNode,
context: RuleContext,
strictMode = true,
): boolean => {
let canCompleteAbruptly = false;
walkAst(root, (candidate) => {
Expand All @@ -1610,10 +1616,42 @@ const subtreeHasAbruptSynchronousOperation = (
canCompleteAbruptly = true;
return false;
}
if (
isNodeOfType(candidate, "CallExpression") &&
!isProvenNonThrowingSynchronousCall(candidate, context)
) {
if (isNodeOfType(candidate, "CallExpression")) {
if (isProvenNonThrowingSynchronousCall(candidate, context)) return;
if (!strictMode) {
const callee = stripParenExpression(candidate.callee);
if (isNodeOfType(callee, "Identifier")) {
const localFunction = resolveExactLocalFunction(callee, context.scopes);
if (
localFunction &&
isFunctionLike(localFunction) &&
!localFunction.async &&
subtreeCanThrowSynchronously(localFunction, localFunction, context.scopes)
) {
canCompleteAbruptly = true;
return false;
}
if (!localFunction && !context.scopes.isGlobalReference(callee)) {
return;
}
} else if (isNodeOfType(callee, "MemberExpression")) {
const receiver = stripParenExpression(callee.object);
if (
isNodeOfType(receiver, "Identifier") &&
!context.scopes.isGlobalReference(receiver)
) {
canCompleteAbruptly = true;
return false;
}
const isKnownBuiltIn =
isNodeOfType(receiver, "Identifier") &&
context.scopes.isGlobalReference(receiver) &&
["JSON", "Date", "Object", "Math", "Array", "String", "Number", "Boolean", "console"].includes(receiver.name);
if (!isKnownBuiltIn) {
return;
}
}
}
canCompleteAbruptly = true;
return false;
}
Expand Down
Loading