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 <noreply@anthropic.com>
This commit is contained in:
@@ -74,14 +74,17 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus):
|
|||||||
}
|
}
|
||||||
headers[CSRF_HEADER] = csrfToken
|
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
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(path, {
|
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// fetch rejects with a TypeError when the network fails; an abort is a
|
// fetch rejects with a TypeError when the network fails; an abort is a
|
||||||
// DOMException and says nothing about the network.
|
// DOMException and says nothing about the network.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { act, render, screen } from '@testing-library/react'
|
import { act, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, type ApiClient } from '../api/index.ts'
|
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 { createNetworkStatus } from './networkStatus.ts'
|
||||||
import { OfflinePill } from './OfflinePill.tsx'
|
import { OfflinePill } from './OfflinePill.tsx'
|
||||||
|
|
||||||
@@ -80,6 +80,22 @@ describe('OfflinePill', () => {
|
|||||||
expect(screen.queryByRole('status')).toBeNull()
|
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<string, unknown> = {}
|
||||||
|
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 () => {
|
it('ignores an aborted request', async () => {
|
||||||
const api = renderPill()
|
const api = renderPill()
|
||||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))
|
||||||
|
|||||||
Reference in New Issue
Block a user