app: handle form fetch failures without unhandled rejection
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled

Catch useForm fetcher errors and surface them so DomainEditPage can show a not-found message instead of hanging on the loader (and so the webpack overlay no longer flakes Cypress).
This commit is contained in:
Roland 2026-07-16 19:40:28 -06:00
parent f5615440e5
commit 0850b6a7fe
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
5 changed files with 104 additions and 10 deletions

View file

@ -630,6 +630,15 @@ export function DomainEditPage() {
};
if (!form.ready) {
if (form.error) {
return <>
<BackButton/>
<Message error>
<Message.Header>Domain not found</Message.Header>
<p>{form.error}</p>
</Message>
</>;
}
return <Loader active>Loading domain...</Loader>;
}

View file

@ -154,10 +154,13 @@ describe('DomainEditPage', () => {
});
describe('Error States', () => {
it('shows loader when form is not ready (fetch fails)', () => {
// When form.ready is false (e.g., fetch failed), show loader
it('shows error message when domain fetch fails', () => {
const form = createTestForm({}, {
overrides: {ready: false, loading: false, error: new Error('Domain not found')}
overrides: {
ready: false,
loading: false,
error: 'Domain collection with ID 999 not found',
}
});
mockUseDomain.mockReturnValue({
@ -168,10 +171,26 @@ describe('DomainEditPage', () => {
render(<DomainEditPage/>);
// Should show loading screen when not ready
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
expect(screen.getByText(/domain not found/i)).toBeInTheDocument();
expect(screen.getByText(/Domain collection with ID 999 not found/i)).toBeInTheDocument();
expect(screen.queryByText(/loading domain/i)).not.toBeInTheDocument();
expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument();
});
// Form should not be rendered
it('shows loader when form is not ready and there is no error', () => {
const form = createTestForm({}, {
overrides: {ready: false, loading: false, error: null}
});
mockUseDomain.mockReturnValue({
domain: null,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage/>);
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument();
});
@ -182,7 +201,7 @@ describe('DomainEditPage', () => {
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false, error: new Error('Update failed')}
overrides: {ready: true, loading: false, error: 'Update failed'}
});
mockUseDomain.mockReturnValue({

View file

@ -24,6 +24,8 @@ export function useForm({
const [loading, setLoading] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
// Form-level error (e.g. initial fetch failed). Distinct from field-level `errors`.
const [error, setError] = React.useState(null);
const [errors, setErrors] = React.useState({});
const [validators, setValidators] = React.useState({});
@ -47,6 +49,12 @@ export function useForm({
// Form is dirty if it has deviated from the default.
setDirty(!_.isEqual(defaultFormData, memoizedFormData));
// Fetch/load failures keep the form not ready so pages can show error UI.
if (error) {
console.debug('Form invalid because of form-level error');
setReady(false);
return
}
const errorValues = Object.values(errors).filter(i => !!i);
if (errorValues.length > 0) {
console.debug('Form invalid because it has errors');
@ -66,12 +74,21 @@ export function useForm({
return
}
setReady(true);
}, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires]);
}, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires, error]);
const localFetch = async () => {
if (fetcher) {
const result = await fetcher();
setFormData(result);
try {
const result = await fetcher();
setFormData(result);
setError(null);
return result;
} catch (e) {
// Catch so callers (useEffect, page refresh) do not get an unhandled rejection
// (which would show the webpack-dev-server error overlay and break Cypress).
console.error('Failed to fetch form data:', e);
setError(e?.message || String(e) || 'Failed to load');
}
}
}
@ -278,6 +295,7 @@ export function useForm({
return {
dirty,
disabled,
error,
fetcher: localFetch,
formData,
getCustomProps,

View file

@ -60,6 +60,7 @@ describe('useForm', () => {
expect(result.current.dirty).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.disabled).toBe(false);
expect(result.current.error).toBe(null);
// Ready starts true when there are no validators or required fields
expect(result.current.ready).toBe(true);
});
@ -91,6 +92,52 @@ describe('useForm', () => {
await waitFor(() => {
expect(result.current.formData).toEqual(fetchedData);
});
expect(result.current.error).toBe(null);
});
it('sets form error and stays not ready when fetcher rejects', async () => {
const fetcher = jest.fn().mockRejectedValue(new Error('Domain not found'));
const {result} = renderHook(() => useForm({
fetcher,
defaultFormData: {},
submitter: jest.fn(),
}));
await waitFor(() => {
expect(result.current.error).toBe('Domain not found');
});
expect(result.current.ready).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch form data:',
expect.any(Error),
);
});
it('clears form error when a subsequent fetch succeeds', async () => {
const fetcher = jest.fn()
.mockRejectedValueOnce(new Error('Domain not found'))
.mockResolvedValueOnce({name: 'recovered'});
const {result} = renderHook(() => useForm({
fetcher,
defaultFormData: {},
submitter: jest.fn(),
}));
await waitFor(() => {
expect(result.current.error).toBe('Domain not found');
});
await act(async () => {
await result.current.fetcher();
});
await waitFor(() => {
expect(result.current.error).toBe(null);
expect(result.current.formData).toEqual({name: 'recovered'});
});
expect(result.current.ready).toBe(true);
});
it('initializes with empty object when no defaultFormData provided', () => {

View file

@ -245,6 +245,7 @@ export function createTestForm(initialData = {}, config = {}) {
loading: false,
disabled: false,
dirty: false,
error: null,
errors: {},
// Methods that update formData directly
patchFormData: jest.fn((updates) => {