diff --git a/.changeset/gentle-windows-enjoy.md b/.changeset/gentle-windows-enjoy.md new file mode 100644 index 000000000000..ac9b1ccd92e9 --- /dev/null +++ b/.changeset/gentle-windows-enjoy.md @@ -0,0 +1,46 @@ +--- +"@astrojs/react": minor +"astro": minor +--- + +Add support for [the React 19 `useActionState()` hook](https://react.dev/reference/react/useActionState) when using Astro Actions. This introduces progressive enhancement when calling an Action with the `withState()` utility. + +This example calls a `like` action that accepts a `postId` and returns the number of likes. Pass this action to the `experimental_withState()` function to apply progressive enhancement info, and apply to `useActionState()` to track the result: + +```tsx +import { actions } from 'astro:actions'; +import { experimental_withState } from '@astrojs/react/actions'; + +export function Like({ postId }: { postId: string }) { + const [state, action, pending] = useActionState( + experimental_withState(actions.like), + 0, // initial likes + ); + + return ( +
+ + +
+ ); +} +``` + +You can also access the state stored by `useActionState()` from your action `handler`. Call `experimental_getActionState()` with the API context, and optionally apply a type to the result: + +```ts +import { defineAction, z } from 'astro:actions'; +import { experimental_getActionState } from '@astrojs/react/actions'; + +export const server = { + like: defineAction({ + input: z.object({ + postId: z.string(), + }), + handler: async ({ postId }, ctx) => { + const currentLikes = experimental_getActionState(ctx); + // write to database + return currentLikes + 1; + } + }) +} diff --git a/packages/astro/e2e/actions-react-19.test.js b/packages/astro/e2e/actions-react-19.test.js index bfb57d8f9096..5ce72a419acb 100644 --- a/packages/astro/e2e/actions-react-19.test.js +++ b/packages/astro/e2e/actions-react-19.test.js @@ -9,6 +9,11 @@ test.beforeAll(async ({ astro }) => { devServer = await astro.startDevServer(); }); +test.afterEach(async ({ astro }) => { + // Force database reset between tests + await astro.editFile('./db/seed.ts', (original) => original); +}); + test.afterAll(async () => { await devServer.stop(); }); @@ -21,6 +26,7 @@ test.describe('Astro Actions - React 19', () => { await expect(likeButton).toBeVisible(); await likeButton.click(); await expect(likeButton, 'like button should be disabled when pending').toBeDisabled(); + await expect(likeButton).not.toBeDisabled({ timeout: 5000 }); }); test('Like action - server progressive enhancement', async ({ page, astro }) => { @@ -30,7 +36,26 @@ test.describe('Astro Actions - React 19', () => { await expect(likeButton, 'like button starts with 10 likes').toContainText('10'); await likeButton.click(); - // May contain "12" after the client button test. - await expect(likeButton, 'like button increments').toContainText(/11|12/); + await expect(likeButton, 'like button increments').toContainText('11'); + }); + + test('Like action - client useActionState', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/blog/first-post/')); + + const likeButton = page.getByLabel('likes-action-client'); + await expect(likeButton).toBeVisible(); + await likeButton.click(); + + await expect(likeButton, 'like button increments').toContainText('11'); + }); + + test('Like action - server useActionState progressive enhancement', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/blog/first-post/')); + + const likeButton = page.getByLabel('likes-action-server'); + await expect(likeButton, 'like button starts with 10 likes').toContainText('10'); + await likeButton.click(); + + await expect(likeButton, 'like button increments').toContainText('11'); }); }); diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts index 036cc087fdca..0588f626c8a8 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts @@ -7,7 +7,7 @@ export const server = { like: defineAction({ input: z.object({ postId: z.string() }), handler: async ({ postId }) => { - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 1000)); const { likes } = await db .update(Likes) diff --git a/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts index 70ecd04aa2cf..9cb867603add 100644 --- a/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts @@ -1,5 +1,6 @@ import { db, Likes, eq, sql } from 'astro:db'; -import { defineAction, z } from 'astro:actions'; +import { defineAction, getApiContext, z } from 'astro:actions'; +import { experimental_getActionState } from '@astrojs/react/actions'; export const server = { blog: { @@ -21,5 +22,26 @@ export const server = { return likes; }, }), + likeWithActionState: defineAction({ + accept: 'form', + input: z.object({ postId: z.string() }), + handler: async ({ postId }) => { + await new Promise((r) => setTimeout(r, 200)); + + const context = getApiContext(); + const state = await experimental_getActionState(context); + + const { likes } = await db + .update(Likes) + .set({ + likes: state + 1, + }) + .where(eq(Likes.postId, postId)) + .returning() + .get(); + + return likes; + }, + }), }, }; diff --git a/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx b/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx index 3928e9eda4a8..0d2dde00916e 100644 --- a/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx +++ b/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx @@ -1,5 +1,7 @@ import { actions } from 'astro:actions'; +import { useActionState } from 'react'; import { useFormStatus } from 'react-dom'; +import { experimental_withState } from '@astrojs/react/actions'; export function Like({ postId, label, likes }: { postId: string; label: string; likes: number }) { return ( @@ -10,6 +12,21 @@ export function Like({ postId, label, likes }: { postId: string; label: string; ); } + +export function LikeWithActionState({ postId, label, likes: initial }: { postId: string; label: string; likes: number }) { + const [likes, action] = useActionState( + experimental_withState(actions.blog.likeWithActionState), + 10, + ); + + return ( +
+ +