From 0573b39f6d60e925056000b8ae5b21f919303d8a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Mon, 5 Oct 2026 07:49:15 -0400 Subject: [PATCH] Serialize the request body before fetching so it never marks the network offline A body JSON.stringify cannot serialize, such as a cyclic one, threw inside the fetch try/catch and was recorded as a network failure, showing the offline pill. It now fails before the fetch with its own error. Co-Authored-By: Claude Opus 5.5 --- web/src/api/client.ts | 15 +++++++++------ web/src/pwa/OfflinePill.test.tsx | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ca48ccb..3ad4074 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -74,14 +74,17 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus): } headers[CSRF_HEADER] = csrfToken } + // Serialize before fetching so a body that cannot be sent is its own error + // and never counts as a network failure. + let json: string | undefined + try { + json = body === undefined ? undefined : JSON.stringify(body) + } catch (error) { + throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error }) + } let response: Response try { - response = await fetch(path, { - method, - headers, - credentials: 'same-origin', - body: body === undefined ? undefined : JSON.stringify(body), - }) + response = await fetch(path, { method, headers, credentials: 'same-origin', body: json }) } catch (error) { // fetch rejects with a TypeError when the network fails; an abort is a // DOMException and says nothing about the network. diff --git a/web/src/pwa/OfflinePill.test.tsx b/web/src/pwa/OfflinePill.test.tsx index d98ea6f..868c650 100644 --- a/web/src/pwa/OfflinePill.test.tsx +++ b/web/src/pwa/OfflinePill.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, type ApiClient } from '../api/index.ts' -import { fakeLocation, jsonResponse, stubFetch } from '../test/fetchStub.ts' +import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts' import { createNetworkStatus } from './networkStatus.ts' import { OfflinePill } from './OfflinePill.tsx' @@ -80,6 +80,22 @@ describe('OfflinePill', () => { expect(screen.queryByRole('status')).toBeNull() }) + it('ignores a request body that cannot be serialized', async () => { + const api = renderPill() + const fetchMock = stubFetch(jsonResponse(200, ME)) + await act(() => api.loadMe()) + const body: Record = {} + body.self = body + + await act(() => + expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow( + 'PUT /api/v1/plan: request body is not serializable as JSON', + ), + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('status')).toBeNull() + }) + it('ignores an aborted request', async () => { const api = renderPill() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError')))