Skip to content

Commit 1c3265a

Browse files
authored
Actions: Make .safe() the default return value (#11571)
* feat: new orThrow types * fix: parens on return type * feat: switch implementation to orThrow() * feat(e2e): update PostComment * fix: remove callSafely from middleware * fix: toString() for actions * fix(e2e): more orThrow updates * feat: remove progressive enhancement from orThrow * fix: remove _astroActionSafe handler from react * feat(e2e): update test to use safe calling * chore: console log * chore: unused import * fix: add rewriting: true to test fixture * fix: correctly throw for server-only actions * chore: changeset * fix: update type tests * fix(test): remove .safe() chain * docs: use "patch" with BREAKING CHANGE notice * docs: clarify react integration in changeset
1 parent a77ed84 commit 1c3265a

File tree

16 files changed

+105
-83
lines changed

16 files changed

+105
-83
lines changed

.changeset/light-chairs-happen.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@astrojs/react': patch
3+
'astro': patch
4+
---
5+
6+
**BREAKING CHANGE to the experimental Actions API only.** Install the latest `@astrojs/react` integration as well if you're using React 19 features.
7+
8+
Make `.safe()` the default return value for actions. This means `{ data, error }` will be returned when calling an action directly. If you prefer to get the data while allowing errors to throw, chain the `.orThrow()` modifier.
9+
10+
```ts
11+
import { actions } from 'astro:actions';
12+
13+
// Before
14+
const { data, error } = await actions.like.safe();
15+
// After
16+
const { data, error } = await actions.like();
17+
18+
// Before
19+
const newLikes = await actions.like();
20+
// After
21+
const newLikes = await actions.like.orThrow();
22+
```
23+
24+
## Migration
25+
26+
To migrate your existing action calls:
27+
28+
- Remove `.safe` from existing _safe_ action calls
29+
- Add `.orThrow` to existing _unsafe_ action calls

packages/astro/e2e/fixtures/actions-blog/src/components/Like.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function Like({ postId, initial }: { postId: string; initial: number }) {
1111
disabled={pending}
1212
onClick={async () => {
1313
setPending(true);
14-
setLikes(await actions.blog.like({ postId }));
14+
setLikes(await actions.blog.like.orThrow({ postId }));
1515
setPending(false);
1616
}}
1717
type="submit"

packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function PostComment({
2121
e.preventDefault();
2222
const form = e.target as HTMLFormElement;
2323
const formData = new FormData(form);
24-
const { data, error } = await actions.blog.comment.safe(formData);
24+
const { data, error } = await actions.blog.comment(formData);
2525
if (isInputError(error)) {
2626
return setBodyError(error.fields.body?.join(' '));
2727
} else if (error) {

packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db, Likes, eq, sql } from 'astro:db';
2-
import { defineAction, getApiContext, z } from 'astro:actions';
2+
import { defineAction, z, type SafeResult } from 'astro:actions';
33
import { experimental_getActionState } from '@astrojs/react/actions';
44

55
export const server = {
@@ -28,12 +28,13 @@ export const server = {
2828
handler: async ({ postId }, ctx) => {
2929
await new Promise((r) => setTimeout(r, 200));
3030

31-
const state = await experimental_getActionState<number>(ctx);
31+
const state = await experimental_getActionState<SafeResult<any, number>>(ctx);
32+
const previousLikes = state.data ?? 0;
3233

3334
const { likes } = await db
3435
.update(Likes)
3536
.set({
36-
likes: state + 1,
37+
likes: previousLikes + 1,
3738
})
3839
.where(eq(Likes.postId, postId))
3940
.returning()

packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ export function Like({ postId, label, likes }: { postId: string; label: string;
1616
export function LikeWithActionState({ postId, label, likes: initial }: { postId: string; label: string; likes: number }) {
1717
const [likes, action] = useActionState(
1818
experimental_withState(actions.blog.likeWithActionState),
19-
10,
19+
{ data: initial },
2020
);
2121

2222
return (
2323
<form action={action}>
2424
<input type="hidden" name="postId" value={postId} />
25-
<Button likes={likes} label={label} />
25+
<Button likes={likes.data} label={label} />
2626
</form>
2727
);
2828
}

packages/astro/src/@types/astro.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2845,7 +2845,7 @@ interface AstroSharedContext<
28452845
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
28462846
>(
28472847
action: TAction
2848-
) => Awaited<ReturnType<TAction['safe']>> | undefined;
2848+
) => Awaited<ReturnType<TAction>> | undefined;
28492849
/**
28502850
* Route parameters for this request if this is a dynamic route.
28512851
*/

packages/astro/src/actions/runtime/middleware.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { AstroError } from '../../core/errors/errors.js';
88
import { defineMiddleware } from '../../core/middleware/index.js';
99
import { ApiContextStorage } from './store.js';
1010
import { formContentTypes, getAction, hasContentType } from './utils.js';
11-
import { callSafely, getActionQueryString } from './virtual/shared.js';
11+
import { getActionQueryString } from './virtual/shared.js';
1212

1313
export type Locals = {
1414
_actionsInternal: {
@@ -72,9 +72,7 @@ async function handlePost({
7272
if (contentType && hasContentType(contentType, formContentTypes)) {
7373
formData = await request.clone().formData();
7474
}
75-
const actionResult = await ApiContextStorage.run(context, () =>
76-
callSafely(() => action(formData))
77-
);
75+
const actionResult = await ApiContextStorage.run(context, () => action(formData));
7876

7977
return handleResult({ context, next, actionName, actionResult });
8078
}
@@ -137,9 +135,7 @@ async function handlePostLegacy({ context, next }: { context: APIContext; next:
137135
});
138136
}
139137

140-
const actionResult = await ApiContextStorage.run(context, () =>
141-
callSafely(() => action(formData))
142-
);
138+
const actionResult = await ApiContextStorage.run(context, () => action(formData));
143139
return handleResult({ context, next, actionName, actionResult });
144140
}
145141

packages/astro/src/actions/runtime/route.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { APIRoute } from '../../@types/astro.js';
22
import { ApiContextStorage } from './store.js';
33
import { formContentTypes, getAction, hasContentType } from './utils.js';
4-
import { callSafely } from './virtual/shared.js';
54

65
export const POST: APIRoute = async (context) => {
76
const { request, url } = context;
@@ -23,7 +22,7 @@ export const POST: APIRoute = async (context) => {
2322
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415
2423
return new Response(null, { status: 415 });
2524
}
26-
const result = await ApiContextStorage.run(context, () => callSafely(() => action(args)));
25+
const result = await ApiContextStorage.run(context, () => action(args));
2726
if (result.error) {
2827
return new Response(
2928
JSON.stringify({

packages/astro/src/actions/runtime/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { ZodType } from 'zod';
2+
import type { ActionAccept, ActionClient } from './virtual/server.js';
3+
14
export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
25

36
export function hasContentType(contentType: string, expected: string[]) {
@@ -17,7 +20,7 @@ export type MaybePromise<T> = T | Promise<T>;
1720
*/
1821
export async function getAction(
1922
path: string
20-
): Promise<((param: unknown) => MaybePromise<unknown>) | undefined> {
23+
): Promise<ActionClient<unknown, ActionAccept, ZodType> | undefined> {
2124
const pathKeys = path.replace('/_actions/', '').split('.');
2225
// @ts-expect-error virtual module
2326
let { server: actionLookup } = await import('astro:internal-actions');

packages/astro/src/actions/runtime/virtual/server.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,21 @@ export type ActionClient<
2828
> = TInputSchema extends z.ZodType
2929
? ((
3030
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
31-
) => Promise<Awaited<TOutput>>) & {
31+
) => Promise<
32+
SafeResult<
33+
z.input<TInputSchema> extends ErrorInferenceObject
34+
? z.input<TInputSchema>
35+
: ErrorInferenceObject,
36+
Awaited<TOutput>
37+
>
38+
>) & {
3239
queryString: string;
33-
safe: (
40+
orThrow: (
3441
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
35-
) => Promise<
36-
SafeResult<
37-
z.input<TInputSchema> extends ErrorInferenceObject
38-
? z.input<TInputSchema>
39-
: ErrorInferenceObject,
40-
Awaited<TOutput>
41-
>
42-
>;
42+
) => Promise<Awaited<TOutput>>;
4343
}
44-
: ((input?: any) => Promise<Awaited<TOutput>>) & {
45-
safe: (input?: any) => Promise<SafeResult<never, Awaited<TOutput>>>;
44+
: (input?: any) => Promise<SafeResult<never, Awaited<TOutput>>> & {
45+
orThrow: (input?: any) => Promise<Awaited<TOutput>>;
4646
};
4747

4848
export function defineAction<
@@ -66,12 +66,15 @@ export function defineAction<
6666
? getFormServerHandler(handler, inputSchema)
6767
: getJsonServerHandler(handler, inputSchema);
6868

69-
Object.assign(serverHandler, {
70-
safe: async (unparsedInput: unknown) => {
71-
return callSafely(() => serverHandler(unparsedInput));
72-
},
69+
const safeServerHandler = async (unparsedInput: unknown) => {
70+
return callSafely(() => serverHandler(unparsedInput));
71+
};
72+
73+
Object.assign(safeServerHandler, {
74+
orThrow: serverHandler,
7375
});
74-
return serverHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
76+
77+
return safeServerHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
7578
}
7679

7780
function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'form'>>(

0 commit comments

Comments
 (0)