diff --git a/web/src/components/community/AddHeaderModal.tsx b/web/src/components/community/AddHeaderModal.tsx
index efcc23645..e052e3199 100644
--- a/web/src/components/community/AddHeaderModal.tsx
+++ b/web/src/components/community/AddHeaderModal.tsx
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { captureEvent } from "@/lib/posthogEvents";
import { authClient } from "@/lib/community/auth-client";
-import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
+import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase";
import { useSubmitLatch } from "@/lib/community/submitLatch";
import { useDialog } from "@/lib/hooks/useDialog";
import { useBackdropClose } from "@/lib/hooks/useBackdropClose";
@@ -93,9 +93,8 @@ export default function AddHeaderModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify(call.body),
});
- const payload = (await response.json()) as { error?: string; hasCpp?: boolean };
if (!response.ok) {
- setError(payload.error ?? "Could not save the header.");
+ setError(await readApiError(response, "Could not save the header"));
return;
}
captureEvent(
diff --git a/web/src/components/community/AddPerformanceModal.tsx b/web/src/components/community/AddPerformanceModal.tsx
index b486ae6bd..158532536 100644
--- a/web/src/components/community/AddPerformanceModal.tsx
+++ b/web/src/components/community/AddPerformanceModal.tsx
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { captureEvent } from "@/lib/posthogEvents";
import { authClient } from "@/lib/community/auth-client";
-import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
+import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase";
import { useSubmitLatch } from "@/lib/community/submitLatch";
import { useDialog } from "@/lib/hooks/useDialog";
import { useBackdropClose } from "@/lib/hooks/useBackdropClose";
@@ -64,9 +64,8 @@ export default function AddPerformanceModal({
body: JSON.stringify({ performanceJson: json, note }),
},
);
- const payload = (await response.json()) as { error?: string };
if (!response.ok) {
- setError(payload.error ?? "Could not save the performance.");
+ setError(await readApiError(response, "Could not save the performance"));
return;
}
captureEvent("community_publish_performance", { pattern_id: patternId });
diff --git a/web/src/components/community/CommentSection.test.tsx b/web/src/components/community/CommentSection.test.tsx
new file mode 100644
index 000000000..5b08944ba
--- /dev/null
+++ b/web/src/components/community/CommentSection.test.tsx
@@ -0,0 +1,99 @@
+// With the app down, nginx answers a comment with its own 502 page. The thread
+// read that page as JSON, the read threw, and the catch said "Network error."
+// exactly as it does when the wire is cut. The server had answered, so the
+// thread says which status it answered with. A fetch that never reaches
+// anything still says "Network error."
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import CommentSection, { type CommentView } from "./CommentSection";
+
+const refresh = vi.fn();
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh, push: vi.fn() }) }));
+vi.mock("@/lib/community/auth-client", () => ({
+ authClient: {
+ useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }),
+ },
+}));
+
+const NGINX_502 =
+ "
502 Bad Gateway502 Bad Gateway
nginx";
+
+const MINE: CommentView = {
+ id: "c1",
+ userId: "u1",
+ body: "first light",
+ createdAt: "2026-10-26T09:00:00.000Z",
+ editedAt: null,
+ username: "ada",
+ displayUsername: "ada",
+};
+
+const htmlPage = (status: number) =>
+ new Response(NGINX_502, { status, headers: { "Content-Type": "text/html" } });
+
+let fetchMock: ReturnType;
+beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+ refresh.mockReset();
+});
+
+function post(text: string) {
+ render();
+ fireEvent.change(screen.getByRole("textbox", { name: "Comment" }), { target: { value: text } });
+ fireEvent.click(screen.getByRole("button", { name: "Post" }));
+}
+
+describe("comment errors", () => {
+ it("says the status when the server answers a post with an HTML page", async () => {
+ fetchMock.mockResolvedValue(htmlPage(502));
+ post("hello");
+ expect(await screen.findByText("Comment failed (HTTP 502).")).toBeTruthy();
+ expect(screen.queryByText("Network error.")).toBeNull();
+ // The draft stays for another try.
+ expect((screen.getByRole("textbox", { name: "Comment" }) as HTMLTextAreaElement).value).toBe(
+ "hello",
+ );
+ });
+
+ it("shows the route's own words when it sent some", async () => {
+ fetchMock.mockResolvedValue(
+ Response.json({ error: "Too many comments — wait a minute and try again." }, { status: 429 }),
+ );
+ post("hello");
+ expect(await screen.findByText("Too many comments — wait a minute and try again.")).toBeTruthy();
+ });
+
+ it("still says Network error when nothing answered", async () => {
+ fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
+ post("hello");
+ expect(await screen.findByText("Network error.")).toBeTruthy();
+ });
+
+ it("posts and clears the field on ok", async () => {
+ fetchMock.mockResolvedValue(Response.json({ ok: true }, { status: 201 }));
+ post("hello");
+ await vi.waitFor(() => expect(refresh).toHaveBeenCalled());
+ expect((screen.getByRole("textbox", { name: "Comment" }) as HTMLTextAreaElement).value).toBe("");
+ });
+
+ it("says the status when saving an edit meets an HTML page", async () => {
+ fetchMock.mockResolvedValue(htmlPage(502));
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "edit" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("Could not save the edit (HTTP 502).")).toBeTruthy();
+ });
+
+ it("says the status when a delete meets an empty answer", async () => {
+ fetchMock.mockResolvedValue(new Response(null, { status: 413 }));
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "delete" }));
+ fireEvent.click(screen.getByRole("button", { name: "press again" }));
+ expect(await screen.findByText("Could not delete the comment (HTTP 413).")).toBeTruthy();
+ });
+});
diff --git a/web/src/components/community/CommentSection.tsx b/web/src/components/community/CommentSection.tsx
index 696bbd27d..ba453aedf 100644
--- a/web/src/components/community/CommentSection.tsx
+++ b/web/src/components/community/CommentSection.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import BodyComposer from "./BodyComposer";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/community/auth-client";
-import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
+import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase";
import { useSubmitLatch } from "@/lib/community/submitLatch";
import { COMMENT_MAX } from "@/lib/community/validate";
import AuthModal from "./AuthModal";
@@ -73,8 +73,7 @@ export default function CommentSection({
},
);
if (!response.ok) {
- const payload = (await response.json()) as { error?: string };
- setRowError(payload.error ?? "Could not save the edit.");
+ setRowError(await readApiError(response, "Could not save the edit"));
return;
}
setEditingId(null);
@@ -95,8 +94,7 @@ export default function CommentSection({
{ method: "DELETE", ...COMMUNITY_FETCH_INIT },
);
if (!response.ok) {
- const payload = (await response.json()) as { error?: string };
- setRowError(payload.error ?? "Could not delete the comment.");
+ setRowError(await readApiError(response, "Could not delete the comment"));
return;
}
setConfirmDelete(null);
@@ -124,9 +122,8 @@ export default function CommentSection({
body: JSON.stringify({ body: text }),
},
);
- const payload = (await response.json()) as { error?: string };
if (!response.ok) {
- setError(payload.error ?? "Comment failed.");
+ setError(await readApiError(response, "Comment failed"));
return;
}
diff --git a/web/src/components/community/EditDetailsModal.tsx b/web/src/components/community/EditDetailsModal.tsx
index 6c5cf78d9..50e9f90d7 100644
--- a/web/src/components/community/EditDetailsModal.tsx
+++ b/web/src/components/community/EditDetailsModal.tsx
@@ -13,7 +13,7 @@ import { LICENSE_OPTIONS, forkLicenseOptions, licenseBySpdx } from "@/lib/patter
import { useDialog } from "@/lib/hooks/useDialog";
import { useBackdropClose } from "@/lib/hooks/useBackdropClose";
import { captureEvent } from "@/lib/posthogEvents";
-import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
+import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase";
import {
VISIBILITY_HINTS,
VISIBILITY_LABELS,
@@ -99,9 +99,8 @@ export default function EditDetailsModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed, description, license, madeOn, madeHow, visibility }),
});
- const payload = (await response.json()) as { error?: string };
if (!response.ok) {
- setError(payload.error ?? "Could not save.");
+ setError(await readApiError(response, "Could not save"));
return;
}
captureEvent("community_edit_details", { pattern_id: patternId, license });
diff --git a/web/src/components/community/ReportModal.tsx b/web/src/components/community/ReportModal.tsx
index 37c9e98e2..f4c6c6737 100644
--- a/web/src/components/community/ReportModal.tsx
+++ b/web/src/components/community/ReportModal.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { authClient } from "@/lib/community/auth-client";
-import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
+import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase";
import { useSubmitLatch } from "@/lib/community/submitLatch";
import {
REPORT_DETAIL_MAX,
@@ -55,9 +55,8 @@ export default function ReportModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetType, targetId, reason, detail }),
});
- const payload = (await response.json()) as { ok?: boolean; error?: string };
if (!response.ok) {
- setError(payload.error ?? "Could not send the report.");
+ setError(await readApiError(response, "Could not send the report"));
return;
}
captureEvent("community_report", { target_type: targetType, reason });
diff --git a/web/src/components/community/ShareDeckModal.tsx b/web/src/components/community/ShareDeckModal.tsx
index 7370dc4c2..60448a0d1 100644
--- a/web/src/components/community/ShareDeckModal.tsx
+++ b/web/src/components/community/ShareDeckModal.tsx
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/community/auth-client";
-import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
+import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase";
import { useSubmitLatch } from "@/lib/community/submitLatch";
import { PUBLIC_DECKS_MAX, type CollectedPattern } from "@/lib/community/deck";
import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate";
@@ -64,9 +64,13 @@ export default function ShareDeckModal({
patternIds: deck.map((item) => item.patternId),
}),
});
- const payload = (await response.json()) as { id?: string; error?: string };
- if (!response.ok || !payload.id) {
- setError(payload.error ?? "Sharing failed.");
+ if (!response.ok) {
+ setError(await readApiError(response, "Sharing failed"));
+ return;
+ }
+ const payload = (await response.json()) as { id?: string };
+ if (!payload.id) {
+ setError("Sharing failed.");
return;
}
captureEvent("community_deck_shared", {
diff --git a/web/src/components/community/formErrors.test.tsx b/web/src/components/community/formErrors.test.tsx
new file mode 100644
index 000000000..0275e9ab8
--- /dev/null
+++ b/web/src/components/community/formErrors.test.tsx
@@ -0,0 +1,133 @@
+// The community's save and send windows read every answer as JSON. When the
+// proxy answered instead, with its 502 page or a bare 413 for a body over its
+// limit, the read threw and the window said "Network error." although the
+// server had answered. Each window here meets both and has to name the status.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import type { ReactElement } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import AddHeaderModal from "./AddHeaderModal";
+import AddPerformanceModal from "./AddPerformanceModal";
+import EditDetailsModal from "./EditDetailsModal";
+import ReportModal from "./ReportModal";
+import ShareDeckModal from "./ShareDeckModal";
+
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
+vi.mock("@/lib/community/auth-client", () => ({
+ authClient: {
+ useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }),
+ },
+}));
+vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() }));
+
+const NGINX_PAGE = (status: number, text: string) =>
+ `${status} ${text}${status} ${text}
nginx`;
+
+const ANSWERS = [
+ { status: 502, body: NGINX_PAGE(502, "Bad Gateway") },
+ { status: 413, body: NGINX_PAGE(413, "Request Entity Too Large") },
+];
+
+type Form = {
+ name: string;
+ element: ReactElement;
+ /** Fill what the window needs and press its button. */
+ submit: () => void;
+ fallback: string;
+};
+
+const FORMS: Form[] = [
+ {
+ name: "Edit details",
+ element: (
+ undefined}
+ />
+ ),
+ submit: () => fireEvent.click(screen.getByRole("button", { name: "Save details" })),
+ fallback: "Could not save",
+ },
+ {
+ name: "Report",
+ element: (
+ undefined} />
+ ),
+ submit: () => fireEvent.click(screen.getByRole("button", { name: "Send report" })),
+ fallback: "Could not send the report",
+ },
+ {
+ name: "Add firmware header",
+ element: (
+ undefined} />
+ ),
+ submit: () => fireEvent.click(screen.getByRole("button", { name: "Update header" })),
+ fallback: "Could not save the header",
+ },
+ {
+ name: "Publish a performance",
+ element: undefined} />,
+ submit: () => {
+ fireEvent.change(screen.getByRole("textbox", { name: "Performance JSON" }), {
+ target: { value: '{"version":1,"length":30,"timeline":[]}' },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Publish performance" }));
+ },
+ fallback: "Could not save the performance",
+ },
+ {
+ name: "Share deck",
+ element: (
+ undefined}
+ />
+ ),
+ submit: () => {
+ fireEvent.change(screen.getByRole("textbox", { name: "Title" }), {
+ target: { value: "Night set" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Publish deck" }));
+ },
+ fallback: "Sharing failed",
+ },
+];
+
+let fetchMock: ReturnType;
+beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+});
+afterEach(() => vi.unstubAllGlobals());
+
+describe("community windows name the status of an answer that is not JSON", () => {
+ for (const form of FORMS) {
+ for (const answer of ANSWERS) {
+ it(`${form.name} · HTTP ${answer.status}`, async () => {
+ fetchMock.mockResolvedValue(
+ new Response(answer.body, {
+ status: answer.status,
+ headers: { "Content-Type": "text/html" },
+ }),
+ );
+ render(form.element);
+ form.submit();
+ expect(await screen.findByText(`${form.fallback} (HTTP ${answer.status}).`)).toBeTruthy();
+ expect(document.body.textContent).not.toMatch(/Network error/);
+ });
+ }
+
+ it(`${form.name} · keeps the route's own error`, async () => {
+ fetchMock.mockResolvedValue(Response.json({ error: "Sign in first." }, { status: 401 }));
+ render(form.element);
+ form.submit();
+ expect(await screen.findByText("Sign in first.")).toBeTruthy();
+ });
+ }
+});
diff --git a/web/src/lib/community/apiBase.test.ts b/web/src/lib/community/apiBase.test.ts
new file mode 100644
index 000000000..05263e961
--- /dev/null
+++ b/web/src/lib/community/apiBase.test.ts
@@ -0,0 +1,52 @@
+// A community form said "Network error." whenever the answer was not JSON:
+// the 502 page nginx writes when the app is down, a bare 413 for a body over
+// the proxy's limit. The server had answered both times. readApiError keeps
+// the route's own words when there are some and gives the status when not.
+
+import { describe, expect, it } from "vitest";
+import { readApiError } from "./apiBase";
+
+const NGINX_502 =
+ "502 Bad Gateway502 Bad Gateway
nginx";
+
+function json(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("readApiError", () => {
+ it("keeps the route's own error", async () => {
+ const response = json(429, { error: "Too many comments — wait a minute and try again." });
+ expect(await readApiError(response, "Comment failed")).toBe(
+ "Too many comments — wait a minute and try again.",
+ );
+ });
+
+ it("gives the status for an HTML error page", async () => {
+ const response = new Response(NGINX_502, {
+ status: 502,
+ headers: { "Content-Type": "text/html" },
+ });
+ expect(await readApiError(response, "Comment failed")).toBe("Comment failed (HTTP 502).");
+ });
+
+ it("gives the status for an empty body", async () => {
+ expect(await readApiError(new Response(null, { status: 413 }), "Could not save")).toBe(
+ "Could not save (HTTP 413).",
+ );
+ });
+
+ it("gives the status when the JSON carries no usable error", async () => {
+ for (const body of [{}, { ok: false }, { error: "" }, { error: " " }, { error: { code: 1 } }, null, [], "nope"]) {
+ expect(await readApiError(json(400, body), "Sharing failed")).toBe("Sharing failed (HTTP 400).");
+ }
+ });
+
+ it("does not throw on a body that was already read", async () => {
+ const response = json(500, { error: "late" });
+ await response.text();
+ expect(await readApiError(response, "Could not save")).toBe("Could not save (HTTP 500).");
+ });
+});
diff --git a/web/src/lib/community/apiBase.ts b/web/src/lib/community/apiBase.ts
index 6ea42cb6d..1778f8a89 100644
--- a/web/src/lib/community/apiBase.ts
+++ b/web/src/lib/community/apiBase.ts
@@ -96,3 +96,21 @@ export function communityApiUrl(path: string): string {
export const COMMUNITY_FETCH_INIT: Pick = {
credentials: "include",
};
+
+/**
+ * What to tell someone when a community call answered but not with ok.
+ *
+ * The route's own `{ error }` when it sent one. A proxy's 502 page or a bare
+ * 413 is not JSON, and reading it as JSON threw into the caller's catch, which
+ * said "Network error." about a server that had answered. Those come back as
+ * `fallback` with the status instead: "Comment failed (HTTP 502)." Pass
+ * `fallback` without its full stop.
+ */
+export async function readApiError(response: Response, fallback: string): Promise {
+ const payload: unknown = await response.json().catch(() => null);
+ if (typeof payload === "object" && payload !== null && "error" in payload) {
+ const { error } = payload;
+ if (typeof error === "string" && error.trim().length > 0) return error;
+ }
+ return `${fallback} (HTTP ${response.status}).`;
+}