From 79a2b21acaf0cc6c60382fb81aefd6c5d7cfb068 Mon Sep 17 00:00:00 2001 From: karthic Date: Tue, 3 Nov 2026 22:23:19 +0530 Subject: [PATCH] fix(pages): use a plain-text 4xx action response as the error message A thrown HTTPException reaches the client as a text/plain body, but VoidActionError.message stayed a status line such as "Action failed with 409 Conflict", so Form and action() callers showed that instead of the action's message. Use the body of a plain-text 4xx response as the message. JSON, HTML, empty and 5xx responses keep the status summary. Fixes #680 Co-Authored-By: Claude Opus 5.5 --- docs/guide/pages-routing/actions-and-forms.md | 2 +- .../integration/react-pages-client.test.ts | 7 ++- packages/void/src/pages/action-result.ts | 19 +++++- packages/void/test/unit/action-result.test.ts | 61 +++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index b825e767f..1b0f0e465 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -272,7 +272,7 @@ if (!result.ok) { `action()` defaults to `POST`. Pass `data`, `params`, and `method` to set the body, route parameters, or HTTP method. -A successful call returns `{ ok: true, pageData }`. Expected errors, such as validation failures or conflicts, return `{ ok: false, error }`. +A successful call returns `{ ok: true, pageData }`. Expected errors, such as validation failures or conflicts, return `{ ok: false, error }`. `error.message` is the message of an [`HTTPException`](../../integrations/hono.md#utilities) with a `4xx` status, or the body of another plain-text `4xx` response. For other errors, it is a status summary such as `Action failed with 404 Not Found`. ## Validation Errors diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts index 979e8466b..cc03d416c 100644 --- a/packages/react/test/integration/react-pages-client.test.ts +++ b/packages/react/test/integration/react-pages-client.test.ts @@ -622,9 +622,10 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') await page.click('button:has-text("Trigger conflict")'); await page.waitForSelector('[data-testid="conflict-error"]'); - const text = await page.textContent('body'); - expect(text).toContain('Action failed with 409 Conflict'); - expect(text).toContain('Action Error'); + expect(await page.textContent('[data-testid="conflict-error"]')).toBe( + 'Conflict body="Conflict"', + ); + expect(await page.textContent('body')).toContain('Action Error'); expect(errors).toEqual([]); } finally { await page.context().close(); diff --git a/packages/void/src/pages/action-result.ts b/packages/void/src/pages/action-result.ts index a32b98d84..1bf0a574b 100644 --- a/packages/void/src/pages/action-result.ts +++ b/packages/void/src/pages/action-result.ts @@ -16,18 +16,21 @@ export class VoidActionError extends Error { constructor({ body, + message, status, statusText, url, }: { body: unknown; + message?: string; status: number; statusText: string; url: string; }) { const limit = status === 429 ? readUsageLimit(body) : null; super( - limit?.message ?? + message ?? + limit?.message ?? (statusText ? `Action failed with ${status} ${statusText}` : `Action failed with ${status}`), @@ -104,12 +107,24 @@ async function readActionErrorBody(response: Response): Promise { } } +function readActionErrorMessage(response: Response, body: unknown): string | undefined { + if (response.status < 400 || response.status >= 500) { + return undefined; + } + if (!response.headers.get('content-type')?.includes('text/plain')) { + return undefined; + } + return typeof body === 'string' && body.trim() ? body : undefined; +} + export async function createActionErrorFromResponse( response: Response, url: string, ): Promise { + const body = await readActionErrorBody(response); return new VoidActionError({ - body: await readActionErrorBody(response), + body, + message: readActionErrorMessage(response, body), status: response.status, statusText: response.statusText, url, diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index f9e43eff9..5b08eab4f 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -1,4 +1,6 @@ +import { HTTPException } from 'hono/http-exception'; import { describe, expect, it, vi } from 'vite-plus/test'; +import { createActionErrorFromResponse } from '../../src/pages/action-result.ts'; import { VoidActionError, categorizeActionError, @@ -215,3 +217,62 @@ it('exposes the application message and retry deadline for structured quota resp expect(error.limit?.resource).toBe('ai'); expect(error.limit?.retryAt).toBe('2099-01-01T00:00:00Z'); }); + +describe('action error messages', () => { + function errorFrom(body: BodyInit | null, init: ResponseInit) { + return createActionErrorFromResponse(new Response(body, init), '/users/1'); + } + + it('uses the message of a thrown HTTPException', async () => { + const response = new HTTPException(409, { message: 'That note was deleted.' }).getResponse(); + const error = await createActionErrorFromResponse(response, '/notes'); + expect(error.message).toBe('That note was deleted.'); + expect(error.status).toBe(409); + }); + + it.each([ + [403, 'Forbidden', 'Cross-origin page actions are not allowed.'], + [404, 'Not Found', 'User not found.'], + [409, 'Conflict', 'That email is already taken.'], + [422, 'Unprocessable Entity', 'The plan is archived.'], + ])('uses the plain-text body of a %i as the message', async (status, statusText, text) => { + const error = await errorFrom(text, { status, statusText }); + expect(error.message).toBe(text); + expect(error.body).toBe(text); + }); + + it('keeps the status summary for an empty or blank body', async () => { + expect((await errorFrom(null, { status: 404, statusText: 'Not Found' })).message).toBe( + 'Action failed with 404 Not Found', + ); + expect((await errorFrom(' ', { status: 404, statusText: 'Not Found' })).message).toBe( + 'Action failed with 404 Not Found', + ); + }); + + it('keeps the status summary for a JSON or HTML body', async () => { + const json = await errorFrom(JSON.stringify({ error: 'Missing' }), { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + }); + expect(json.message).toBe('Action failed with 404 Not Found'); + expect(json.body).toEqual({ error: 'Missing' }); + + const html = await errorFrom('

Request Entity Too Large

', { + status: 413, + statusText: 'Payload Too Large', + headers: { 'content-type': 'text/html' }, + }); + expect(html.message).toBe('Action failed with 413 Payload Too Large'); + }); + + it('keeps the status summary for a server error', async () => { + const error = await errorFrom('relation "users" does not exist', { + status: 500, + statusText: 'Internal Server Error', + }); + expect(error.message).toBe('Action failed with 500 Internal Server Error'); + expect(error.body).toBe('relation "users" does not exist'); + }); +});