Skip to content

Generalize abortable fetching for race conditions#1089

Open
thangqp wants to merge 2 commits into
mainfrom
generalize_abort_controller_use_effect
Open

Generalize abortable fetching for race conditions#1089
thangqp wants to merge 2 commits into
mainfrom
generalize_abort_controller_use_effect

Conversation

@thangqp
Copy link
Copy Markdown
Contributor

@thangqp thangqp commented Apr 3, 2026

PR Summary

Following to this PR: #1082

Signed-off-by: Thang PHAM <phamthang37@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

📝 Walkthrough

Walkthrough

This PR introduces a reusable useAbortableFetch hook that encapsulates fetch lifecycle management (loading state, abort handling, timeout, flicker reduction) and refactors two components to use it instead of manual useEffect-based fetch logic, eliminating duplicate code.

Changes

Cohort / File(s) Summary
New Fetch Hook
src/hooks/use-abortable-fetch.ts, src/hooks/index.ts
Introduced useAbortableFetch hook that manages abort signals, timeouts, loading state, and success/error handlers internally, with configurable flicker-reduction delay and conditional fetch skipping. Exported from hooks module.
Refactored Components
src/components/parameters/common/contingency-table/contingency-table.tsx, src/components/parameters/sensi/use-sensitivity-analysis-parameters.ts
Replaced manual useEffect-based fetch implementations with useAbortableFetch hook, removing duplicate abort controller, timeout, and loading state management code while preserving existing behavior.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description references a related PR and provides minimal context, but is related to the changeset since it indicates the work builds on previous fetch-related improvements.
Title check ✅ Passed The title accurately summarizes the main change: introducing a generalized useAbortableFetch hook to handle race conditions across multiple components. It reflects the PR's core objective of refactoring fetch logic into a reusable abstraction.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/parameters/common/contingency-table/contingency-table.tsx`:
- Around line 48-79: hasNoContingencies is used to skip the fetch which leaves
simulatedContingencyCount null (showing '...') and the flatMap can inject
undefined IDs; fix by explicitly preserving the "no contingencies" state and
removing undefined IDs: when deciding to skip because hasNoContingencies,
setSimulatedContingencyCount(0) (or the existing no-contingency sentinel) so the
noContingency alert shows, and change the ID builder in the fetcher (the
contingencyListsInfos.filter(...).flatMap(...)) to strip falsy/undefined IDs
(e.g., add .filter(Boolean) after mapping) so fetchContingencyCount never
receives undefined entries; keep other onSuccess/onError/cleanup behavior
unchanged.

In `@src/components/parameters/sensi/use-sensitivity-analysis-parameters.ts`:
- Around line 210-220: The fetch for factor counts can run with a null
studyUuid; update the useAbortableFetch call (deps and skipFetch) so it includes
studyUuid in deps and adds studyUuid to the skipFetch condition (i.e., skip when
!studyUuid || !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid)
to prevent requests with an invalid study id; likewise, adjust
updateFactorCount() to early-return when studyUuid is not present before
recomputing factorCountParams so factorCountParams is not derived while the
study context (UseSensitivityAnalysisParametersFormProps) is incomplete.

In `@src/hooks/use-abortable-fetch.ts`:
- Around line 59-91: The then-handler currently calls onSuccessEvent for every
completion, allowing stale responses to overwrite newer state; update the
success path in use-abortable-fetch so it ignores late completions when the
associated signal is already aborted (similar to the catch check), i.e., before
calling onSuccessEvent, check signal.aborted and signal.reason?.message ===
IGNORE_SIGNAL (or a general signal.aborted) and return early to drop the result;
ensure setLoading and timeout handling also only run for non-aborted completions
so fetcherWithSignal, signal, IGNORE_SIGNAL, onSuccessEvent, setLoading, and
timeoutRef behave consistently with the existing abort logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 37e88136-8100-44d2-8175-cc51a1f625d1

📥 Commits

Reviewing files that changed from the base of the PR and between f8c26a4 and 4ad170d.

📒 Files selected for processing (4)
  • src/components/parameters/common/contingency-table/contingency-table.tsx
  • src/components/parameters/sensi/use-sensitivity-analysis-parameters.ts
  • src/hooks/index.ts
  • src/hooks/use-abortable-fetch.ts

Comment on lines +48 to +79
const hasNoContingencies =
!contingencyListsInfos ||
(contingencyListsInfos.length ?? 0) === 0 ||
contingencyListsInfos.every((c) => !c[ACTIVATED] || (c[CONTINGENCY_LISTS]?.length ?? 0) === 0);

