Skip to content

Commit

Permalink
Actions: New fallback behavior with action={actions.name} (#11570)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bholmesdev authored Jul 30, 2024
1 parent 1953dbb commit 84189b6
Show file tree
Hide file tree
Showing 11 changed files with 269 additions and 59 deletions.
53 changes: 53 additions & 0 deletions .changeset/silly-bulldogs-sparkle.md
Original file line number Diff line number Diff line change
@@ -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>
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
124 changes: 98 additions & 26 deletions packages/astro/src/actions/runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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');

if (context.request.method === 'POST' && actionName) {
return handlePost({ context, next, actionName });
}

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

if (!contentType || !hasContentType(contentType, formContentTypes)) {
return nextWithLocalsStub(next, context);
if (context.request.method === 'POST') {
return handlePostLegacy({ context, next });
}

const formData = await request.clone().formData();
const actionPath = formData.get('_astroAction');
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, context);
return nextWithLocalsStub(next, context);
});

async function handlePost({
context,
next,
actionName,
}: { context: APIContext; next: MiddlewareNext; actionName: string }) {
const { request } = context;

const action = await getAction(actionPath);
if (!action) return nextWithLocalsStub(next, 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))
);

const result = 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', {
Expand Down
5 changes: 3 additions & 2 deletions packages/astro/src/actions/runtime/virtual/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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)
Expand All @@ -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'>>(
Expand Down
19 changes: 18 additions & 1 deletion packages/astro/src/actions/runtime/virtual/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
30 changes: 30 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 84189b6

Please sign in to comment.