diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 77691eae4..1b12747ce 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -300,6 +300,20 @@ Expected errors such as `400`, `404`, `409`, `422`, and `429` stay with the form Authentication errors (`401`, `403`), server errors (`500`, `502`), and unexpected failures are thrown. Handle them with your framework's error boundary or error handling. +Use `isVoidActionError()` to tell a failed action apart from other errors. It's `true` for a `VoidActionError` at any status, and narrows the error so you can read `status` and `message`. `isCallSiteActionError()` is `true` only for the expected errors that `action()` returns. + +```ts +import { isVoidActionError } from '@void/react'; // or "@void/vue", "@void/svelte", "@void/solid" + +function describeError(error: unknown) { + if (isVoidActionError(error)) { + if (error.status === 401) return 'Please sign in again.'; + if (error.status === 403) return error.message; + } + return 'Something went wrong.'; +} +``` + Actions can throw `ValidationError` for custom validation logic: ```ts diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index f42d1ed94..ec3857b23 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -91,6 +91,8 @@ Imported from `"@void/vue"`. | `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. | | `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params, preserveScroll }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. | +| `isVoidActionError(error)` | Returns `true` when `error` is a `VoidActionError` at any status, such as a thrown `401`, `403`, or `500`. Narrows `error` to `VoidActionError`. See [handling thrown errors](../../guide/pages-routing/actions-and-forms.md#validation-errors). | +| `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. | | `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). | Vue `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links. @@ -144,6 +146,8 @@ Imported from `"@void/react"`. | `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper hook for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. | | `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params, preserveScroll }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. | +| `isVoidActionError(error)` | Returns `true` when `error` is a `VoidActionError` at any status, such as a thrown `401`, `403`, or `500`. Narrows `error` to `VoidActionError`. See [handling thrown errors](../../guide/pages-routing/actions-and-forms.md#validation-errors). | +| `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. | | `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). | | `Deferred` | React-only prop type for `defer()` results. It is `Promise` and is consumed with React `use()`. | @@ -221,6 +225,8 @@ Imported from `"@void/svelte"`. | `useForm(url, defaults, options?)` | Form helper using Svelte 5 runes. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. | | `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params, preserveScroll }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. | +| `isVoidActionError(error)` | Returns `true` when `error` is a `VoidActionError` at any status, such as a thrown `401`, `403`, or `500`. Narrows `error` to `VoidActionError`. See [handling thrown errors](../../guide/pages-routing/actions-and-forms.md#validation-errors). | +| `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. | | `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). | | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. | | `useParams()` | Returns dynamic route params for the page currently being rendered. | @@ -272,6 +278,8 @@ Imported from `"@void/solid"`. | `useForm(url, defaults, options?)` | Form helper using Solid stores. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. | | `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params, preserveScroll }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. | +| `isVoidActionError(error)` | Returns `true` when `error` is a `VoidActionError` at any status, such as a thrown `401`, `403`, or `500`. Narrows `error` to `VoidActionError`. See [handling thrown errors](../../guide/pages-routing/actions-and-forms.md#validation-errors). | +| `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. | | `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). | | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. | | `useParams()` | Returns dynamic route params for the page currently being rendered. | diff --git a/packages/react/src/runtime/index.ts b/packages/react/src/runtime/index.ts index d3ad9687d..b1f6580fd 100644 --- a/packages/react/src/runtime/index.ts +++ b/packages/react/src/runtime/index.ts @@ -7,6 +7,7 @@ export { useForm } from './use-form.ts'; export { useNavigation } from './use-navigation.ts'; export { useIslandForm } from './use-island-form.ts'; export { action, setActionRouter } from './action.ts'; +export { isCallSiteActionError, isVoidActionError, type VoidActionError } from 'void/pages-client'; export { SharedContext, ErrorsContext, diff --git a/packages/solid/src/runtime/index.ts b/packages/solid/src/runtime/index.ts index 36727e276..4bff63219 100644 --- a/packages/solid/src/runtime/index.ts +++ b/packages/solid/src/runtime/index.ts @@ -7,6 +7,7 @@ export { useForm } from './use-form.ts'; export { useNavigation } from './use-navigation.ts'; export { useIslandForm } from './use-island-form.ts'; export { action, setActionRouter } from './action.ts'; +export { isCallSiteActionError, isVoidActionError, type VoidActionError } from 'void/pages-client'; export { SharedContext, ErrorsContext, diff --git a/packages/svelte/src/runtime/index.ts b/packages/svelte/src/runtime/index.ts index 4e5240d3f..135b06530 100644 --- a/packages/svelte/src/runtime/index.ts +++ b/packages/svelte/src/runtime/index.ts @@ -7,4 +7,5 @@ export { useForm } from './use-form.svelte.ts'; export { useNavigation } from './use-navigation.ts'; export { useIslandForm } from './use-island-form.svelte.ts'; export { action, setActionRouter } from './action.ts'; +export { isCallSiteActionError, isVoidActionError, type VoidActionError } from 'void/pages-client'; export type { DeferredState as Deferred, InferProps } from 'void'; diff --git a/packages/void/src/pages/action-result.ts b/packages/void/src/pages/action-result.ts index 1bf0a574b..ff7ef5d97 100644 --- a/packages/void/src/pages/action-result.ts +++ b/packages/void/src/pages/action-result.ts @@ -73,18 +73,22 @@ export function categorizeActionError(status: number): ActionErrorHandlingScope } } -export function isCallSiteActionError(error: unknown): error is VoidActionError { +// Checks the name, not `instanceof`: an app can bundle more than one copy of this class. +export function isVoidActionError(error: unknown): error is VoidActionError { return ( typeof error === 'object' && error !== null && 'name' in error && error.name === 'VoidActionError' && 'status' in error && - typeof error.status === 'number' && - categorizeActionError(error.status) === 'callSite' + typeof error.status === 'number' ); } +export function isCallSiteActionError(error: unknown): error is VoidActionError { + return isVoidActionError(error) && categorizeActionError(error.status) === 'callSite'; +} + export function isAbortError(error: unknown): boolean { return ( typeof error === 'object' && diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 78a8b4caa..d88f08474 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -45,6 +45,7 @@ export { categorizeActionError, isAbortError, isCallSiteActionError, + isVoidActionError, } from './action-result.ts'; export type { ActionErrorHandlingScope, ActionResult } from './action-result.ts'; export { submitIslandAction, type IslandActionResult } from './island-action.ts'; diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index d8443ed5e..5db822319 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -6,6 +6,8 @@ import { categorizeActionError, createActionClient, isAbortError, + isCallSiteActionError, + isVoidActionError, submitAction, type VoidRouter, } from '../../src/pages/client.ts'; @@ -122,6 +124,35 @@ describe('action result handling', () => { await expect(submitAction(router, '/users', { method: 'POST' })).rejects.toBe(error); }); + it('recognizes action errors at every status', () => { + for (const status of [401, 403, 409, 422, 500]) { + const error = new VoidActionError({ body: '', status, statusText: '', url: '/users' }); + expect(isVoidActionError(error)).toBe(true); + } + // Another bundled copy of the class + expect(isVoidActionError({ name: 'VoidActionError', status: 403 })).toBe(true); + }); + + it('rejects values that are not action errors', () => { + expect(isVoidActionError(new Error('Boom'))).toBe(false); + expect(isVoidActionError({ name: 'VoidActionError' })).toBe(false); + expect(isVoidActionError({ name: 'VoidActionError', status: '403' })).toBe(false); + expect(isVoidActionError(null)).toBe(false); + expect(isVoidActionError('VoidActionError')).toBe(false); + }); + + it('recognizes call-site action errors by status', () => { + const error = (status: number) => + new VoidActionError({ body: '', status, statusText: '', url: '/users' }); + + expect(isCallSiteActionError(error(409))).toBe(true); + expect(isCallSiteActionError(error(422))).toBe(true); + expect(isCallSiteActionError(error(401))).toBe(false); + expect(isCallSiteActionError(error(403))).toBe(false); + expect(isCallSiteActionError(error(500))).toBe(false); + expect(isCallSiteActionError(new Error('Boom'))).toBe(false); + }); + it('detects abort errors', () => { expect(isAbortError(new DOMException('Navigation aborted', 'AbortError'))).toBe(true); expect(isAbortError(new Error('Boom'))).toBe(false); diff --git a/packages/vue/src/runtime/index.ts b/packages/vue/src/runtime/index.ts index 295bbd6ca..301c74b95 100644 --- a/packages/vue/src/runtime/index.ts +++ b/packages/vue/src/runtime/index.ts @@ -7,4 +7,5 @@ export { useForm } from './use-form.ts'; export { useNavigation } from './use-navigation.ts'; export { useIslandForm } from './use-island-form.ts'; export { action, setActionRouter } from './action.ts'; +export { isCallSiteActionError, isVoidActionError, type VoidActionError } from 'void/pages-client'; export type { DeferredState as Deferred, InferProps } from 'void';