Skip to content

fix: show inline errors for secondary data instead of spinning#1069

Open
igboyes wants to merge 1 commit into
mainfrom
igboyes/vir-2537-surface-inline-errors-for-secondary-react-query-data
Open

fix: show inline errors for secondary data instead of spinning#1069
igboyes wants to merge 1 commit into
mainfrom
igboyes/vir-2537-surface-inline-errors-for-secondary-react-query-data

Conversation

@igboyes

@igboyes igboyes commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

  • Replace the if (isPending || !data) anti-pattern in secondary (Tier 2) React Query consumers — lists, sidebar widgets, dialogs, banners, and the wrapper hooks that compose queries — so a failed request shows a contained inline error instead of spinning forever (VIR-2537).
  • Add a shared @base/QueryError (a red Alert taking a noun, the Tier 2 counterpart to RouteError) and gate it on isError && !data — error only when there's nothing to show. In React Query v5 error and data coexist, so a bare if (isError) would blank an already-loaded view the moment a background refetch fails; && !data fixes the initial-load spin while keeping stale data on screen through background errors.
  • Fix a latent crash in AnalysisDetail, which never checked its analysis query's error and would fall through to analysis.ready.
  • Update docs/queries.md and AGENTS.md to document the QueryError component and the isError && !data rule.

Secondary (Tier 2) React Query consumers used `if (isPending || !data)`,
which on a failed request fell into the loading branch and spun forever
instead of surfacing the error.

Add a shared `@base/QueryError` (a red `Alert` taking a `noun`) and gate
it on `isError && !data` across lists, sidebar widgets, dialogs, banners,
and the wrapper hooks that compose queries. The `&& !data` is
load-bearing: in React Query v5 `error` and `data` coexist, so a bare
`if (isError)` would blank an already-loaded view the moment a background
refetch fails. Erroring only when there is nothing to show fixes the
initial-load spin while keeping stale data on screen through background
errors, and still narrows `data` for the success branch.

Also fixes AnalysisDetail, which never checked its analysis query's error
and would fall through to `analysis.ready` and crash.

Update docs/queries.md and AGENTS.md to document the QueryError component
and the isError && !data rule.
@linear-code

linear-code Bot commented Jun 10, 2026

Copy link
Copy Markdown

VIR-2537

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved error handling across the application to display clear error messages when secondary data fails to load, rather than showing indefinite loading states or blank content.
  • New Features

    • Added explicit error states throughout the interface that indicate when data couldn't be fetched, with informative messages about what failed to load.

Walkthrough

This PR introduces inline error handling for secondary (Tier 2) React Query data fetches by creating a new QueryError component and systematically applying the isError && !data pattern across 40+ components. The pattern ensures that query failures display error UI rather than incorrectly routing initial-load failures to the loading state. Documentation is updated to explain why the previous isPending || !data anti-pattern masks errors. Two custom hooks extend their return types to expose isError state, and all consuming components are refactored to check error conditions before pending conditions.


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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • There is now a lot of repeated if (isError && !data) return <QueryError ...>; if (isPending) ... logic across components; consider extracting a small helper or wrapper hook for Tier 2 queries so the error/loading pattern is implemented in one place and harder to get subtly wrong in future changes.
  • In views backed by multiple queries (eg. OtuList, FileManager, CurrentOtuContextProvider, analysis create flows), you now short-circuit to a top-level <QueryError> when any of the queries error with missing data; it may be worth revisiting which pieces of these views can still render with partial/stale data and localizing QueryError to just the failing section instead of blanking the whole view.
  • For places like IndexDetail you’re still mapping a broad isError (including non-404 failures) to <NotFound />; consider distinguishing genuine not-found from transient/network errors and using QueryError for the latter so users don’t see a 404-equivalent when the backend is temporarily unavailable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There is now a lot of repeated `if (isError && !data) return <QueryError ...>; if (isPending) ...` logic across components; consider extracting a small helper or wrapper hook for Tier 2 queries so the error/loading pattern is implemented in one place and harder to get subtly wrong in future changes.
