diff --git a/packages/react/src/runtime/use-island-form.ts b/packages/react/src/runtime/use-island-form.ts index 7a323485e..a1505ed07 100644 --- a/packages/react/src/runtime/use-island-form.ts +++ b/packages/react/src/runtime/use-island-form.ts @@ -1,23 +1,5 @@ import { useState, useRef, useCallback, useMemo } from 'react'; -import { - VoidActionError, - categorizeActionError, - getValidationErrors, - isEqualFormValue, -} from 'void/pages-client'; - -async function readActionErrorBody(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - try { - if (contentType.includes('application/json')) { - return await response.json(); - } - const text = await response.text(); - return text || null; - } catch { - return null; - } -} +import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; export type IslandFormReturn> = Readonly<{ data: T; @@ -106,18 +88,14 @@ export function useIslandForm>(defaults: T): I setError(null); try { - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); + const result = await submitIslandAction(url, method, data); - if (response.redirected) { - window.location.href = response.url; + if (result.kind === 'redirect') { + window.location.href = result.url; return; } - if (response.ok) { + if (result.kind === 'success') { setWasSuccessful(true); setRecentlySuccessful(true); const nextDefaults = { ...data }; @@ -125,23 +103,10 @@ export function useIslandForm>(defaults: T): I setDefaultsState(nextDefaults); successTimeoutRef.current = setTimeout(() => setRecentlySuccessful(false), 2000); window.location.reload(); + } else if (result.kind === 'validation') { + setErrors(result.errors); } else { - const body = await readActionErrorBody(response); - const actionError = new VoidActionError({ - body, - status: response.status, - statusText: response.statusText, - url, - }); - const validationErrors = response.status === 422 ? getValidationErrors(body) : null; - if (validationErrors) { - setErrors(validationErrors); - return; - } - if (categorizeActionError(response.status) === 'boundary') { - throw actionError; - } - setError(actionError); + setError(result.error); } } finally { setPending(false); diff --git a/packages/solid/src/runtime/use-island-form.ts b/packages/solid/src/runtime/use-island-form.ts index 495b7cef9..7aa7cd59a 100644 --- a/packages/solid/src/runtime/use-island-form.ts +++ b/packages/solid/src/runtime/use-island-form.ts @@ -1,24 +1,6 @@ import { createSignal, createMemo } from 'solid-js'; import { createStore, reconcile } from 'solid-js/store'; -import { - VoidActionError, - categorizeActionError, - getValidationErrors, - isEqualFormValue, -} from 'void/pages-client'; - -async function readActionErrorBody(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - try { - if (contentType.includes('application/json')) { - return await response.json(); - } - const text = await response.text(); - return text || null; - } catch { - return null; - } -} +import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; export type IslandFormReturn> = Readonly<{ data: T; @@ -100,40 +82,23 @@ export function useIslandForm>(defaults: T): I clearError(); try { - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); + const result = await submitIslandAction(url, method, data); - if (response.redirected) { - window.location.href = response.url; + if (result.kind === 'redirect') { + window.location.href = result.url; return; } - if (response.ok) { + if (result.kind === 'success') { setWasSuccessful(true); setRecentlySuccessful(true); defaults_ = { ...data } as typeof defaults_; successTimeout = setTimeout(() => setRecentlySuccessful(false), 2000); window.location.reload(); + } else if (result.kind === 'validation') { + setErrors(result.errors); } else { - const body = await readActionErrorBody(response); - const actionError = new VoidActionError({ - body, - status: response.status, - statusText: response.statusText, - url, - }); - const validationErrors = response.status === 422 ? getValidationErrors(body) : null; - if (validationErrors) { - setErrors(validationErrors); - return; - } - if (categorizeActionError(response.status) === 'boundary') { - throw actionError; - } - setError(actionError); + setError(result.error); } } finally { setPending(false); diff --git a/packages/svelte/src/runtime/use-island-form.svelte.ts b/packages/svelte/src/runtime/use-island-form.svelte.ts index 9ce1ffe1f..446f2d786 100644 --- a/packages/svelte/src/runtime/use-island-form.svelte.ts +++ b/packages/svelte/src/runtime/use-island-form.svelte.ts @@ -1,9 +1,4 @@ -import { - VoidActionError, - categorizeActionError, - getValidationErrors, - isEqualFormValue, -} from 'void/pages-client'; +import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; type UntypedIslandForm = { readonly data: Record; @@ -23,19 +18,6 @@ type UntypedIslandForm = { clearError(): void; }; -async function readActionErrorBody(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - try { - if (contentType.includes('application/json')) { - return await response.json(); - } - const text = await response.text(); - return text || null; - } catch { - return null; - } -} - /** * Form composable for island pages. Uses fetch + page reload instead of the * Void Router (which is not available in island mode). @@ -90,18 +72,14 @@ export function useIslandForm(defaults: Record): UntypedIslandF clearError(); try { - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); + const result = await submitIslandAction(url, method, data); - if (response.redirected) { - window.location.href = response.url; + if (result.kind === 'redirect') { + window.location.href = result.url; return; } - if (response.ok) { + if (result.kind === 'success') { wasSuccessful = true; recentlySuccessful = true; defaults_ = { ...data }; @@ -109,23 +87,10 @@ export function useIslandForm(defaults: Record): UntypedIslandF recentlySuccessful = false; }, 2000); window.location.reload(); + } else if (result.kind === 'validation') { + Object.assign(errors, result.errors); } else { - const body = await readActionErrorBody(response); - const actionError = new VoidActionError({ - body, - status: response.status, - statusText: response.statusText, - url, - }); - const validationErrors = response.status === 422 ? getValidationErrors(body) : null; - if (validationErrors) { - Object.assign(errors, validationErrors); - return; - } - if (categorizeActionError(response.status) === 'boundary') { - throw actionError; - } - error = actionError; + error = result.error; } } finally { pending = false; diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index fe166862d..b2e2936c7 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -28,6 +28,7 @@ export { isCallSiteActionError, } from './action-result.ts'; export type { ActionErrorHandlingScope, ActionResult } from './action-result.ts'; +export { submitIslandAction, type IslandActionResult } from './island-action.ts'; export { isEqualFormValue } from './form-value-equality.ts'; export { cloneFormValue, diff --git a/packages/void/src/pages/island-action.ts b/packages/void/src/pages/island-action.ts new file mode 100644 index 000000000..e8ae013d6 --- /dev/null +++ b/packages/void/src/pages/island-action.ts @@ -0,0 +1,41 @@ +import { + categorizeActionError, + createActionErrorFromResponse, + type VoidActionError, +} from './action-result.ts'; +import { getValidationErrors } from './adapter-helpers.ts'; + +export type IslandActionResult = + | { kind: 'redirect'; url: string } + | { kind: 'success' } + | { kind: 'validation'; errors: Record } + | { kind: 'error'; error: VoidActionError }; + +export async function submitIslandAction( + url: string, + method: string, + data: Record, +): Promise { + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + if (response.redirected) { + return { kind: 'redirect', url: response.url }; + } + if (response.ok) { + return { kind: 'success' }; + } + + const error = await createActionErrorFromResponse(response, url); + const errors = response.status === 422 ? getValidationErrors(error.body) : null; + if (errors) { + return { kind: 'validation', errors }; + } + if (categorizeActionError(response.status) === 'boundary') { + throw error; + } + return { kind: 'error', error }; +} diff --git a/packages/void/test/unit/island-action.test.ts b/packages/void/test/unit/island-action.test.ts new file mode 100644 index 000000000..9c16ac7f2 --- /dev/null +++ b/packages/void/test/unit/island-action.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { submitIslandAction, VoidActionError } from '../../src/pages/client.ts'; + +function respondWith(response: Response) { + const fetchMock = vi.fn().mockResolvedValue(response); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('submitIslandAction', () => { + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('submits JSON with %s', async (method) => { + const fetchMock = respondWith(new Response(null, { status: 204 })); + + await expect( + submitIslandAction('/posts?save', method, { + title: 'Hello', + tags: ['news'], + published: false, + }), + ).resolves.toEqual({ kind: 'success' }); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith('/posts?save', { + method, + headers: { 'Content-Type': 'application/json' }, + body: '{"title":"Hello","tags":["news"],"published":false}', + }); + }); + + it('does not parse a successful response body', async () => { + const response = new Response('not JSON'); + respondWith(response); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toEqual({ kind: 'success' }); + expect(response.bodyUsed).toBe(false); + }); + + it.each([200, 422, 500])( + 'handles redirects before status %s and body decoding', + async (status) => { + const response = new Response('{invalid JSON', { + status, + headers: { 'Content-Type': 'application/json' }, + }); + Object.defineProperties(response, { + redirected: { value: true }, + url: { value: 'https://app.test/posts/1' }, + }); + respondWith(response); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toEqual({ + kind: 'redirect', + url: 'https://app.test/posts/1', + }); + expect(response.bodyUsed).toBe(false); + }, + ); + + it.each([{ title: 'Required' }, {}])('returns 422 field errors: %j', async (errors) => { + respondWith(Response.json({ errors }, { status: 422 })); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toEqual({ + kind: 'validation', + errors, + }); + }); + + it.each([null, {}, { errors: null }, { errors: [] }, { errors: 'invalid' }])( + 'keeps 422 responses without a field-error object as action errors: %j', + async (body) => { + respondWith(Response.json(body, { status: 422 })); + + const result = await submitIslandAction('/posts', 'POST', {}); + + expect(result).toMatchObject({ kind: 'error', error: { status: 422, body } }); + }, + ); + + it.each([400, 402, 404, 408, 409, 412, 413, 415, 422, 429, 499])( + 'returns a form-level action error for status %s', + async (status) => { + respondWith(new Response('Try again', { status, statusText: 'Action failed' })); + + const result = await submitIslandAction('/posts?save', 'POST', {}); + + expect(result).toMatchObject({ + kind: 'error', + error: { status, statusText: 'Action failed', url: '/posts?save', body: 'Try again' }, + }); + if (result.kind === 'error') { + expect(result.error).toBeInstanceOf(VoidActionError); + } + }, + ); + + it('requires status 422 to treat an errors object as field errors', async () => { + respondWith(Response.json({ errors: { title: 'Conflict' } }, { status: 409 })); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toMatchObject({ + kind: 'error', + error: { status: 409, body: { errors: { title: 'Conflict' } } }, + }); + }); + + it.each([302, 401, 403, 405, 418, 428, 500, 501, 502, 503, 504])( + 'throws boundary and unrecognized action errors for status %s', + async (status) => { + respondWith(Response.json({ errors: { title: 'Unavailable' } }, { status })); + + const action = submitIslandAction('/posts', 'POST', {}); + + await expect(action).rejects.toBeInstanceOf(VoidActionError); + await expect(action).rejects.toMatchObject({ + status, + url: '/posts', + body: { errors: { title: 'Unavailable' } }, + }); + }, + ); + + it.each([ + ['application/json; charset=utf-8', '{"message":"Conflict"}', { message: 'Conflict' }], + ['application/json', '{invalid JSON', null], + ['text/plain', '', null], + ['text/plain', 'Conflict', 'Conflict'], + ['application/problem+json', '{"detail":"Conflict"}', '{"detail":"Conflict"}'], + ])('decodes an error body with content type %s: %s', async (contentType, body, expected) => { + respondWith(new Response(body, { status: 409, headers: { 'Content-Type': contentType } })); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toMatchObject({ + kind: 'error', + error: { status: 409, body: expected }, + }); + }); + + it('keeps the HTTP error when reading the body fails', async () => { + const response = new Response('Conflict', { status: 409 }); + await response.text(); + respondWith(response); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toMatchObject({ + kind: 'error', + error: { status: 409, body: null }, + }); + }); + + it('preserves structured usage-limit messages and retry metadata', async () => { + const body = { + code: 'usage_limit', + resource: 'ai', + reason: 'monthly_quota', + message: 'Your draft is still editable.', + retryAt: '2099-01-01T00:00:00Z', + }; + respondWith(Response.json(body, { status: 429 })); + + await expect(submitIslandAction('/posts', 'POST', {})).resolves.toMatchObject({ + kind: 'error', + error: { status: 429, message: body.message, body, limit: body }, + }); + }); + + it('propagates network failures unchanged', async () => { + const failure = new TypeError('Failed to fetch'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(failure)); + + await expect(submitIslandAction('/posts', 'POST', {})).rejects.toBe(failure); + }); + + it('rejects serialization failures before making a request', async () => { + const fetchMock = respondWith(new Response()); + const data: Record = {}; + data.self = data; + + await expect(submitIslandAction('/posts', 'POST', data)).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vue/src/runtime/use-island-form.ts b/packages/vue/src/runtime/use-island-form.ts index 67d55e066..2238a6cb5 100644 --- a/packages/vue/src/runtime/use-island-form.ts +++ b/packages/vue/src/runtime/use-island-form.ts @@ -1,9 +1,4 @@ -import { - VoidActionError, - categorizeActionError, - getValidationErrors, - isEqualFormValue, -} from 'void/pages-client'; +import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; import { reactive, ref, watch } from 'vue'; export type IslandFormReturn> = Readonly<{ @@ -24,19 +19,6 @@ export type IslandFormReturn> = Readonly<{ clearError(): void; }>; -async function readActionErrorBody(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - try { - if (contentType.includes('application/json')) { - return await response.json(); - } - const text = await response.text(); - return text || null; - } catch { - return null; - } -} - /** * Form composable for island pages. Uses fetch + page reload instead of the * Void Router (which is not available in island mode). @@ -100,18 +82,14 @@ export function useIslandForm>(defaults: T): I clearError(); try { - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); + const result = await submitIslandAction(url, method, data); - if (response.redirected) { - window.location.href = response.url; + if (result.kind === 'redirect') { + window.location.href = result.url; return; } - if (response.ok) { + if (result.kind === 'success') { wasSuccessful.value = true; recentlySuccessful.value = true; defaults_ = { ...data }; @@ -119,23 +97,10 @@ export function useIslandForm>(defaults: T): I recentlySuccessful.value = false; }, 2000); window.location.reload(); + } else if (result.kind === 'validation') { + Object.assign(errors, result.errors); } else { - const body = await readActionErrorBody(response); - const actionError = new VoidActionError({ - body, - status: response.status, - statusText: response.statusText, - url, - }); - const validationErrors = response.status === 422 ? getValidationErrors(body) : null; - if (validationErrors) { - Object.assign(errors, validationErrors); - return; - } - if (categorizeActionError(response.status) === 'boundary') { - throw actionError; - } - error.value = actionError; + error.value = result.error; } } finally { pending.value = false;