Skip to content

Commit 7109fe7

Browse files
fix(ui): Surface fetch errors instead of swallowing them (#1168)
* fix(ui): Surface fetch errors instead of swallowing them Client-side fetch and submit handlers caught errors silently, leaving users with hung spinners, blank pages, or misleading empty states. Add handleClientError(err, context) in src/utils/ that logs with context and returns a user-safe message, and use it at every fixed site. Components now set an error state and render the message with a retry affordance (retry button or the original action button). Fixed sites: - challenges/index: recommended + history fetches (per-panel error and retry), hint and solution handlers (shared error banner) - challenges/[id]: hint, coach, and solution handlers - containers/profile/bio: profile load (was an infinite spinner) and profile save - components/profile/TroopDashboard: module-change PATCH - lessons/lesson/[id]: lesson load (was a blank page), with retry - community/index: candidates load, with retry Non-ok responses in these paths now throw and surface too, instead of being ignored. Deliberate non-critical suppressions (safe-storage, analytics, optional decorative panels, hooks that track their own errors) are left alone. * fix(community): Surface non-OK candidate fetches instead of empty UI * fix(profile): Label select, add backdrop prefix, drop repo animation * chore: Re-sync branch with master to refresh merge status
1 parent 82677a5 commit 7109fe7

11 files changed

Lines changed: 430 additions & 179 deletions

File tree

__tests__/pages/challenges/index.test.tsx

Lines changed: 170 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
22

33
// Mock next/router
44
vi.mock("next/router", () => ({
@@ -23,6 +23,7 @@ vi.mock("@/lib/challenge-runner", () => ({
2323
}));
2424

2525
import { runChallenge } from "@/lib/challenge-runner";
26+
2627
const mockRunChallenge = vi.mocked(runChallenge);
2728

2829
const mockFetch = vi.fn();
@@ -103,15 +104,16 @@ describe("ChallengesPage", () => {
103104
if (url.includes("start") && opts?.method === "POST") {
104105
return Promise.resolve({
105106
ok: true,
106-
json: () => Promise.resolve({
107-
id: "ch-1",
108-
title: "FizzBuzz",
109-
description: "Write a FizzBuzz function",
110-
topic: "javascript",
111-
difficulty: "easy",
112-
language: "javascript",
113-
starter_code: "function fizzBuzz(n) {\n // your code\n}",
114-
}),
107+
json: () =>
108+
Promise.resolve({
109+
id: "ch-1",
110+
title: "FizzBuzz",
111+
description: "Write a FizzBuzz function",
112+
topic: "javascript",
113+
difficulty: "easy",
114+
language: "javascript",
115+
starter_code: "function fizzBuzz(n) {\n // your code\n}",
116+
}),
115117
});
116118
}
117119
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
@@ -155,27 +157,29 @@ describe("ChallengesPage", () => {
155157
if (url.includes("start") && opts?.method === "POST") {
156158
return Promise.resolve({
157159
ok: true,
158-
json: () => Promise.resolve({
159-
id: "ch-2",
160-
title: "Sum Array",
161-
description: "Sum all elements",
162-
topic: "javascript",
163-
difficulty: "easy",
164-
language: "javascript",
165-
test_cases: [
166-
{ input: "[1, 2, 3]", expected_output: "6", hidden: false },
167-
],
168-
}),
160+
json: () =>
161+
Promise.resolve({
162+
id: "ch-2",
163+
title: "Sum Array",
164+
description: "Sum all elements",
165+
topic: "javascript",
166+
difficulty: "easy",
167+
language: "javascript",
168+
test_cases: [
169+
{ input: "[1, 2, 3]", expected_output: "6", hidden: false },
170+
],
171+
}),
169172
});
170173
}
171174
if (url.includes("submit") && opts?.method === "POST") {
172175
return Promise.resolve({
173176
ok: true,
174-
json: () => Promise.resolve({
175-
passed: true,
176-
score: 100,
177-
feedback: "Perfect solution!",
178-
}),
177+
json: () =>
178+
Promise.resolve({
179+
passed: true,
180+
score: 100,
181+
feedback: "Perfect solution!",
182+
}),
179183
});
180184
}
181185
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
@@ -200,8 +204,8 @@ describe("ChallengesPage", () => {
200204
});
201205

202206
// The submit POST must include the runner's structured client_results.
203-
const submitCall = mockFetch.mock.calls.find(([url, opts]) =>
204-
String(url).includes("submit") && opts?.method === "POST"
207+
const submitCall = mockFetch.mock.calls.find(
208+
([url, opts]) => String(url).includes("submit") && opts?.method === "POST"
205209
);
206210
expect(submitCall).toBeDefined();
207211
const body = JSON.parse(submitCall![1].body);
@@ -215,16 +219,17 @@ describe("ChallengesPage", () => {
215219
if (url.includes("recommended")) {
216220
return Promise.resolve({
217221
ok: true,
218-
json: () => Promise.resolve([
219-
{
220-
id: "rec-1",
221-
title: "Binary Search",
222-
description: "Implement binary search",
223-
topic: "algorithms",
224-
difficulty: "medium",
225-
language: "javascript",
226-
},
227-
]),
222+
json: () =>
223+
Promise.resolve([
224+
{
225+
id: "rec-1",
226+
title: "Binary Search",
227+
description: "Implement binary search",
228+
topic: "algorithms",
229+
difficulty: "medium",
230+
language: "javascript",
231+
},
232+
]),
228233
});
229234
}
230235
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
@@ -244,26 +249,27 @@ describe("ChallengesPage", () => {
244249
if (url.includes("history")) {
245250
return Promise.resolve({
246251
ok: true,
247-
json: () => Promise.resolve([
248-
{
249-
id: "att-1",
250-
challenge_id: "ch-1",
251-
title: "Two Sum",
252-
topic: "arrays",
253-
difficulty: "easy",
254-
passed: true,
255-
submitted_at: "2026-04-01T10:00:00Z",
256-
},
257-
{
258-
id: "att-2",
259-
challenge_id: "ch-2",
260-
title: "Merge Sort",
261-
topic: "algorithms",
262-
difficulty: "hard",
263-
passed: false,
264-
submitted_at: "2026-04-02T10:00:00Z",
265-
},
266-
]),
252+
json: () =>
253+
Promise.resolve([
254+
{
255+
id: "att-1",
256+
challenge_id: "ch-1",
257+
title: "Two Sum",
258+
topic: "arrays",
259+
difficulty: "easy",
260+
passed: true,
261+
submitted_at: "2026-04-01T10:00:00Z",
262+
},
263+
{
264+
id: "att-2",
265+
challenge_id: "ch-2",
266+
title: "Merge Sort",
267+
topic: "algorithms",
268+
difficulty: "hard",
269+
passed: false,
270+
submitted_at: "2026-04-02T10:00:00Z",
271+
},
272+
]),
267273
});
268274
}
269275
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
@@ -297,19 +303,117 @@ describe("ChallengesPage", () => {
297303
});
298304
});
299305

306+
it("shows an error with retry when the recommended fetch rejects", async () => {
307+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
308+
mockFetch.mockImplementation((url: string) => {
309+
if (url.includes("recommended")) {
310+
return Promise.reject(new TypeError("Failed to fetch"));
311+
}
312+
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
313+
});
314+
315+
render(<ChallengesPage />);
316+
317+
await waitFor(() => {
318+
expect(screen.getByText("Failed to fetch")).toBeInTheDocument();
319+
});
320+
expect(screen.getByText("Retry")).toBeInTheDocument();
321+
322+
// Retry refetches and renders the recovered list
323+
mockFetch.mockImplementation((url: string) => {
324+
if (url.includes("recommended")) {
325+
return Promise.resolve({
326+
ok: true,
327+
json: () =>
328+
Promise.resolve([
329+
{
330+
id: "rec-1",
331+
title: "Binary Search",
332+
topic: "algorithms",
333+
difficulty: "medium",
334+
},
335+
]),
336+
});
337+
}
338+
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
339+
});
340+
341+
fireEvent.click(screen.getByText("Retry"));
342+
343+
await waitFor(() => {
344+
expect(screen.getByText("Binary Search")).toBeInTheDocument();
345+
});
346+
consoleSpy.mockRestore();
347+
});
348+
349+
it("shows an error when the history request returns non-ok", async () => {
350+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
351+
mockFetch.mockImplementation((url: string) => {
352+
if (url.includes("history")) {
353+
return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
354+
}
355+
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
356+
});
357+
358+
render(<ChallengesPage />);
359+
360+
await waitFor(() => {
361+
expect(screen.getByText("Failed to load your history")).toBeInTheDocument();
362+
});
363+
consoleSpy.mockRestore();
364+
});
365+
366+
it("surfaces an error when fetching a hint fails", async () => {
367+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
368+
mockFetch.mockImplementation((url: string, opts?: any) => {
369+
if (url.includes("start") && opts?.method === "POST") {
370+
return Promise.resolve({
371+
ok: true,
372+
json: () =>
373+
Promise.resolve({
374+
id: "ch-1",
375+
title: "Test",
376+
description: "Test desc",
377+
topic: "js",
378+
difficulty: "easy",
379+
language: "javascript",
380+
}),
381+
});
382+
}
383+
if (url.includes("hint")) {
384+
return Promise.reject(new TypeError("Failed to fetch"));
385+
}
386+
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
387+
});
388+
389+
render(<ChallengesPage />);
390+
391+
fireEvent.click(screen.getByText("Start Challenge"));
392+
393+
await waitFor(() => screen.getByText("Test"));
394+
395+
fireEvent.click(screen.getByText("Get Hint (0 used)"));
396+
397+
await waitFor(() => {
398+
expect(screen.getByText("Failed to fetch")).toBeInTheDocument();
399+
});
400+
consoleSpy.mockRestore();
401+
});
402+
300403
it("navigates back from active challenge", async () => {
301404
mockFetch.mockImplementation((url: string, opts?: any) => {
302405
if (url.includes("start") && opts?.method === "POST") {
303406
return Promise.resolve({
304407
ok: true,
305-
json: () => Promise.resolve({
306-
id: "ch-1",
307-
title: "Test",
308-
description: "Test desc",
309-
topic: "js",
310-
difficulty: "easy",
311-
language: "javascript",
312-
}),
408+
json: () =>
409+
Promise.resolve({
410+
id: "ch-1",
411+
title: "Test",
412+
description: "Test desc",
413+
topic: "js",
414+
difficulty: "easy",
415+
language: "javascript",
416+
}),
313417
});
314418
}
315419
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });

src/assets/css/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@
301301
NAVIGATION UPGRADE — Premium styling
302302
============================================= */
303303
.header-inner {
304+
-webkit-backdrop-filter: blur(12px);
304305
backdrop-filter: blur(12px);
305306
}
306307

src/components/profile/RepositoryShowcase.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { motion } from "motion/react";
21
import { useState } from "react";
32
import type { GitHubRepo } from "@/types/profile";
43
import { GITHUB_LANGUAGE_COLORS } from "@/types/profile";
@@ -85,15 +84,12 @@ const RepositoryShowcase = ({ repos, isLoading }: RepositoryShowcaseProps) => {
8584

8685
{/* Repo list */}
8786
<div className="tw-grid tw-gap-4 md:tw-grid-cols-2">
88-
{sorted.map((repo, i) => (
89-
<motion.a
87+
{sorted.map((repo) => (
88+
<a
9089
key={repo.id}
9190
href={repo.html_url}
9291
target="_blank"
9392
rel="noopener noreferrer"
94-
initial={{ opacity: 0, y: 10 }}
95-
animate={{ opacity: 1, y: 0 }}
96-
transition={{ duration: 0.3, delay: i * 0.05 }}
9793
className="tw-block tw-rounded-lg tw-border tw-border-navy/10 tw-bg-white tw-p-5 tw-shadow-sm tw-transition-all hover:tw-border-gold/30 hover:tw-shadow-md hover:-tw-translate-y-0.5"
9894
>
9995
<div className="tw-flex tw-items-start tw-justify-between">
@@ -142,7 +138,7 @@ const RepositoryShowcase = ({ repos, isLoading }: RepositoryShowcaseProps) => {
142138
</span>
143139
))}
144140
</div>
145-
</motion.a>
141+
</a>
146142
))}
147143
</div>
148144
</div>

0 commit comments

Comments
 (0)