Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/api/pages.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/react/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/solid/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
10 changes: 7 additions & 3 deletions packages/void/src/pages/action-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down
1 change: 1 addition & 0 deletions packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
31 changes: 31 additions & 0 deletions packages/void/test/unit/action-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
categorizeActionError,
createActionClient,
isAbortError,
isCallSiteActionError,
isVoidActionError,
submitAction,
type VoidRouter,
} from '../../src/pages/client.ts';
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';