Skip to content

Commit 84189b6

Browse files
authored
Actions: New fallback behavior with action={actions.name} (#11570)
* feat: support _astroAction query param * feat(test): _astroAction query param * fix: handle _actions requests from legacy fallback * feat(e2e): new actions pattern on blog test * feat: update React 19 adapter to use query params * fix: remove legacy getApiContext() * feat: ActionQueryStringInvalidError * fix: update error description * feat: ActionQueryStringInvalidError * chore: comment on _actions skip * feat: .queryString property * chore: comment on throw new Error * chore: better guess for "why" on query string * chore: remove console log * chore: changeset * chore: changeset
1 parent 1953dbb commit 84189b6

File tree

11 files changed

+269
-59
lines changed

11 files changed

+269
-59
lines changed

.changeset/silly-bulldogs-sparkle.md

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
Updates the Astro Actions fallback to support `action={actions.name}` instead of using `getActionProps().` This will submit a form to the server in zero-JS scenarios using a search parameter:
9+
10+
```astro
11+
---
12+
import { actions } from 'astro:actions';
13+
---
14+
15+
<form action={actions.logOut}>
16+
<!--output: action="?_astroAction=logOut"-->
17+
<button>Log Out</button>
18+
</form>
19+
```
20+
21+
You may also construct form action URLs using string concatenation, or by using the `URL()` constructor, with the an action's `.queryString` property:
22+
23+
```astro
24+
---
25+
import { actions } from 'astro:actions';
26+
27+
const confirmationUrl = new URL('/confirmation', Astro.url);
28+
confirmationUrl.search = actions.queryString;
29+
---
30+
31+
<form method="POST" action={confirmationUrl.pathname}>
32+
<button>Submit</button>
33+
</form>
34+
```
35+
36+
## Migration
37+
38+
`getActionProps()` is now deprecated. To use the new fallback pattern, remove the `getActionProps()` input from your form and pass your action function to the form `action` attribute:
39+
40+
```diff
41+
---
42+
import {
43+
actions,
44+
- getActionProps,
45+
} from 'astro:actions';
46+
---
47+
48+
+ <form method="POST" action={actions.logOut}>
49+
- <form method="POST">
50+
- <input {...getActionProps(actions.logOut)} />
51+
<button>Log Out</button>
52+
</form>
53+
```

packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro

+2-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import BlogPost from '../../layouts/BlogPost.astro';
44
import { db, eq, Comment, Likes } from 'astro:db';
55
import { Like } from '../../components/Like';
66
import { PostComment } from '../../components/PostComment';
7-
import { actions, getActionProps } from 'astro:actions';
7+
import { actions } from 'astro:actions';
88
import { isInputError } from 'astro:actions';
99
1010
export const prerender = false;
@@ -55,8 +55,7 @@ const commentPostIdOverride = Astro.url.searchParams.get('commentPostIdOverride'
5555
: undefined}
5656
client:load
5757
/>
58-
<form method="POST" data-testid="progressive-fallback">
59-
<input {...getActionProps(actions.blog.comment)} />
58+
<form method="POST" data-testid="progressive-fallback" action={actions.blog.comment.queryString}>
6059
<input type="hidden" name="postId" value={post.id} />
6160
<label for="fallback-author">
6261
Author

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

+2-3
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@ export const server = {
2525
likeWithActionState: defineAction({
2626
accept: 'form',
2727
input: z.object({ postId: z.string() }),
28-
handler: async ({ postId }) => {
28+
handler: async ({ postId }, ctx) => {
2929
await new Promise((r) => setTimeout(r, 200));
3030

31-
const context = getApiContext();
32-
const state = await experimental_getActionState<number>(context);
31+
const state = await experimental_getActionState<number>(ctx);
3332

3433
const { likes } = await db
3534
.update(Likes)

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

+98-26
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import type { APIContext, MiddlewareNext } from '../../@types/astro.js';
33
import { defineMiddleware } from '../../core/middleware/index.js';
44
import { ApiContextStorage } from './store.js';
55
import { formContentTypes, getAction, hasContentType } from './utils.js';
6-
import { callSafely } from './virtual/shared.js';
6+
import { callSafely, getActionQueryString } from './virtual/shared.js';
7+
import { AstroError } from '../../core/errors/errors.js';
8+
import {
9+
ActionQueryStringInvalidError,
10+
ActionsUsedWithForGetError,
11+
} from '../../core/errors/errors-data.js';
712

813
export type Locals = {
914
_actionsInternal: {
@@ -14,62 +19,129 @@ export type Locals = {
1419

1520
export const onRequest = defineMiddleware(async (context, next) => {
1621
const locals = context.locals as Locals;
22+
const { request } = context;
1723
// Actions middleware may have run already after a path rewrite.
1824
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
1925
// `_actionsInternal` is the same for every page,
2026
// so short circuit if already defined.
2127
if (locals._actionsInternal) return ApiContextStorage.run(context, () => next());
22-
if (context.request.method === 'GET') {
23-
return nextWithLocalsStub(next, context);
24-
}
2528

2629
// Heuristic: If body is null, Astro might've reset this for prerendering.
2730
// Stub with warning when `getActionResult()` is used.
28-
if (context.request.method === 'POST' && context.request.body === null) {
31+
if (request.method === 'POST' && request.body === null) {
2932
return nextWithStaticStub(next, context);
3033
}
3134

32-
const { request, url } = context;
33-
const contentType = request.headers.get('Content-Type');
35+
const actionName = context.url.searchParams.get('_astroAction');
36+
37+
if (context.request.method === 'POST' && actionName) {
38+
return handlePost({ context, next, actionName });
39+
}
3440

35-
// Avoid double-handling with middleware when calling actions directly.
36-
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
41+
if (context.request.method === 'GET' && actionName) {
42+
throw new AstroError({
43+
...ActionsUsedWithForGetError,
44+
message: ActionsUsedWithForGetError.message(actionName),
45+
});
46+
}
3747

38-
if (!contentType || !hasContentType(contentType, formContentTypes)) {
39-
return nextWithLocalsStub(next, context);
48+
if (context.request.method === 'POST') {
49+
return handlePostLegacy({ context, next });
4050
}
4151

42-
const formData = await request.clone().formData();
43-
const actionPath = formData.get('_astroAction');
44-
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, context);
52+
return nextWithLocalsStub(next, context);
53+
});
54+
55+
async function handlePost({
56+
context,
57+
next,
58+
actionName,
59+
}: { context: APIContext; next: MiddlewareNext; actionName: string }) {
60+
const { request } = context;
4561

46-
const action = await getAction(actionPath);
47-
if (!action) return nextWithLocalsStub(next, context);
62+
const action = await getAction(actionName);
63+
if (!action) {
64+
throw new AstroError({
65+
...ActionQueryStringInvalidError,
66+
message: ActionQueryStringInvalidError.message(actionName),
67+
});
68+
}
69+
70+
const contentType = request.headers.get('content-type');
71+
let formData: FormData | undefined;
72+
if (contentType && hasContentType(contentType, formContentTypes)) {
73+
formData = await request.clone().formData();
74+
}
75+
const actionResult = await ApiContextStorage.run(context, () =>
76+
callSafely(() => action(formData))
77+
);
4878

49-
const result = await ApiContextStorage.run(context, () => callSafely(() => action(formData)));
79+
return handleResult({ context, next, actionName, actionResult });
80+
}
5081

82+
function handleResult({
83+
context,
84+
next,
85+
actionName,
86+
actionResult,
87+
}: { context: APIContext; next: MiddlewareNext; actionName: string; actionResult: any }) {
5188
const actionsInternal: Locals['_actionsInternal'] = {
5289
getActionResult: (actionFn) => {
53-
if (actionFn.toString() !== actionPath) return Promise.resolve(undefined);
54-
// The `action` uses type `unknown` since we can't infer the user's action type.
55-
// Cast to `any` to satisfy `getActionResult()` type.
56-
return result as any;
90+
if (actionFn.toString() !== getActionQueryString(actionName)) {
91+
return Promise.resolve(undefined);
92+
}
93+
return actionResult;
5794
},
58-
actionResult: result,
95+
actionResult,
5996
};
97+
const locals = context.locals as Locals;
6098
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
99+
61100
return ApiContextStorage.run(context, async () => {
62101
const response = await next();
63-
if (result.error) {
102+
if (actionResult.error) {
64103
return new Response(response.body, {
65-
status: result.error.status,
66-
statusText: result.error.name,
104+
status: actionResult.error.status,
105+
statusText: actionResult.error.type,
67106
headers: response.headers,
68107
});
69108
}
70109
return response;
71110
});
72-
});
111+
}
112+
113+
async function handlePostLegacy({ context, next }: { context: APIContext; next: MiddlewareNext }) {
114+
const { request } = context;
115+
116+
// We should not run a middleware handler for fetch()
117+
// requests directly to the /_actions URL.
118+
// Otherwise, we may handle the result twice.
119+
if (context.url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
120+
121+
const contentType = request.headers.get('content-type');
122+
let formData: FormData | undefined;
123+
if (contentType && hasContentType(contentType, formContentTypes)) {
124+
formData = await request.clone().formData();
125+
}
126+
127+
if (!formData) return nextWithLocalsStub(next, context);
128+
129+
const actionName = formData.get('_astroAction') as string;
130+
if (!actionName) return nextWithLocalsStub(next, context);
131+
132+
const action = await getAction(actionName);
133+
if (!action) {
134+
throw new AstroError({
135+
...ActionQueryStringInvalidError,
136+
message: ActionQueryStringInvalidError.message(actionName),
137+
});
138+
}
139+
140+
const actionResult = await ApiContextStorage.run(context, () =>
141+
callSafely(() => action(formData))
142+
);
143+
return handleResult({ context, next, actionName, actionResult });
144+
}
73145

74146
function nextWithStaticStub(next: MiddlewareNext, context: APIContext) {
75147
Object.defineProperty(context.locals, '_actionsInternal', {

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type ActionClient<
2929
? ((
3030
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
3131
) => Promise<Awaited<TOutput>>) & {
32+
queryString: string;
3233
safe: (
3334
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
3435
) => Promise<
@@ -59,7 +60,7 @@ export function defineAction<
5960
input?: TInputSchema;
6061
accept?: TAccept;
6162
handler: ActionHandler<TInputSchema, TOutput>;
62-
}): ActionClient<TOutput, TAccept, TInputSchema> {
63+
}): ActionClient<TOutput, TAccept, TInputSchema> & string {
6364
const serverHandler =
6465
accept === 'form'
6566
? getFormServerHandler(handler, inputSchema)
@@ -70,7 +71,7 @@ export function defineAction<
7071
return callSafely(() => serverHandler(unparsedInput));
7172
},
7273
});
73-
return serverHandler as ActionClient<TOutput, TAccept, TInputSchema>;
74+
return serverHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
7475
}
7576

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

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

+18-1
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,27 @@ export async function callSafely<TOutput>(
154154
}
155155
}
156156

157+
export function getActionQueryString(name: string) {
158+
const searchParams = new URLSearchParams({ _astroAction: name });
159+
return `?${searchParams.toString()}`;
160+
}
161+
162+
/**
163+
* @deprecated You can now pass action functions
164+
* directly to the `action` attribute on a form.
165+
*
166+
* Example: `<form action={actions.like} />`
167+
*/
157168
export function getActionProps<T extends (args: FormData) => MaybePromise<unknown>>(action: T) {
169+
const params = new URLSearchParams(action.toString());
170+
const actionName = params.get('_astroAction');
171+
if (!actionName) {
172+
// No need for AstroError. `getActionProps()` will be removed for stable.
173+
throw new Error('Invalid actions function was passed to getActionProps()');
174+
}
158175
return {
159176
type: 'hidden',
160177
name: '_astroAction',
161-
value: action.toString(),
178+
value: actionName,
162179
} as const;
163180
}

packages/astro/src/core/errors/errors-data.ts

+30
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,36 @@ export const ActionsWithoutServerOutputError = {
16171617
hint: 'Learn about on-demand rendering: https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered',
16181618
} satisfies ErrorData;
16191619

1620+
/**
1621+
* @docs
1622+
* @see
1623+
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
1624+
* @description
1625+
* Action was called from a form using a GET request, but only POST requests are supported. This often occurs if `method="POST"` is missing on the form.
1626+
*/
1627+
export const ActionsUsedWithForGetError = {
1628+
name: 'ActionsUsedWithForGetError',
1629+
title: 'An invalid Action query string was passed by a form.',
1630+
message: (actionName: string) =>
1631+
`Action ${actionName} was called from a form using a GET request, but only POST requests are supported. This often occurs if \`method="POST"\` is missing on the form.`,
1632+
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
1633+
} satisfies ErrorData;
1634+
1635+
/**
1636+
* @docs
1637+
* @see
1638+
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
1639+
* @description
1640+
* The server received the query string `?_astroAction=name`, but could not find an action with that name. Use the action function's `.queryString` property to retrieve the form `action` URL.
1641+
*/
1642+
export const ActionQueryStringInvalidError = {
1643+
name: 'ActionQueryStringInvalidError',
1644+
title: 'An invalid Action query string was passed by a form.',
1645+
message: (actionName: string) =>
1646+
`The server received the query string \`?_astroAction=${actionName}\`, but could not find an action with that name. If you changed an action's name in development, remove this query param from your URL and refresh.`,
1647+
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
1648+
} satisfies ErrorData;
1649+
16201650
/**
16211651
* @docs
16221652
* @see

0 commit comments

Comments
 (0)