const controller = new AbortController();
// build a signal which allows us to cancel the fetch by calling controller.abort() or the timeout fires
const abortSignal = AbortSignal.any([controller.signal, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]);
setIsLoading(true);
const { loading: isLoading } = useAbortableFetch({
deps: [snackError, contingencyListsInfos, fetchContingencyCount, showContingencyCount, isBuiltCurrentNode],
skipFetch: !showContingencyCount || !isBuiltCurrentNode || hasNoContingencies,
timeoutMs: DEFAULT_TIMEOUT_MS,
fetcher: (signal) =>
fetchContingencyCount?.(
contingencyListsInfos
.filter((l) => l[ACTIVATED])
.flatMap((l) => l[CONTINGENCY_LISTS]?.map((c) => c[ID])),
signal
),

fetchContingencyCount?.(
contingencyListsInfos
.filter((lists) => lists[ACTIVATED])
.flatMap((lists) => lists[CONTINGENCY_LISTS]?.map((contingencyList) => contingencyList[ID])),
abortSignal
)
.then((contingencyCount) => {
setSimulatedContingencyCount(contingencyCount);
loadingTimeoutId = setTimeout(() => {
setIsLoading(false);
}, 500);
})
.catch((error) => {
setSimulatedContingencyCount(null);
onSuccess: (count) => {
setSimulatedContingencyCount(count);
},

if (abortSignal.aborted && abortSignal.reason?.message === IGNORE_SIGNAL) {
return;
}
setIsLoading(false);
snackWithFallback(snackError, error, { headerId: 'getSecurityAnalysisContingenciesCountError' });
onError: (error) => {
setSimulatedContingencyCount(null);
snackWithFallback(snackError, error, {
headerId: 'getSecurityAnalysisContingenciesCountError',
});
},

return () => {
controller?.abort(new Error(IGNORE_SIGNAL));
clearTimeout(loadingTimeoutId);
};
}, [snackError, contingencyListsInfos, fetchContingencyCount, showContingencyCount, isBuiltCurrentNode]);
cleanup: () => {
setSimulatedContingencyCount(null);
},
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the empty-selection state and strip undefined IDs.

With hasNoContingencies folded into skipFetch, this path now leaves simulatedContingencyCount at null, so the component falls back to the '...' placeholder instead of the existing noContingency alert. Also, flatMap((l) => l[CONTINGENCY_LISTS]?.map(...)) inserts undefined entries for activated rows whose list is missing.

Possible fix
+    const emptyCount: ContingencyCount = { contingencies: 0, notFoundElements: 0 };
+
     const { loading: isLoading } = useAbortableFetch({
         deps: [snackError, contingencyListsInfos, fetchContingencyCount, showContingencyCount, isBuiltCurrentNode],
-        skipFetch: !showContingencyCount || !isBuiltCurrentNode || hasNoContingencies,
+        skipFetch: !showContingencyCount || !isBuiltCurrentNode,
         timeoutMs: DEFAULT_TIMEOUT_MS,
         fetcher: (signal) =>
-            fetchContingencyCount?.(
-                contingencyListsInfos
-                    .filter((l) => l[ACTIVATED])
-                    .flatMap((l) => l[CONTINGENCY_LISTS]?.map((c) => c[ID])),
-                signal
-            ),
+            hasNoContingencies
+                ? Promise.resolve(emptyCount)
+                : fetchContingencyCount?.(
+                      contingencyListsInfos
+                          .filter((l) => l[ACTIVATED])
+                          .flatMap((l) => (l[CONTINGENCY_LISTS] ?? []).map((c) => c[ID])),
+                      signal
+                  ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/parameters/common/contingency-table/contingency-table.tsx`
around lines 48 - 79, hasNoContingencies is used to skip the fetch which leaves
simulatedContingencyCount null (showing '...') and the flatMap can inject
undefined IDs; fix by explicitly preserving the "no contingencies" state and
removing undefined IDs: when deciding to skip because hasNoContingencies,
setSimulatedContingencyCount(0) (or the existing no-contingency sentinel) so the
noContingency alert shows, and change the ID builder in the fetcher (the
contingencyListsInfos.filter(...).flatMap(...)) to strip falsy/undefined IDs
(e.g., add .filter(Boolean) after mapping) so fetchContingencyCount never
receives undefined entries; keep other onSuccess/onError/cleanup behavior
unchanged.

Comment on lines +210 to +220
const { loading: isLoading } = useAbortableFetch({
deps: [snackError, studyUuid, currentRootNetworkUuid, currentNodeUuid, factorCountParams],
skipFetch: !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid,
timeoutMs: DEFAULT_TIMEOUT_MS,
fetcher: (signal) =>
getSensitivityAnalysisFactorsCount(
studyUuid,
currentNodeUuid!,
currentRootNetworkUuid!,
factorCountParams!,
signal
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate the factors-count request on studyUuid too.

studyUuid is still nullable here, but this fetch only skips on the node/root IDs. In the partially initialized state allowed by UseSensitivityAnalysisParametersFormProps, that can still fire the request with an invalid study id.

Possible fix
     const { loading: isLoading } = useAbortableFetch({
         deps: [snackError, studyUuid, currentRootNetworkUuid, currentNodeUuid, factorCountParams],
-        skipFetch: !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid,
+        skipFetch: !studyUuid || !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid,
         timeoutMs: DEFAULT_TIMEOUT_MS,
         fetcher: (signal) =>
             getSensitivityAnalysisFactorsCount(
                 studyUuid,

Also align the early return in updateFactorCount() so factorCountParams is not recomputed while the study context is still incomplete.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { loading: isLoading } = useAbortableFetch({
deps: [snackError, studyUuid, currentRootNetworkUuid, currentNodeUuid, factorCountParams],
skipFetch: !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid,
timeoutMs: DEFAULT_TIMEOUT_MS,
fetcher: (signal) =>
getSensitivityAnalysisFactorsCount(
studyUuid,
currentNodeUuid!,
currentRootNetworkUuid!,
factorCountParams!,
signal
const { loading: isLoading } = useAbortableFetch({
deps: [snackError, studyUuid, currentRootNetworkUuid, currentNodeUuid, factorCountParams],
skipFetch: !studyUuid || !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid,
timeoutMs: DEFAULT_TIMEOUT_MS,
fetcher: (signal) =>
getSensitivityAnalysisFactorsCount(
studyUuid,
currentNodeUuid!,
currentRootNetworkUuid!,
factorCountParams!,
signal
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/parameters/sensi/use-sensitivity-analysis-parameters.ts`
around lines 210 - 220, The fetch for factor counts can run with a null
studyUuid; update the useAbortableFetch call (deps and skipFetch) so it includes
studyUuid in deps and adds studyUuid to the skipFetch condition (i.e., skip when
!studyUuid || !factorCountParams || !currentNodeUuid || !currentRootNetworkUuid)
to prevent requests with an invalid study id; likewise, adjust
updateFactorCount() to early-return when studyUuid is not present before
recomputing factorCountParams so factorCountParams is not derived while the
study context (UseSensitivityAnalysisParametersFormProps) is incomplete.

Comment on lines +59 to +91
fetcherWithSignal
.then((fetchedData) => {
// custom success handling if needed
onSuccessEvent(fetchedData);
// delay loading reset to avoid flickering in case of very fast requests
if (delayMs) {
timeoutRef.current = setTimeout(() => {
setLoading(false);
}, delayMs);
} else {
setLoading(false);
}
})
.catch((error) => {
// treat manual abort as a special case => silently ignore
if (signal.aborted && signal.reason?.message === IGNORE_SIGNAL) {
return;
}
// other cases => treat as a normal error, including the timeout abort
setLoading(false);
// custom error handling if needed
onErrorEvent(error);
});

// clean up
return () => {
controller?.abort(new Error(IGNORE_SIGNAL));

if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// custom cleanup if needed
onCleanupEvent();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop late completions from cancelled or timed-out runs.

This still calls onSuccess when a stale request resolves instead of rejecting. That lets an older response overwrite newer state, which defeats the race-condition protection this hook is meant to centralize.

Possible fix
+        let disposed = false;
+
         setLoading(true);
         fetcherWithSignal
             .then((fetchedData) => {
+                if (disposed || signal.aborted) {
+                    return;
+                }
                 // custom success handling if needed
                 onSuccessEvent(fetchedData);
                 // delay loading reset to avoid flickering in case of very fast requests
                 if (delayMs) {
                     timeoutRef.current = setTimeout(() => {
-                        setLoading(false);
+                        if (!disposed && !signal.aborted) {
+                            setLoading(false);
+                        }
                     }, delayMs);
                 } else {
                     setLoading(false);
                 }
             })
             .catch((error) => {
+                if (disposed) {
+                    return;
+                }
                 // treat manual abort as a special case => silently ignore
                 if (signal.aborted && signal.reason?.message === IGNORE_SIGNAL) {
                     return;
                 }
                 // other cases => treat as a normal error, including the timeout abort
@@
         // clean up
         return () => {
+            disposed = true;
             controller?.abort(new Error(IGNORE_SIGNAL));
 
             if (timeoutRef.current) {
                 clearTimeout(timeoutRef.current);
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-abortable-fetch.ts` around lines 59 - 91, The then-handler
currently calls onSuccessEvent for every completion, allowing stale responses to
overwrite newer state; update the success path in use-abortable-fetch so it
ignores late completions when the associated signal is already aborted (similar
to the catch check), i.e., before calling onSuccessEvent, check signal.aborted
and signal.reason?.message === IGNORE_SIGNAL (or a general signal.aborted) and
return early to drop the result; ensure setLoading and timeout handling also
only run for non-aborted completions so fetcherWithSignal, signal,
IGNORE_SIGNAL, onSuccessEvent, setLoading, and timeoutRef behave consistently
with the existing abort logic.

@thangqp thangqp changed the title Generalize fetching with race conditions Generalize abortable fetching Apr 3, 2026
@thangqp thangqp changed the title Generalize abortable fetching Generalize abortable fetching for race conditions Apr 3, 2026
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 3, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant