('link', { name: 'Download' });
+ expect(link.getAttribute('href')).toBe('/api/file-storage/files/f1/download');
+ expect(shouldInterceptNavigation(link, new URL('http://localhost:3000/files'))).toBe(false);
+ });
+});
diff --git a/modules/settings/settings/pages/ModulesEdit.tsx b/modules/settings/settings/pages/ModulesEdit.tsx
index 58672436..0db34f4b 100644
--- a/modules/settings/settings/pages/ModulesEdit.tsx
+++ b/modules/settings/settings/pages/ModulesEdit.tsx
@@ -142,7 +142,11 @@ function ModulesEdit({ modules, testable = {} }: Props) {
) : current ? (
-
+
) : (
{t(keys.settings.modules.empty_title)}
)}
diff --git a/modules/settings/settings/pages/components/ModuleForm.tsx b/modules/settings/settings/pages/components/ModuleForm.tsx
index 09bad003..0b4e8794 100644
--- a/modules/settings/settings/pages/components/ModuleForm.tsx
+++ b/modules/settings/settings/pages/components/ModuleForm.tsx
@@ -42,18 +42,23 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
return o;
}, [m.fields]);
- const [values, setValues] = useState>(initial);
- const [baseline, setBaseline] = useState>(initial);
+ const [{ values, baseline }, setBuffer] = useState({ values: initial, baseline: initial });
const [errors, setErrors] = useState>({});
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [formErrors, setFormErrors] = useState([]);
- // Reset the edit buffer whenever the underlying module changes (package
- // switch, or server-reloaded props after a save/reset).
+ // Refresh saved values without discarding drafts in other fields (#637).
useEffect(() => {
- setValues(initial);
- setBaseline(initial);
+ setBuffer((prev) => ({
+ values: Object.fromEntries(
+ Object.entries(initial).map(([name, value]) => [
+ name,
+ notEqual(prev.values[name], prev.baseline[name]) ? prev.values[name] : value,
+ ]),
+ ),
+ baseline: initial,
+ }));
setErrors({});
setSaved(false);
}, [initial]);
@@ -107,7 +112,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
} else {
// Keeping the form mounted lets the confirmation remain visible instead
// of being discarded by an immediate Inertia reload.
- setBaseline({ ...values });
+ setBuffer((prev) => ({ ...prev, baseline: { ...values } }));
setSaved(true);
}
setBusy(false);
@@ -121,6 +126,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
params: { path: { package: m.package, field: name } },
}),
);
+ setBuffer((prev) => ({
+ ...prev,
+ values: { ...prev.values, [name]: prev.baseline[name] },
+ }));
router.reload({ only: ['modules'] });
}
@@ -187,7 +196,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
onReset={() => onReset(f.name)}
onChange={(name, v) => {
setSaved(false);
- setValues((prev) => ({ ...prev, [name]: v }));
+ setBuffer((prev) => ({
+ ...prev,
+ values: { ...prev.values, [name]: v },
+ }));
}}
/>
))}
diff --git a/modules/settings/settings/pages/components/TestConnectionButton.tsx b/modules/settings/settings/pages/components/TestConnectionButton.tsx
index f5124d27..5b43c0a8 100644
--- a/modules/settings/settings/pages/components/TestConnectionButton.tsx
+++ b/modules/settings/settings/pages/components/TestConnectionButton.tsx
@@ -3,7 +3,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time';
import { apiFetch } from '@simple-module-py/ui/lib/api';
import { CheckCircle2, PlugZap, XCircle } from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { ROUTES } from '../routes';
import { type LastTest, readLastTest, writeLastTest } from './last-test';
@@ -35,9 +35,19 @@ export function TestConnectionButton({ pkg, checks }: Props) {
const [error, setError] = useState(null);
const [last, setLast] = useState(null);
+ const requestId = useRef(0);
+
// Read on mount rather than during render: the result is per-tab session
// state, and touching sessionStorage while rendering breaks SSR hydration.
- useEffect(() => setLast(readLastTest(pkg)), [pkg]);
+ useEffect(() => {
+ setLast(readLastTest(pkg));
+ setResults(null);
+ setError(null);
+ setBusy(false);
+ return () => {
+ requestId.current++;
+ };
+ }, [pkg]);
// "users.mailer" → "mailer". Naming the check is the difference between
// "Test connection" and knowing an SMTP dial is about to happen; with two
@@ -50,6 +60,7 @@ export function TestConnectionButton({ pkg, checks }: Props) {
: t(keys.settings.modules.test_connection_generic);
async function run() {
+ const id = ++requestId.current;
setBusy(true);
setError(null);
setResults(null);
@@ -57,8 +68,9 @@ export function TestConnectionButton({ pkg, checks }: Props) {
const result = await apiFetch<{ checks?: CheckResult[] }>(ROUTES.testConnection(pkg), {
method: 'POST',
});
- if (result.ok) {
- const checkResults = result.data?.checks ?? [];
+ if (id !== requestId.current) return;
+ const checkResults = result.data?.checks;
+ if (result.ok && Array.isArray(checkResults) && checkResults.length > 0) {
setResults(checkResults);
setLast(
writeLastTest(
diff --git a/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx b/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx
new file mode 100644
index 00000000..4c2a3c46
--- /dev/null
+++ b/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx
@@ -0,0 +1,88 @@
+import '@testing-library/jest-dom/vitest';
+import { configureI18n } from '@simple-module-py/i18n';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, expect, test, vi } from 'vitest';
+import type { FieldMeta } from '../settings/pages/components/FieldInput';
+import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm';
+import ModulesEdit from '../settings/pages/ModulesEdit';
+
+const mocks = vi.hoisted(() => ({ reload: vi.fn() }));
+vi.mock('@inertiajs/react', () => ({ router: { reload: mocks.reload }, Head: () => null }));
+configureI18n({
+ locale: 'en',
+ messages: {
+ 'settings.modules_form.save': 'Save',
+ 'settings.modules_form.reset_to_default': 'Revert',
+ 'settings.modules.unsaved_count_one': '{count} unsaved',
+ },
+});
+
+function field(name: string, value: string, db_override = false): FieldMeta {
+ return {
+ name,
+ value,
+ db_override,
+ type: 'string',
+ default: '',
+ description: '',
+ is_secret: false,
+ requires_restart: false,
+ group: null,
+ env_var: 'SM_TEST_FIELD',
+ env_set: false,
+ env_readable: false,
+ choices: null,
+ };
+}
+function view(fields: FieldMeta[]): ModuleView {
+ return {
+ module_name: 'Users',
+ package: 'users',
+ env_prefix: 'SM_USERS_',
+ class_name: 'UsersSettings',
+ fields,
+ };
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+});
+
+test('reverting one field preserves other drafts and their saved baseline after props reload', async () => {
+ const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
+ vi.stubGlobal('fetch', fetchMock);
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText('sender'), { target: { value: 'draft' } });
+ fireEvent.change(screen.getByLabelText('smtp_host'), { target: { value: 'discard me' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Revert' }));
+ await waitFor(() => expect(mocks.reload).toHaveBeenCalled());
+ rerender(
+ ,
+ );
+ expect(screen.getByLabelText('smtp_host')).toHaveValue('default');
+ expect(screen.getByLabelText('sender')).toHaveValue('draft');
+ expect(screen.getByText('1 unsaved')).toBeVisible();
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+ const [request] = fetchMock.mock.calls[1] as unknown as [Request];
+ expect(await request.json()).toEqual({ sender: 'draft' });
+});
+
+test('switching modules starts a fresh edit buffer even when field names match', () => {
+ const users = view([field('sender', 'users original')]);
+ const mail = {
+ ...view([field('sender', 'mail original')]),
+ module_name: 'Mail',
+ package: 'mail',
+ };
+ render();
+ fireEvent.change(screen.getByLabelText('sender'), { target: { value: 'users draft' } });
+ fireEvent.click(screen.getByRole('button', { name: /mail/ }));
+ expect(screen.getByLabelText('sender')).toHaveValue('mail original');
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+});
diff --git a/modules/settings/tests-js/settings_TestConnectionButton.test.tsx b/modules/settings/tests-js/settings_TestConnectionButton.test.tsx
index 6adb03f0..848f317c 100644
--- a/modules/settings/tests-js/settings_TestConnectionButton.test.tsx
+++ b/modules/settings/tests-js/settings_TestConnectionButton.test.tsx
@@ -1,6 +1,6 @@
import '@testing-library/jest-dom/vitest';
import { configureI18n, keys } from '@simple-module-py/i18n';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
configureI18n({ locale: 'en', messages: {} });
@@ -24,6 +24,70 @@ describe('TestConnectionButton', () => {
window.sessionStorage.clear();
});
+ it('clears the displayed result when the package changes', async () => {
+ stub(200, { checks: [{ name: 'a.b', status: 'healthy', detail: 'users connected' }] });
+ const { rerender } = render();
+ click();
+ expect(await screen.findByText('users connected')).toBeVisible();
+ rerender();
+ expect(screen.queryByText('users connected')).not.toBeInTheDocument();
+ });
+
+ it('ignores a pending result after switching packages, including switching back', async () => {
+ let finish!: (response: Response) => void;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ ),
+ );
+ const { rerender } = render();
+ click();
+ rerender();
+ expect(screen.getByRole('button')).toBeEnabled();
+ rerender();
+ await act(async () =>
+ finish(
+ new Response(
+ JSON.stringify({
+ checks: [{ name: 'a.b', status: 'healthy', detail: 'stale result' }],
+ }),
+ { status: 200 },
+ ),
+ ),
+ );
+ expect(screen.queryByText('stale result')).not.toBeInTheDocument();
+ expect(window.sessionStorage.getItem('sm.settings.last-test.users')).toBeNull();
+ });
+
+ it.each([null, {}, { checks: [] }, { checks: 'invalid' }])(
+ 'does not record success for an invalid check response: %j',
+ async (body) => {
+ stub(200, body);
+ render();
+ click();
+ expect(await screen.findByText(keys.settings.modules.test_failed)).toBeVisible();
+ expect(
+ JSON.parse(window.sessionStorage.getItem('sm.settings.last-test.users') ?? '{}').ok,
+ ).toBe(false);
+ },
+ );
+
+ it('does not record success when the response body cannot be read', async () => {
+ const response = new Response('{}', { status: 200 });
+ vi.spyOn(response, 'text').mockRejectedValue(new TypeError('stream interrupted'));
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+ render();
+ click();
+ expect(await screen.findByText(keys.settings.modules.test_failed)).toBeVisible();
+ expect(
+ JSON.parse(window.sessionStorage.getItem('sm.settings.last-test.users') ?? '{}').ok,
+ ).toBe(false);
+ });
+
it('POSTs to the view route and shows each check detail', async () => {
const fetchMock = stub(200, {
checks: [{ name: 'users.mailer', status: 'unhealthy', detail: 'connection refused' }],
diff --git a/modules/users/tests-js/users_VerifyEmail.test.tsx b/modules/users/tests-js/users_VerifyEmail.test.tsx
new file mode 100644
index 00000000..f96382da
--- /dev/null
+++ b/modules/users/tests-js/users_VerifyEmail.test.tsx
@@ -0,0 +1,65 @@
+import { configureI18n, keys } from '@simple-module-py/i18n';
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { json, stubFetch } from './users_fixtures';
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ usePage: () => ({ props: { branding: { appName: 'Acme', logoUrl: null } } }),
+}));
+
+import VerifyEmail from '../users/pages/VerifyEmail';
+
+configureI18n({
+ locale: 'en',
+ messages: {
+ [keys.users.common.error_try_again]: 'An error occurred. Please try again.',
+ [keys.users.verify_email.expired_title]: 'Link expired',
+ [keys.users.verify_email.already_title]: 'Already verified',
+ [keys.users.verify_email.success_title]: 'Email verified',
+ [keys.users.verify_email.incomplete_title]: 'Link incomplete',
+ },
+});
+
+afterEach(() => vi.unstubAllGlobals());
+
+function verify(token = 'verification-token') {
+ render();
+}
+
+describe('VerifyEmail failure classification (#638)', () => {
+ it.each([500, 429, 422, 403, 400])(
+ 'does not label HTTP %s as an expired link',
+ async (status) => {
+ stubFetch(() => json(status, { detail: 'Unexpected failure' }));
+ verify();
+ expect(await screen.findByText('An error occurred. Please try again.')).toBeInTheDocument();
+ expect(screen.queryByText('Link expired')).toBeNull();
+ },
+ );
+
+ it('does not label a network failure as an expired link', async () => {
+ stubFetch(() => Promise.reject(new TypeError('Network unavailable')));
+ verify();
+ expect(await screen.findByText('An error occurred. Please try again.')).toBeInTheDocument();
+ expect(screen.queryByText('Link expired')).toBeNull();
+ });
+
+ it.each([
+ [400, 'VERIFY_USER_BAD_TOKEN', 'Link expired'],
+ [400, 'VERIFY_USER_ALREADY_VERIFIED', 'Already verified'],
+ [200, null, 'Email verified'],
+ [204, null, 'Email verified'],
+ ])('preserves the expected state for %s / %s', async (status, detail, title) => {
+ stubFetch(() => (status === 204 ? new Response(null, { status }) : json(status, { detail })));
+ verify();
+ expect(await screen.findByRole('heading', { name: title })).toBeInTheDocument();
+ });
+
+ it('shows an incomplete link without sending a request when the token is missing', () => {
+ const fetch = stubFetch(() => json(500, null));
+ verify('');
+ expect(screen.getByRole('heading', { name: 'Link incomplete' })).toBeInTheDocument();
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/modules/users/users/pages/VerifyEmail.tsx b/modules/users/users/pages/VerifyEmail.tsx
index 63128601..41ec186b 100644
--- a/modules/users/users/pages/VerifyEmail.tsx
+++ b/modules/users/users/pages/VerifyEmail.tsx
@@ -4,7 +4,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
import { apiResult } from '@simple-module-py/ui/lib/api';
import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes';
-import { CheckCircle2, Loader2, TimerOff, Unlink } from 'lucide-react';
+import { CheckCircle2, CircleAlert, Loader2, TimerOff, Unlink } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AuthStateCard, type AuthStateTone } from '../auth_local/components/AuthStateCard';
import { ResendVerification } from '../auth_local/components/ResendVerification';
@@ -17,7 +17,7 @@ interface Props {
verification_lifetime_days: number;
}
-type VerifyStatus = 'pending' | 'success' | 'already_verified' | 'expired' | 'incomplete';
+type VerifyStatus = 'pending' | 'success' | 'already_verified' | 'expired' | 'incomplete' | 'error';
function VerifyEmail({ token, email, verification_lifetime_days: lifetimeDays }: Props) {
const { t } = useT();
@@ -38,7 +38,13 @@ function VerifyEmail({ token, email, verification_lifetime_days: lifetimeDays }:
setStatus('success');
return;
}
- setStatus(res.detail === 'VERIFY_USER_ALREADY_VERIFIED' ? 'already_verified' : 'expired');
+ if (res.status === 400 && res.detail === 'VERIFY_USER_ALREADY_VERIFIED') {
+ setStatus('already_verified');
+ } else if (res.status === 400 && res.detail === 'VERIFY_USER_BAD_TOKEN') {
+ setStatus('expired');
+ } else {
+ setStatus('error');
+ }
});
}, [token]);
@@ -87,6 +93,13 @@ function VerifyEmail({ token, email, verification_lifetime_days: lifetimeDays }:
description: t(keys.users.verify_email.expired_description, { days: lifetimeDays }),
body: ,
},
+ error: {
+ icon: CircleAlert,
+ tone: 'destructive',
+ title: t(keys.users.common.error_occurred),
+ description: t(keys.users.common.error_try_again),
+ body: null,
+ },
incomplete: {
icon: Unlink,
tone: 'amber',
diff --git a/packages/ui/src/layouts/SidebarLayout.drawer.test.tsx b/packages/ui/src/layouts/SidebarLayout.drawer.test.tsx
index 11473d2b..cb481df0 100644
--- a/packages/ui/src/layouts/SidebarLayout.drawer.test.tsx
+++ b/packages/ui/src/layouts/SidebarLayout.drawer.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@inertiajs/react', () => ({
@@ -120,3 +120,62 @@ describe('SidebarLayout mobile drawer', () => {
expect(document.activeElement).toBe(last);
});
});
+
+describe('SidebarLayout closed drawer accessibility (#638)', () => {
+ it('makes a closed phone drawer inert and hides it from assistive technology', () => {
+ render(
+
+ page
+ ,
+ );
+ const drawer = document.querySelector('aside');
+ expect(drawer).toHaveAttribute('inert');
+ expect(drawer).toHaveAttribute('aria-hidden', 'true');
+ expect(screen.queryByRole('link', { name: 'One' })).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'open' }));
+ expect(drawer).not.toHaveAttribute('inert');
+ expect(drawer).not.toHaveAttribute('aria-hidden', 'true');
+ expect(screen.getByRole('link', { name: 'One' })).toBeInTheDocument();
+
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
+ expect(drawer).toHaveAttribute('inert');
+ expect(drawer).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('keeps the desktop sidebar accessible and updates a closed drawer on resize', () => {
+ const listeners = new Set<(event: MediaQueryListEvent) => void>();
+ const media = {
+ matches: true,
+ addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) =>
+ listeners.add(listener),
+ removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) =>
+ listeners.delete(listener),
+ };
+ vi.stubGlobal('matchMedia', () => media);
+ try {
+ render(
+
+ page
+ ,
+ );
+ const drawer = document.querySelector('aside');
+ expect(drawer).not.toHaveAttribute('inert');
+ expect(screen.getByRole('link', { name: 'One' })).toBeInTheDocument();
+ act(() => {
+ media.matches = false;
+ for (const listener of listeners) listener({ matches: false } as MediaQueryListEvent);
+ });
+ expect(drawer).toHaveAttribute('inert');
+ expect(drawer).toHaveAttribute('aria-hidden', 'true');
+ act(() => {
+ media.matches = true;
+ for (const listener of listeners) listener({ matches: true } as MediaQueryListEvent);
+ });
+ expect(drawer).not.toHaveAttribute('inert');
+ expect(screen.getByRole('link', { name: 'One' })).toBeInTheDocument();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/packages/ui/src/layouts/useDrawerA11y.test.tsx b/packages/ui/src/layouts/useDrawerA11y.test.tsx
index 4bbffa4a..61cf1266 100644
--- a/packages/ui/src/layouts/useDrawerA11y.test.tsx
+++ b/packages/ui/src/layouts/useDrawerA11y.test.tsx
@@ -69,7 +69,8 @@ describe('useDrawerA11y', () => {
act(() => media.toDesktop());
expect(drawer.dataset.open).toBe('false');
- expect(media.listeners.size).toBe(0);
+ // Keep observing so a closed drawer becomes inert on a return to mobile.
+ expect(media.listeners.size).toBe(1);
const last = screen.getByRole('button', { name: 'last' });
last.focus();
fireEvent.keyDown(last, { key: 'Tab' });
diff --git a/packages/ui/src/layouts/useDrawerA11y.ts b/packages/ui/src/layouts/useDrawerA11y.ts
index 1fdd068b..df3629c1 100644
--- a/packages/ui/src/layouts/useDrawerA11y.ts
+++ b/packages/ui/src/layouts/useDrawerA11y.ts
@@ -30,18 +30,27 @@ export function useDrawerA11y(
drawerRef: RefObject,
) {
useEffect(() => {
- if (!open) return;
const desktop =
typeof window.matchMedia === 'function' ? window.matchMedia(DESKTOP_SIDEBAR_QUERY) : null;
- if (desktop?.matches) {
- onClose();
- return;
- }
+ const drawer = drawerRef.current;
+ const updateVisibility = () => {
+ const hidden = !open && !desktop?.matches;
+ drawer?.toggleAttribute('inert', hidden);
+ if (hidden) drawer?.setAttribute('aria-hidden', 'true');
+ else drawer?.removeAttribute('aria-hidden');
+ };
+ updateVisibility();
const onBreakpoint = (e: MediaQueryListEvent) => {
- if (e.matches) onClose();
+ updateVisibility();
+ if (e.matches && open) onClose();
};
desktop?.addEventListener('change', onBreakpoint);
- const drawer = drawerRef.current;
+ const unsubscribe = () => desktop?.removeEventListener('change', onBreakpoint);
+ if (!open) return unsubscribe;
+ if (desktop?.matches) {
+ onClose();
+ return unsubscribe;
+ }
const opener = document.activeElement as HTMLElement | null;
const items = () =>
drawer ? Array.from(drawer.querySelectorAll(FOCUSABLE)).filter(isFocusable) : [];
@@ -77,7 +86,7 @@ export function useDrawerA11y(
};
document.addEventListener('keydown', onKeyDown);
return () => {
- desktop?.removeEventListener('change', onBreakpoint);
+ unsubscribe();
document.removeEventListener('keydown', onKeyDown);
if (opener?.isConnected) opener.focus();
};
diff --git a/packages/ui/src/lib/api-result.ts b/packages/ui/src/lib/api-result.ts
index fa0c9db2..5276c2e4 100644
--- a/packages/ui/src/lib/api-result.ts
+++ b/packages/ui/src/lib/api-result.ts
@@ -66,7 +66,7 @@ export async function apiResult(
/** Parse a raw response body: JSON when it is JSON, the text otherwise, null when empty. */
export async function readBody(response: Response): Promise {
- const text = await response.text().catch(() => '');
+ const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
diff --git a/packages/ui/src/lib/api.test.ts b/packages/ui/src/lib/api.test.ts
index f941c317..f62a9a34 100644
--- a/packages/ui/src/lib/api.test.ts
+++ b/packages/ui/src/lib/api.test.ts
@@ -134,6 +134,13 @@ describe('apiResult', () => {
});
describe('apiFetch', () => {
+ it('reports a failed response body read as a network failure', async () => {
+ const response = new Response('{}', { status: 200 });
+ vi.spyOn(response, 'text').mockRejectedValue(new TypeError('stream interrupted'));
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+ expect(await apiFetch('/x')).toMatchObject({ ok: false, status: 0, data: null });
+ });
+
it('serialises json and normalises the result', async () => {
const fetchMock = stubFetch(200, { saved: true });
const result = await apiFetch<{ saved: boolean }>('/setup/save', {
diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts
index 4d27bd7f..1714b393 100644
--- a/packages/ui/src/lib/api.ts
+++ b/packages/ui/src/lib/api.ts
@@ -71,7 +71,6 @@ export async function apiFetch(
const { json, ...rest } = init;
const headers = new Headers(rest.headers);
if (json !== undefined) headers.set('Content-Type', 'application/json');
- let response: Response;
try {
const request = new Request(new URL(url, globalThis.location?.href), {
credentials: 'same-origin',
@@ -79,10 +78,10 @@ export async function apiFetch(
headers,
body: json !== undefined ? JSON.stringify(json) : rest.body,
});
- response = await globalThis.fetch(prepare(request, currentCsrfToken));
+ const response = await globalThis.fetch(prepare(request, currentCsrfToken));
+ const body = await readBody(response);
+ return response.ok ? success(response.status, body as T) : failure(response.status, body);
} catch {
return failure(NETWORK_FAILURE, null);
}
- const body = await readBody(response);
- return response.ok ? success(response.status, body as T) : failure(response.status, body);
}