Skip to content

feat: improve frontend error visibility - #151

Merged
mankatcheung merged 1 commit into
mainfrom
worktree-frontend-error-visibility
Jul 30, 2026
Merged

mankatcheung merged 1 commit into
mainfrom
worktree-frontend-error-visibility

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Users had no way to tell "no data" from "something failed": a failed query rendered the exact same empty state as a genuinely empty result, and a failed mutation (board drag-and-drop status change, bulk actions, note/contact/interview-round CRUD, logout) just did nothing with zero feedback. No error boundary existed either, so an uncaught render exception would blank the page.

Backend (small, fixed first since the frontend leans on reliable error codes)

  • NotFoundError (AppError.ts) was double-appending " not found" when the use-case's message already ended with it — "Application not found" became "Application not found not found" on the wire. Fixed to only append when absent.
  • 3 use-cases (GenerateCoverLetterUseCase, ComputeHealthScoreUseCase, ParseJobDescriptionUseCase) threw plain uncoded Errors for their not-found/forbidden/validation cases, so formatError.ts masked them as a generic "Internal server error" instead of the real reason. Attached the missing ERROR_CODES, matching the pattern every sibling use-case already follows.

Frontend

  • New src/lib/errors.ts: getErrorMessage() — the one place that maps any thrown error (GraphQL, network, or otherwise) to a user-facing string, keyed off extensions.code. Replaces three different ad-hoc extractors that existed before this (login's local extractGqlError, two bare hardcoded fallback strings in the application forms).
  • Added sonner + a global MutationCache.onError in queryClient.ts — fixes every mutation that only wired onSuccess with a single change, since it fires in addition to any mutation-level handler that already exists (e.g. the login/register forms keep their own inline error box unchanged).
  • Added an errorComponent to the root route so a render exception shows a friendly fallback with a reload button instead of a blank page.
  • New ErrorState component (mirrors the app's existing empty-state visual convention — icon, centered text, py-12 — just in red, with a retry action) wired into every primary query's isLoading/isError/empty ternary chain: dashboard, applications board, applications list, application detail (+ its notes query), analytics.

Test plan

  • pnpm --filter @job-finder/api typecheck / test (746 tests) / build — all clean
  • pnpm --filter @job-finder/web typecheck / lint / build — all clean
  • pnpm --filter @job-finder/web test — at the pre-existing baseline (39 failing, unrelated localStorage-in-jsdom environment issue confirmed via git-stash comparison, not introduced by this PR); zero new regressions
  • End-to-end verification against a live dev server + seeded DB: surgically failed just the ApplicationsPage query (not the session refresh, which this app's plain-<a> sidebar nav depends on for every navigation) — confirmed ErrorState renders with a working "Try again" that recovers once the query succeeds again; failed a CreateNote mutation — confirmed a sonner toast renders with a readable message; confirmed both render correctly in dark mode

🤖 Generated with Claude Code

https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM

Summary by CodeRabbit

  • New Features

    • Added consistent error messages across sign-in, applications, analytics and dashboard workflows.
    • Added reusable error screens with “Try again” actions for failed data loads.
    • Added app-wide notifications for unhandled mutation errors.
    • Added a fallback screen with reload and dashboard navigation when unexpected errors occur.
  • Bug Fixes

    • Prevented duplicated “not found” wording in error messages.
    • Improved handling of missing, restricted and invalid application data.
    • Added clearer validation feedback when job description content is unavailable.

Users had no way to tell "no data" from "something failed" — a failed
query rendered the exact same empty state as a genuinely empty result,
and a failed mutation (status change, note/contact/interview-round
CRUD, logout) just did nothing with zero feedback. No error boundary
existed either, so an uncaught render exception blanked the page.

Backend (small fixes everything else leans on):
- NotFoundError (AppError.ts) was double-appending " not found" when
  the use-case's message already ended with it ("Application not
  found" -> "Application not found not found" on the wire) — fixed,
  only appends when absent.
- 3 use-cases (GenerateCoverLetterUseCase, ComputeHealthScoreUseCase,
  ParseJobDescriptionUseCase) threw plain uncoded Errors for their
  not-found/forbidden/validation cases, so formatError.ts masked them
  as generic "Internal server error" instead of the real reason.
  Attached the missing ERROR_CODES, matching the pattern every sibling
  use-case already follows.

Frontend:
- New src/lib/errors.ts: getErrorMessage(), the one place that maps
  any thrown error (GraphQL, network, or otherwise) to a user-facing
  string, keyed off extensions.code. Replaces three different ad-hoc
  extractors (login.tsx's local extractGqlError, two bare hardcoded
  fallback strings in the application forms).
- Added sonner + a global MutationCache.onError in queryClient.ts —
  fixes every mutation that only wired onSuccess (board drag-and-drop
  status change, bulk actions, note/contact/interview-round CRUD,
  logout) with one change, since it fires in addition to any
  mutation-level handler.
- Added an errorComponent to the root route so a render exception
  shows a fallback with a reload button instead of a blank page.
- New ErrorState component (mirrors the app's existing empty-state
  visual convention, in red, with a retry action) wired into every
  primary query's isLoading/isError/empty ternary chain: dashboard,
  applications board, applications list, application detail (+ its
  notes query), analytics.

Verified end-to-end against a live dev server: surgically failing just
the ApplicationsPage query (not the session refresh, which this app's
plain-<a> sidebar nav depends on for every navigation) shows ErrorState
with working retry; failing a mutation shows a sonner toast; both
render correctly in dark mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The API now emits structured error codes and avoids duplicate not-found suffixes. The web app centralises error messages, adds mutation toasts, route fallbacks, and retryable query states, and updates authentication and application forms to use the shared handling.

Changes

Error handling flow

Layer / File(s) Summary
Structured API error contracts
apps/api/src/use-cases/..., apps/api/src/http/errors/..., apps/api/src/__tests__/application/..., apps/api/src/__tests__/http/errors/...
Use cases now attach NOT_FOUND, FORBIDDEN, and VALIDATION codes; not-found formatting prevents duplicate suffixes; tests verify these contracts.
Shared web error infrastructure
apps/web/src/constants.ts, apps/web/src/lib/errors.ts, apps/web/src/lib/queryClient.ts, apps/web/src/components/ErrorState.tsx, apps/web/src/routes/__root.tsx, apps/web/package.json
GraphQL errors are mapped to user-facing messages, mutation failures produce toasts, retryable error UI is added, and the root route gains fallback and themed toaster rendering.
Query failure and retry flows
apps/web/src/routes/_authenticated/...
Authenticated query pages now render retryable or inline error states for analytics, applications, boards, details, notes, and dashboard data.
Mutation and authentication error messages
apps/web/src/routes/login.tsx, apps/web/src/routes/_authenticated/applications/new.tsx, apps/web/src/routes/_authenticated/applications/$applicationId/edit.tsx, apps/web/src/__tests__/components/*
Login and application form failures use getErrorMessage; tests verify coded GraphQL failures and generic fallback text.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LoginPage
  participant GraphQLAPI
  participant getErrorMessage
  LoginPage->>GraphQLAPI: submit credentials or verification code
  GraphQLAPI-->>LoginPage: return success or coded error
  LoginPage->>getErrorMessage: resolve caught error
  getErrorMessage-->>LoginPage: return display message
Loading

Possibly related PRs

Poem

I’m a rabbit with errors neatly sewn,
Coded messages now brightly shown.
Retry buttons hop into view,
Toasts chime when failures brew.
“Not found” stays once—not two!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main frontend error-visibility work, though it underplays the backend error-code changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-frontend-error-visibility

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.

@github-actions

Copy link
Copy Markdown

@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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/lib/errors.ts`:
- Around line 13-15: Update isGraphQLErrorLike to require that error.response is
a non-null object before treating the value as GraphQLErrorLike, so responses
such as null are rejected and the generic fallback remains available.

In `@apps/web/src/lib/queryClient.ts`:
- Around line 5-13: The comment describing mutationCache.onError incorrectly
implies locally handled mutations are excluded. Update the surrounding comment
for queryClient and MutationCache to state that the global handler runs for
every mutation failure, including mutations with their own onError, unless an
explicit opt-out mechanism is implemented; do not claim inline-handled mutations
are skipped.

In `@apps/web/src/routes/_authenticated/dashboard.tsx`:
- Around line 118-119: Update the dashboard rendering around the Recent
applications error branch and metric cards so a failed query does not calculate
or display zero-valued metrics from empty fallback data. Render a page-level
ErrorState for the query failure, or propagate the error/loading state to the
stat cards while preserving normal metrics for successful queries.
🪄 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 Plus

Run ID: b8a19ebf-93f5-4203-98a4-3140a6bc3b41

📥 Commits

Reviewing files that changed from the base of the PR and between b50e58a and 42b5bf8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (26)
  • apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts
  • apps/api/src/__tests__/application/healthScore/ComputeHealthScoreUseCase.test.ts
  • apps/api/src/__tests__/application/jobDescription/ParseJobDescriptionUseCase.test.ts
  • apps/api/src/__tests__/http/errors/AppError.test.ts
  • apps/api/src/__tests__/http/errors/formatError.test.ts
  • apps/api/src/http/errors/AppError.ts
  • apps/api/src/use-cases/application/ComputeHealthScoreUseCase.ts
  • apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts
  • apps/api/src/use-cases/jobDescription/ParseJobDescriptionUseCase.ts
  • apps/web/package.json
  • apps/web/src/__tests__/components/EditApplicationPage.test.tsx
  • apps/web/src/__tests__/components/LoginPage.test.tsx
  • apps/web/src/__tests__/components/NewApplicationPage.test.tsx
  • apps/web/src/components/ErrorState.tsx
  • apps/web/src/constants.ts
  • apps/web/src/lib/errors.ts
  • apps/web/src/lib/queryClient.ts
  • apps/web/src/routes/__root.tsx
  • apps/web/src/routes/_authenticated/-analytics-page.tsx
  • apps/web/src/routes/_authenticated/applications/$applicationId/edit.tsx
  • apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx
  • apps/web/src/routes/_authenticated/applications/-board-page.tsx
  • apps/web/src/routes/_authenticated/applications/index.tsx
  • apps/web/src/routes/_authenticated/applications/new.tsx
  • apps/web/src/routes/_authenticated/dashboard.tsx
  • apps/web/src/routes/login.tsx

Comment on lines +13 to +15
function isGraphQLErrorLike(error: unknown): error is GraphQLErrorLike {
return typeof error === 'object' && error !== null && 'response' in error;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate the response value, not only its presence.

{ response: null } passes this guard, then Line 42 throws while reading response.errors; the recovery UI fails instead of showing the generic fallback.

Proposed fix
 function isGraphQLErrorLike(error: unknown): error is GraphQLErrorLike {
-  return typeof error === 'object' && error !== null && 'response' in error;
+  return (
+    typeof error === 'object' &&
+    error !== null &&
+    'response' in error &&
+    typeof (error as { response?: unknown }).response === 'object' &&
+    (error as { response?: unknown }).response !== null
+  );
 }
📝 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
function isGraphQLErrorLike(error: unknown): error is GraphQLErrorLike {
return typeof error === 'object' && error !== null && 'response' in error;
}
function isGraphQLErrorLike(error: unknown): error is GraphQLErrorLike {
return (
typeof error === 'object' &&
error !== null &&
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/errors.ts` around lines 13 - 15, Update isGraphQLErrorLike
to require that error.response is a non-null object before treating the value as
GraphQLErrorLike, so responses such as null are rejected and the generic
fallback remains available.

Comment on lines 5 to +13
export const queryClient = new QueryClient({
// Fires for every mutation that doesn't already handle its own error (in
// addition to, not instead of, any mutation-level onError) — this is the
// safety net for the many mutations (board drag-and-drop, bulk actions,
// note/contact/interview-round CRUD, logout) that previously failed with
// no feedback to the user at all.
mutationCache: new MutationCache({
onError: (error) => toast.error(getErrorMessage(error)),
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate queryClient file =="
fd -a 'queryClient\.ts$' . || true

echo "== relevant file =="
if [ -f apps/web/src/lib/queryClient.ts ]; then
  cat -n apps/web/src/lib/queryClient.ts
fi

echo "== package versions =="
for f in package.json apps/web/package.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    node -e "const p=require('./$f'); console.log(JSON.stringify({dependencies:p.dependencies?.['`@tanstack/react-query`'], devDependencies:p.devDependencies?.['`@tanstack/react-query`']}, null, 2))"
  fi
done

echo "== usages of toast/error handling in web =="
rg -n "toast\.(error|success)|onError:|onSuccess:|onSettled:" apps/web/src --glob '*.{ts,tsx}' | head -200

Repository: mankatcheung/job-finder

Length of output: 2964


🌐 Web query:

TanStack Query 5 MutationCache onError mutation-level onError callbacks invoked together documentation

💡 Result:

In TanStack Query v5, when a mutation encounters an error, the onError callbacks defined in the MutationCache and those defined at the mutation level (via useMutation or the mutate function) are both invoked [1][2]. They are not mutually exclusive, and the MutationCache callback does not "override" or prevent the mutation-level callbacks from running [3][2][4]. Execution Order The callbacks are executed in a specific order within the mutation lifecycle [1]: 1. MutationCache onError: The global onError handler defined in the MutationCache is executed first [1][2]. 2. Mutation-level onError: The onError handler defined in the useMutation hook or the mutate function is executed second [1]. Key Behavioral Notes - Global vs. Local: The MutationCache onError handler is designed to be a global, always-called handler (e.g., for global error logging or toast notifications), while mutation-level handlers are intended for component-specific logic [3][5][2]. - No Suppression: There is no built-in mechanism to "stop" the chain or suppress the global MutationCache handler when a local handler is present [2]. If you need to avoid triggering the global handler conditionally, common workarounds include checking meta information or variables within the MutationCache handler to decide whether to execute its logic [6][7]. - Lifecycle Difference: Mutation-level callbacks (especially those passed to mutate) are subject to component lifecycle constraints—they may not execute if the component unmounts before the mutation finishes [5][8][4]. In contrast, MutationCache handlers are not tied to component lifecycles and will always execute for any mutation error [3][2].

Citations:


Do not imply that locally handled mutations are excluded.

MutationCache.onError is global in TanStack Query v5 and still runs when a mutation also defines its own onError; this handler has no opt-out marker. Mutations with inline error handling may therefore display this toast in addition to their local feedback, so either document that all mutation failures are toasted or add an explicit guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/queryClient.ts` around lines 5 - 13, The comment describing
mutationCache.onError incorrectly implies locally handled mutations are
excluded. Update the surrounding comment for queryClient and MutationCache to
state that the global handler runs for every mutation failure, including
mutations with their own onError, unless an explicit opt-out mechanism is
implemented; do not claim inline-handled mutations are skipped.

Comment on lines +118 to +119
) : isError ? (
<ErrorState error={error} onRetry={() => refetch()} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid showing zero-valued metrics after a failed query.

Line 118 renders an error only for Recent applications, while the stat cards above receive loading={false} and calculate from an empty fallback. A failed request therefore displays misleading zero counts. Render a page-level error state, or keep the metric cards in an error/loading state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/dashboard.tsx` around lines 118 - 119,
Update the dashboard rendering around the Recent applications error branch and
metric cards so a failed query does not calculate or display zero-valued metrics
from empty fallback data. Render a page-level ErrorState for the query failure,
or propagate the error/loading state to the stat cards while preserving normal
metrics for successful queries.

@mankatcheung
mankatcheung merged commit 49cfd3c into main Jul 30, 2026
10 checks passed
mankatcheung added a commit that referenced this pull request Jul 30, 2026
Resolves conflicts with the frontend-error-visibility work (#151),
which touched the same NOT_FOUND/FORBIDDEN/VALIDATION coded-error
sites in GenerateCoverLetterUseCase/ParseJobDescriptionUseCase and
added getErrorMessage/ErrorState to the application detail page.
Kept both: the AI_NOT_CONFIGURED provider-factory check from this
branch alongside #151's error coding/messaging fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM
@mankatcheung
mankatcheung deleted the worktree-frontend-error-visibility branch August 18, 2026 11:29
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