- In views backed by multiple queries (eg. `OtuList`, `FileManager`, `CurrentOtuContextProvider`, analysis create flows), you now short-circuit to a top-level `<QueryError>` when any of the queries error with missing data; it may be worth revisiting which pieces of these views can still render with partial/stale data and localizing `QueryError` to just the failing section instead of blanking the whole view.
- For places like `IndexDetail` you’re still mapping a broad `isError` (including non-404 failures) to `<NotFound />`; consider distinguishing genuine not-found from transient/network errors and using `QueryError` for the latter so users don’t see a 404-equivalent when the backend is temporarily unavailable.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (4)
apps/web/src/analyses/components/AnalysisDetail.tsx (1)

37-39: ⚖️ Poor tradeoff

Imprecise error condition for dual-query scenario.

The condition (isError || isSampleError) && (!analysis || !sample) can false-positive when one query has errored with stale data while the other is still pending. For example, if the analysis background refetch fails (leaving stale data) while the sample is on its initial load, this would show an error instead of the loading placeholder.

The pattern should check that each errored query specifically lacks data:

if ((isError && !analysis) || (isSampleError && !sample)) {
  return <QueryError noun="analysis" />;
}

This ensures error UI appears only when a query has failed and has no data to display, aligning with the React Query v5 isError && !data pattern and avoiding false positives during mixed pending/error states.

