0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-27 22:19:04 -05:00

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
This commit is contained in:
Ben Holmes 2024-07-30 11:04:10 -04:00 committed by GitHub
parent 1953dbbd41
commit 84189b6511
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 270 additions and 60 deletions

View file

@ -0,0 +1,53 @@
---
'@astrojs/react': patch
'astro': patch
---
**BREAKING CHANGE to the experimental Actions API only.** Install the latest `@astrojs/react` integration as well if you're using React 19 features.
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:
```astro
---
import { actions } from 'astro:actions';
---
<form action={actions.logOut}>
<!--output: action="?_astroAction=logOut"-->
<button>Log Out</button>
</form>
```
You may also construct form action URLs using string concatenation, or by using the `URL()` constructor, with the an action's `.queryString` property:
```astro
---
import { actions } from 'astro:actions';
const confirmationUrl = new URL('/confirmation', Astro.url);
confirmationUrl.search = actions.queryString;
---
<form method="POST" action={confirmationUrl.pathname}>
<button>Submit</button>
</form>
```
## Migration
`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:
```diff
---
import {
actions,
- getActionProps,
} from 'astro:actions';
---
+ <form method="POST" action={actions.logOut}>
- <form method="POST">
- <input {...getActionProps(actions.logOut)} />
<button>Log Out</button>
</form>
```

View file

@ -4,7 +4,7 @@ import BlogPost from '../../layouts/BlogPost.astro';
import { db, eq, Comment, Likes } from 'astro:db';
import { Like } from '../../components/Like';
import { PostComment } from '../../components/PostComment';
import { actions, getActionProps } from 'astro:actions';
import { actions } from 'astro:actions';
import { isInputError } from 'astro:actions';
export const prerender = false;
@ -55,8 +55,7 @@ const commentPostIdOverride = Astro.url.searchParams.get('commentPostIdOverride'
: undefined}
client:load
/>
<form method="POST" data-testid="progressive-fallback">
<input {...getActionProps(actions.blog.comment)} />
<form method="POST" data-testid="progressive-fallback" action={actions.blog.comment.queryString}>
<input type="hidden" name="postId" value={post.id} />
<label for="fallback-author">
Author

View file

@ -25,11 +25,10 @@ export const server = {
likeWithActionState: defineAction({
accept: 'form',
input: z.object({ postId: z.string() }),
handler: async ({ postId }) => {
handler: async ({ postId }, ctx) => {
await new Promise((r) => setTimeout(r, 200));
const context = getApiContext();
const state = await experimental_getActionState<number>(context);
const state = await experimental_getActionState<number>(ctx);
const { likes } = await db
.update(Likes)

View file

@ -3,7 +3,12 @@ import type { APIContext, MiddlewareNext } from '../../@types/astro.js';
import { defineMiddleware } from '../../core/middleware/index.js';
import { ApiContextStorage } from './store.js';
import { formContentTypes, getAction, hasContentType } from './utils.js';
import { callSafely } from './virtual/shared.js';
import { callSafely, getActionQueryString } from './virtual/shared.js';
import { AstroError } from '../../core/errors/errors.js';
import {
ActionQueryStringInvalidError,
ActionsUsedWithForGetError,
} from '../../core/errors/errors-data.js';
export type Locals = {
_actionsInternal: {
@ -14,62 +19,129 @@ export type Locals = {
export const onRequest = defineMiddleware(async (context, next) => {
const locals = context.locals as Locals;
const { request } = context;
// Actions middleware may have run already after a path rewrite.
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
// `_actionsInternal` is the same for every page,
// so short circuit if already defined.
if (locals._actionsInternal) return ApiContextStorage.run(context, () => next());
if (context.request.method === 'GET') {
return nextWithLocalsStub(next, context);
}
// Heuristic: If body is null, Astro might've reset this for prerendering.
// Stub with warning when `getActionResult()` is used.
if (context.request.method === 'POST' && context.request.body === null) {
if (request.method === 'POST' && request.body === null) {
return nextWithStaticStub(next, context);
}
const { request, url } = context;
const contentType = request.headers.get('Content-Type');
const actionName = context.url.searchParams.get('_astroAction');
// Avoid double-handling with middleware when calling actions directly.
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
if (!contentType || !hasContentType(contentType, formContentTypes)) {
return nextWithLocalsStub(next, context);
if (context.request.method === 'POST' && actionName) {
return handlePost({ context, next, actionName });
}
const formData = await request.clone().formData();
const actionPath = formData.get('_astroAction');
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, context);
if (context.request.method === 'GET' && actionName) {
throw new AstroError({
...ActionsUsedWithForGetError,
message: ActionsUsedWithForGetError.message(actionName),
});
}
const action = await getAction(actionPath);
if (!action) return nextWithLocalsStub(next, context);
if (context.request.method === 'POST') {
return handlePostLegacy({ context, next });
}
const result = await ApiContextStorage.run(context, () => callSafely(() => action(formData)));
return nextWithLocalsStub(next, context);
});
async function handlePost({
context,
next,
actionName,
}: { context: APIContext; next: MiddlewareNext; actionName: string }) {
const { request } = context;
const action = await getAction(actionName);
if (!action) {
throw new AstroError({
...ActionQueryStringInvalidError,
message: ActionQueryStringInvalidError.message(actionName),
});
}
const contentType = request.headers.get('content-type');
let formData: FormData | undefined;
if (contentType && hasContentType(contentType, formContentTypes)) {
formData = await request.clone().formData();
}
const actionResult = await ApiContextStorage.run(context, () =>
callSafely(() => action(formData))
);
return handleResult({ context, next, actionName, actionResult });
}
function handleResult({
context,
next,
actionName,
actionResult,
}: { context: APIContext; next: MiddlewareNext; actionName: string; actionResult: any }) {
const actionsInternal: Locals['_actionsInternal'] = {
getActionResult: (actionFn) => {
if (actionFn.toString() !== actionPath) return Promise.resolve(undefined);
// The `action` uses type `unknown` since we can't infer the user's action type.
// Cast to `any` to satisfy `getActionResult()` type.
return result as any;
if (actionFn.toString() !== getActionQueryString(actionName)) {
return Promise.resolve(undefined);
}
return actionResult;
},
actionResult: result,
actionResult,
};
const locals = context.locals as Locals;
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
return ApiContextStorage.run(context, async () => {
const response = await next();
if (result.error) {
if (actionResult.error) {
return new Response(response.body, {
status: result.error.status,
statusText: result.error.name,
status: actionResult.error.status,
statusText: actionResult.error.type,
headers: response.headers,
});
}
return response;
});
});
}
async function handlePostLegacy({ context, next }: { context: APIContext; next: MiddlewareNext }) {
const { request } = context;
// We should not run a middleware handler for fetch()
// requests directly to the /_actions URL.
// Otherwise, we may handle the result twice.
if (context.url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
const contentType = request.headers.get('content-type');
let formData: FormData | undefined;
if (contentType && hasContentType(contentType, formContentTypes)) {
formData = await request.clone().formData();
}
if (!formData) return nextWithLocalsStub(next, context);
const actionName = formData.get('_astroAction') as string;
if (!actionName) return nextWithLocalsStub(next, context);
const action = await getAction(actionName);
if (!action) {
throw new AstroError({
...ActionQueryStringInvalidError,
message: ActionQueryStringInvalidError.message(actionName),
});
}
const actionResult = await ApiContextStorage.run(context, () =>
callSafely(() => action(formData))
);
return handleResult({ context, next, actionName, actionResult });
}
function nextWithStaticStub(next: MiddlewareNext, context: APIContext) {
Object.defineProperty(context.locals, '_actionsInternal', {

View file

@ -29,6 +29,7 @@ export type ActionClient<
? ((
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
) => Promise<Awaited<TOutput>>) & {
queryString: string;
safe: (
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
) => Promise<
@ -59,7 +60,7 @@ export function defineAction<
input?: TInputSchema;
accept?: TAccept;
handler: ActionHandler<TInputSchema, TOutput>;
}): ActionClient<TOutput, TAccept, TInputSchema> {
}): ActionClient<TOutput, TAccept, TInputSchema> & string {
const serverHandler =
accept === 'form'
? getFormServerHandler(handler, inputSchema)
@ -70,7 +71,7 @@ export function defineAction<
return callSafely(() => serverHandler(unparsedInput));
},
});
return serverHandler as ActionClient<TOutput, TAccept, TInputSchema>;
return serverHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
}
function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'form'>>(

View file

@ -154,10 +154,27 @@ export async function callSafely<TOutput>(
}
}
export function getActionQueryString(name: string) {
const searchParams = new URLSearchParams({ _astroAction: name });
return `?${searchParams.toString()}`;
}
/**
* @deprecated You can now pass action functions
* directly to the `action` attribute on a form.
*
* Example: `<form action={actions.like} />`
*/
export function getActionProps<T extends (args: FormData) => MaybePromise<unknown>>(action: T) {
const params = new URLSearchParams(action.toString());
const actionName = params.get('_astroAction');
if (!actionName) {
// No need for AstroError. `getActionProps()` will be removed for stable.
throw new Error('Invalid actions function was passed to getActionProps()');
}
return {
type: 'hidden',
name: '_astroAction',
value: action.toString(),
value: actionName,
} as const;
}

View file

@ -1617,6 +1617,36 @@ export const ActionsWithoutServerOutputError = {
hint: 'Learn about on-demand rendering: https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered',
} satisfies ErrorData;
/**
* @docs
* @see
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
* @description
* 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.
*/
export const ActionsUsedWithForGetError = {
name: 'ActionsUsedWithForGetError',
title: 'An invalid Action query string was passed by a form.',
message: (actionName: string) =>
`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.`,
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
} satisfies ErrorData;
/**
* @docs
* @see
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
* @description
* 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.
*/
export const ActionQueryStringInvalidError = {
name: 'ActionQueryStringInvalidError',
title: 'An invalid Action query string was passed by a form.',
message: (actionName: string) =>
`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.`,
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
} satisfies ErrorData;
/**
* @docs
* @see

View file

@ -1,36 +1,39 @@
import { ActionError, callSafely } from 'astro:actions';
import { ActionError, callSafely, getActionQueryString } from 'astro:actions';
function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
function toActionProxy(actionCallback = {}, aggregatedPath = '') {
return new Proxy(actionCallback, {
get(target, objKey) {
if (objKey in target) {
if (objKey in target || typeof objKey === 'symbol') {
return target[objKey];
}
const path = aggregatedPath + objKey.toString();
const action = (param) => actionHandler(param, path);
action.toString = () => path;
action.toString = () => getActionQueryString(path);
action.queryString = action.toString();
action.safe = (input) => {
return callSafely(() => action(input));
};
action.safe.toString = () => path;
action.safe.toString = () => action.toString();
// Add progressive enhancement info for React.
action.$$FORM_ACTION = function () {
const data = new FormData();
data.set('_astroAction', action.toString());
return {
method: 'POST',
name: action.toString(),
data,
// `name` creates a hidden input.
// It's unused by Astro, but we can't turn this off.
// At least use a name that won't conflict with a user's formData.
name: '_astroAction',
action: action.toString(),
};
};
action.safe.$$FORM_ACTION = function () {
const data = new FormData();
data.set('_astroAction', action.toString());
data.set('_astroActionSafe', 'true');
return {
method: 'POST',
name: action.toString(),
name: '_astroAction',
action: action.toString(),
data,
};
};
@ -72,7 +75,7 @@ async function actionHandler(param, path) {
headers.set('Content-Type', 'application/json');
headers.set('Content-Length', body?.length.toString() ?? '0');
}
const res = await fetch(path, {
const res = await fetch(`/_actions/${path}`, {
method: 'POST',
body,
headers,

View file

@ -174,12 +174,10 @@ describe('Astro Actions', () => {
assert.equal(json.isFormData, true, 'Should receive plain FormData');
});
it('Respects user middleware', async () => {
const formData = new FormData();
formData.append('_astroAction', '/_actions/getUser');
const req = new Request('http://example.com/user', {
it('Response middleware fallback', async () => {
const req = new Request('http://example.com/user?_astroAction=getUser', {
method: 'POST',
body: formData,
body: new FormData(),
});
const res = await app.render(req);
assert.equal(res.ok, true);
@ -190,11 +188,9 @@ describe('Astro Actions', () => {
});
it('Respects custom errors', async () => {
const formData = new FormData();
formData.append('_astroAction', '/_actions/getUserOrThrow');
const req = new Request('http://example.com/user-or-throw', {
const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', {
method: 'POST',
body: formData,
body: new FormData(),
});
const res = await app.render(req);
assert.equal(res.ok, false);
@ -206,6 +202,40 @@ describe('Astro Actions', () => {
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
});
describe('legacy', () => {
it('Response middleware fallback', async () => {
const formData = new FormData();
formData.append('_astroAction', 'getUser');
const req = new Request('http://example.com/user', {
method: 'POST',
body: formData,
});
const res = await app.render(req);
assert.equal(res.ok, true);
const html = await res.text();
let $ = cheerio.load(html);
assert.equal($('#user').text(), 'Houston');
});
it('Respects custom errors', async () => {
const formData = new FormData();
formData.append('_astroAction', 'getUserOrThrow');
const req = new Request('http://example.com/user-or-throw', {
method: 'POST',
body: formData,
});
const res = await app.render(req);
assert.equal(res.ok, false);
assert.equal(res.status, 401);
const html = await res.text();
let $ = cheerio.load(html);
assert.equal($('#error-message').text(), 'Not logged in');
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
});
});
it('Sets status to 204 when no content', async () => {
const req = new Request('http://example.com/_actions/fireAndForget', {
method: 'POST',

View file

@ -131,6 +131,7 @@ async function getFormState({ result }) {
if (!actionResult) return undefined;
if (!isFormRequest(request.headers.get('content-type'))) return undefined;
const { searchParams } = new URL(request.url);
const formData = await request.clone().formData();
/**
* The key generated by React to identify each `useActionState()` call.
@ -142,7 +143,11 @@ async function getFormState({ result }) {
* This matches the endpoint path.
* @example "/_actions/blog.like"
*/
const actionName = formData.get('_astroAction')?.toString();
const actionName =
searchParams.get('_astroAction') ??
/* Legacy. TODO: remove for stable */ formData
.get('_astroAction')
?.toString();
if (!actionKey || !actionName) return undefined;

View file

@ -25,8 +25,9 @@ export function experimental_withState<T>(action: FormFn<T>) {
callback.$$FORM_ACTION = action.$$FORM_ACTION;
// Called by React when form state is passed from the server.
// If the action names match, React returns this state from `useActionState()`.
callback.$$IS_SIGNATURE_EQUAL = (actionName: string) => {
return action.toString() === actionName;
callback.$$IS_SIGNATURE_EQUAL = (incomingActionName: string) => {
const actionName = new URLSearchParams(action.toString()).get('_astroAction');
return actionName === incomingActionName;
};
// React calls `.bind()` internally to pass the initial state value.