Fix: Prevent session-expiry errors from briefly showing in useConfigItem.save()

When save() encounters a 401 or 403 error, the HTTP client dispatches
SESSION_EXPIRED_EVENT which triggers auth handling and navigation to login.
However, setSaveError was called first, causing a brief flash of an
'Unauthorized' message before the redirect.

Now, isAuthError(err) checks if the error is a 401/403 before setting
saveError. Auth errors are rethrown without setting error state, allowing
the auth handler to deal with session expiry cleanly without UX confusion.

- Import isAuthError from api/client in useConfigItem hook
- Check for auth errors in the save() catch block before setSaveError
- Add tests for 401 and 403 error handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 09:45:11 +02:00
parent 6d5be523ab
commit 8904e180d1
3 changed files with 39 additions and 20 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConfigItem } from "../useConfigItem";
import { ApiError } from "../../api/client";
describe("useConfigItem", () => {
beforeEach(() => {
@@ -85,4 +86,40 @@ describe("useConfigItem", () => {
expect(result.current.saveError).toBe("save failed");
});
it("auth errors are rethrown without setting saveError", async () => {
const fetchFn = vi.fn().mockResolvedValue("ok");
const authError = new ApiError(401, "Unauthorized");
const saveFn = vi.fn().mockRejectedValue(authError);
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await expect(result.current.save("test")).rejects.toThrow(authError);
});
expect(result.current.saveError).toBeNull();
});
it("403 errors are rethrown without setting saveError", async () => {
const fetchFn = vi.fn().mockResolvedValue("ok");
const forbiddenError = new ApiError(403, "Forbidden");
const saveFn = vi.fn().mockRejectedValue(forbiddenError);
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await expect(result.current.save("test")).rejects.toThrow(forbiddenError);
});
expect(result.current.saveError).toBeNull();
});
});

View File

@@ -3,6 +3,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import { isAuthError } from "../api/client";
export interface UseConfigItemResult<T, U> {
data: T | null;
@@ -71,6 +72,7 @@ export function useConfigItem<T, U>(
setData((prevData) => mergeOnSave(prevData, update));
}
} catch (err: unknown) {
if (isAuthError(err)) throw err; // let auth handler deal with it
const message = err instanceof Error ? err.message : "Failed to save data";
setSaveError(message);
throw err;