🔍 Suggested fix for precise dual-query error handling
-	if ((isError || isSampleError) && (!analysis || !sample)) {
+	if ((isError && !analysis) || (isSampleError && !sample)) {
 		return <QueryError noun="analysis" />;
 	}
apps/web/src/analyses/components/Create/CreateIimi.tsx (1)

52-54: ⚡ Quick win

Incomplete isError && !data pattern for mlModels query.

The error condition checks isErrorIndexes || isErrorMlModels without verifying that data is actually missing. Per the PR's isError && !data pattern, this should also check !mlModels to distinguish between "initial load failed" and "background refetch failed with stale data." The current check will show an error even when mlModels has stale data that could still be displayed.

Note: The indexes portion cannot implement the pattern fully because useCompatibleIndexes always returns an array (via data ?? []), making it impossible to distinguish missing data from an empty result. That's a hook design constraint, but mlModels does not have this limitation.

♻️ Recommended fix to align with the pattern
-	if (isErrorIndexes || isErrorMlModels) {
+	if (isErrorIndexes || (isErrorMlModels && !mlModels)) {
 		return <QueryError noun="analysis options" />;
 	}
apps/web/src/analyses/components/Create/CreateNuvs.tsx (1)

58-60: ⚡ Quick win

Incomplete isError && !data pattern (same issue as CreateIimi).

The error condition isErrorIndexes || isErrorSubtractions checks only the error flags without verifying missing data. This deviates from the PR's isError && !data pattern and will show errors even when stale data could be displayed.

The hooks' design (both return derived arrays/objects rather than exposing raw data) makes implementing the full pattern difficult here, but the check is overly conservative compared to the documented pattern.

apps/web/src/analyses/components/Create/CreatePathoscope.tsx (1)

58-60: ⚡ Quick win

Incomplete isError && !data pattern (same issue as CreateIimi and CreateNuvs).

The error condition checks only isErrorIndexes || isErrorSubtractions without verifying that data is missing. This deviates from the PR's isError && !data pattern and will show error UI even when stale data from successful prior fetches could be displayed.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7364826a-a823-41de-8139-c7f5fb4f12e8

📥 Commits

Reviewing files that changed from the base of the PR and between 9e6cb21 and 2c95800.

📒 Files selected for processing (41)
  • AGENTS.md
  • apps/web/src/account/components/AccountProfile.tsx
  • apps/web/src/account/components/ApiKeyPermissions.tsx
  • apps/web/src/account/components/ApiKeys.tsx
  • apps/web/src/administration/components/Banners.tsx
  • apps/web/src/administration/components/ServerSettings.tsx
  • apps/web/src/analyses/components/AnalysisDetail.tsx
  • apps/web/src/analyses/components/Create/CreateIimi.tsx
  • apps/web/src/analyses/components/Create/CreateNuvs.tsx
  • apps/web/src/analyses/components/Create/CreatePathoscope.tsx
  • apps/web/src/analyses/components/Create/QuickAnalyze.tsx
  • apps/web/src/analyses/hooks.ts
  • apps/web/src/base/QueryError.tsx
  • apps/web/src/groups/components/Groups.tsx
  • apps/web/src/hmm/components/HmmInstall.tsx
  • apps/web/src/hmm/components/HmmList.tsx
  • apps/web/src/indexes/components/IndexDetail.tsx
  • apps/web/src/indexes/components/Indexes.tsx
  • apps/web/src/indexes/components/RebuildAlert.tsx
  • apps/web/src/jobs/components/JobList.tsx
  • apps/web/src/labels/components/Labels.tsx
  • apps/web/src/otus/components/Detail/OtuSection.tsx
  • apps/web/src/otus/components/Detail/Schema/Schema.tsx
  • apps/web/src/otus/components/OtuList.tsx
  • apps/web/src/otus/queries.tsx
  • apps/web/src/references/components/Detail/AddReferenceGroup.tsx
  • apps/web/src/references/components/Detail/AddReferenceUser.tsx
  • apps/web/src/references/components/Detail/ReferenceManager.tsx
  • apps/web/src/references/components/ReferenceList.tsx
  • apps/web/src/routes/_authenticated/samples/$sampleId/general.tsx
  • apps/web/src/routes/_authenticated/samples/create.tsx
  • apps/web/src/routes/_authenticated/samples/index.tsx
  • apps/web/src/samples/components/SamplesList.tsx
  • apps/web/src/samples/components/Sidebar/DefaultSubtractions.tsx
  • apps/web/src/samples/hooks.ts
  • apps/web/src/subtraction/components/SubtractionCreate.tsx
  • apps/web/src/subtraction/components/SubtractionList.tsx
  • apps/web/src/uploads/components/FileManager.tsx
  • apps/web/src/users/components/UserGroups.tsx
  • apps/web/src/users/components/UsersList.tsx
  • docs/queries.md

Comment on lines 85 to 89
type UseCompatibleIndexesResult = {
indexes: IndexMinimal[];
isPending: boolean;
isError: boolean;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add JSDoc comment for exported type.

Per coding guidelines, every exported type must have a one-line JSDoc comment. UseCompatibleIndexesResult is missing its documentation.

📝 Proposed fix
+/** Result of useCompatibleIndexes hook providing ready, non-archived indexes grouped by reference. */
 type UseCompatibleIndexesResult = {
 	indexes: IndexMinimal[];
 	isPending: boolean;
 	isError: boolean;
 };
📝 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
type UseCompatibleIndexesResult = {
indexes: IndexMinimal[];
isPending: boolean;
isError: boolean;
};
/** Result of useCompatibleIndexes hook providing ready, non-archived indexes grouped by reference. */
type UseCompatibleIndexesResult = {
indexes: IndexMinimal[];
isPending: boolean;
isError: boolean;
};

Source: Coding guidelines

Comment on lines 106 to 111
type UseSubtractionOptionsResult = {
defaultSubtractions: SubtractionOption[];
subtractions: SubtractionOption[];
isPending: boolean;
isError: boolean;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add JSDoc comment for exported type.

Per coding guidelines, every exported type must have a one-line JSDoc comment. UseSubtractionOptionsResult is missing its documentation.

📝 Proposed fix
+/** Result of useSubtractionOptions hook providing available subtractions and defaults for sample analysis. */
 type UseSubtractionOptionsResult = {
 	defaultSubtractions: SubtractionOption[];
 	subtractions: SubtractionOption[];
 	isPending: boolean;
 	isError: boolean;
 };
📝 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
type UseSubtractionOptionsResult = {
defaultSubtractions: SubtractionOption[];
subtractions: SubtractionOption[];
isPending: boolean;
isError: boolean;
};
/** Result of useSubtractionOptions hook providing available subtractions and defaults for sample analysis. */
type UseSubtractionOptionsResult = {
defaultSubtractions: SubtractionOption[];
subtractions: SubtractionOption[];
isPending: boolean;
isError: boolean;
};

Source: Coding guidelines

Comment on lines +3 to +5
type QueryErrorProps = {
noun: string;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add JSDoc comment for exported type.

Per coding guidelines, every exported type must have a one-line JSDoc comment. QueryErrorProps is missing its documentation.

📝 Proposed fix
+/** Props for rendering an inline query error message. */
 type QueryErrorProps = {
 	noun: string;
 };
📝 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
type QueryErrorProps = {
noun: string;
};
/** Props for rendering an inline query error message. */
type QueryErrorProps = {
noun: string;
};

Source: Coding guidelines

Comment on lines 43 to 50
const { data: selectedGroup } = useFetchGroup(selectedGroupId ?? 0);

if (isPendingGroups || !groups || (groups.length && !selectedGroup)) {
if (isErrorGroups && !groups) {
return <QueryError noun="groups" />;
}

if (isPendingGroups || (groups.length && !selectedGroup)) {
return <LoadingPlaceholder className="mt-32" />;

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 | ⚡ Quick win

Handle selected-group query errors to avoid infinite loading.

When the selected group request fails with no cached data, Line 49 keeps the component in LoadingPlaceholder forever because there is no isError branch for useFetchGroup.

💡 Proposed fix
-	const { data: selectedGroup } = useFetchGroup(selectedGroupId ?? 0);
+	const {
+		data: selectedGroup,
+		isPending: isPendingSelectedGroup,
+		isError: isErrorSelectedGroup,
+	} = useFetchGroup(selectedGroupId ?? 0);

 	if (isErrorGroups && !groups) {
 		return <QueryError noun="groups" />;
 	}

-	if (isPendingGroups || (groups.length && !selectedGroup)) {
+	if (isErrorSelectedGroup && groups.length && !selectedGroup) {
+		return <QueryError noun="selected group" />;
+	}
+
+	if (isPendingGroups || (groups.length && isPendingSelectedGroup)) {
 		return <LoadingPlaceholder className="mt-32" />;
 	}

} = useFetchReference(refId);

if (isError) {
if ((isError || isErrorReference) && (!index || !reference)) {

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 | ⚡ Quick win

Refine the NotFound condition to pair each query's error with its own data check.

The current condition (isError || isErrorReference) && (!index || !reference) can show NotFound when one query has cached data (from a previous successful fetch) but is in an error state due to a failed background refetch, while the other query's data is missing for an unrelated reason. This doesn't match the React Query v5 pattern where error and data can coexist.

The correct pattern pairs each query's error state with its own data: if the index query errors and has no data, OR if the reference query errors and has no data, then show NotFound.

🔧 Proposed fix
-	if ((isError || isErrorReference) && (!index || !reference)) {
+	if ((isError && !index) || (isErrorReference && !reference)) {
 		return <NotFound />;
 	}
📝 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
if ((isError || isErrorReference) && (!index || !reference)) {
if ((isError && !index) || (isErrorReference && !reference)) {
return <NotFound />;
}

Comment on lines +17 to +27
const { isPending: isPendingReference, isError: isErrorReference } =
useFetchReference(refId);
const {
data: otu,
isPending: isPendingOTU,
isError: isErrorOTU,
} = useFetchOTU(otuId);

if (isPendingReference || isPendingOTU || !otu) {
if ((isErrorReference || isErrorOTU) && !otu) {
return <QueryError noun="OTU" />;
}

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 | ⚡ Quick win

Root cause: cross-query predicate coupling causes false error UI.

These branches combine one query’s isError with another query’s missing data, which can display <QueryError /> for mixed states (for example, one query in refetch-error with stale data while the other is just pending). Use independent checks per query: (isErrorX && !dataX) || (isErrorY && !dataY).

Comment on lines +23 to 29
if (isErrorAccount || isErrorSample) {
return { hasPermission: false, isPending: false };
}

if (isPendingSample || isPendingAccount) {
return { hasPermission: false, isPending: true };
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "isErrorAccount|isErrorSample|hasPermission|isPendingSample|isPendingAccount" apps/web/src/samples/hooks.ts
sed -n '1,120p' apps/web/src/samples/hooks.ts

Repository: virtool/virtool-ui

Length of output: 1606


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "export function useFetchAccount|useFetchAccount\\(" apps/web/src -S
rg -n "export function useFetchSample|useFetchSample\\(" apps/web/src -S
fd -t f "hooks.ts" apps/web/src/samples -a

Repository: virtool/virtool-ui

Length of output: 1987


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- apps/web/src/account/queries.ts ----"
sed -n '1,200p' apps/web/src/account/queries.ts

echo "---- apps/web/src/samples/queries.ts ----"
sed -n '1,220p' apps/web/src/samples/queries.ts

Repository: virtool/virtool-ui

Length of output: 10610


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "useFetchAccount\\(\\)" apps/web/src/account apps/web/src -S
rg -n "const \\{[^}]*isError[^}]*\\} = useFetchAccount" apps/web/src -S
rg -n "isError.*data" apps/web/src -S
rg -n "isRefetchError" apps/web/src -S

Repository: virtool/virtool-ui

Length of output: 3182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' apps/web/src/users/components/UsersList.tsx
sed -n '1,120p' apps/web/src/account/components/ApiKeys.tsx
sed -n '1,120p' apps/web/src/account/components/AccountProfile.tsx
sed -n '1,120p' apps/web/src/subtraction/components/SubtractionList.tsx

Repository: virtool/virtool-ui

Length of output: 6040


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "useCheckCanEditSample\\(" apps/web/src -S
rg -n "hasPermission" apps/web/src/samples -S

Repository: virtool/virtool-ui

Length of output: 1049


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' apps/web/src/routes/_authenticated/samples/\$sampleId.tsx
sed -n '1,120p' apps/web/src/analyses/components/AnalysisList.tsx
sed -n '1,120p' apps/web/src/samples/components/SamplesToolbar.tsx
sed -n '1,120p' apps/web/src/samples/components/Detail/SampleRights.tsx

Repository: virtool/virtool-ui

Length of output: 9712


Fix permission gating to not deny on isError when cached data exists

apps/web/src/samples/hooks.ts:23-29 currently returns hasPermission: false when isErrorAccount || isErrorSample even if account/sample is still present. This codebase typically gates errors like this: if (isError && !data) { ... }, so permission checks should follow the same isError && !data pattern.

Suggested fix
-	if (isErrorAccount || isErrorSample) {
+	if ((isErrorAccount && !account) || (isErrorSample && !sample)) {
 		return { hasPermission: false, isPending: false };
 	}

-	if (isPendingSample || isPendingAccount) {
+	if (isPendingSample || isPendingAccount || !account || !sample) {
 		return { hasPermission: false, isPending: true };
 	}
📝 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
if (isErrorAccount || isErrorSample) {
return { hasPermission: false, isPending: false };
}
if (isPendingSample || isPendingAccount) {
return { hasPermission: false, isPending: true };
}
if ((isErrorAccount && !account) || (isErrorSample && !sample)) {
return { hasPermission: false, isPending: false };
}
if (isPendingSample || isPendingAccount || !account || !sample) {
return { hasPermission: false, isPending: true };
}

Comment on lines +59 to +61
if ((isErrorAccount || isErrorFiles) && (!account || !files)) {
return <QueryError noun="files" />;
}

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 | ⚡ Quick win

Decouple error guards per query to prevent false error UI.

The current combined predicate can return QueryError when only one query errored and the other is just pending/missing, which incorrectly short-circuits loading/success paths.

Suggested fix
-if ((isErrorAccount || isErrorFiles) && (!account || !files)) {
-	return <QueryError noun="files" />;
-}
+if (isErrorAccount && !account) {
+	return <QueryError noun="account" />;
+}
+
+if (isErrorFiles && !files) {
+	return <QueryError noun="files" />;
+}
📝 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
if ((isErrorAccount || isErrorFiles) && (!account || !files)) {
return <QueryError noun="files" />;
}
if (isErrorAccount && !account) {
return <QueryError noun="account" />;
}
if (isErrorFiles && !files) {
return <QueryError noun="files" />;
}

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant