Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Actions: React 19 progressive enhancement support #11071

Merged
merged 16 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/gentle-windows-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@astrojs/react": minor
"astro": minor
---

Adds two new functions `experimental_getActionState()` and `experimental_withState()` to support [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 (
<form action={action}>
<input type="hidden" name="postId" value={postId} />
<button disabled={pending}>{state} ❤️</button>
</form>
);
}
```

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<number>(ctx);
// write to database
return currentLikes + 1;
}
})
}
18 changes: 18 additions & 0 deletions .changeset/twenty-cycles-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"astro": minor
---

Adds compatibility for Astro Actions in the React 19 beta. Actions can be passed to a `form action` prop directly, and Astro will automatically add metadata for progressive enhancement.

```tsx
import { actions } from 'astro:actions';

function Like() {
return (
<form action={actions.like}>
{/* auto-inserts hidden input for progressive enhancement */}
<button type="submit">Like</button>
</form>
)
}
```
61 changes: 61 additions & 0 deletions packages/astro/e2e/actions-react-19.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect } from '@playwright/test';
import { testFactory } from './test-utils.js';

const test = testFactory({ root: './fixtures/actions-react-19/' });

let devServer;

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();
});

test.describe('Astro Actions - React 19', () => {
test('Like action - client pending state', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/blog/first-post/'));

const likeButton = page.getByLabel('likes-client');
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 }) => {
await page.goto(astro.resolveUrl('/blog/first-post/'));

const likeButton = page.getByLabel('likes-server');
await expect(likeButton, 'like button starts with 10 likes').toContainText('10');
await likeButton.click();

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');
});
});
2 changes: 1 addition & 1 deletion packages/astro/e2e/fixtures/actions-blog/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@e2e/astro-actions-basics",
"name": "@e2e/actions-blog",
"type": "module",
"version": "0.0.1",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions packages/astro/e2e/fixtures/actions-react-19/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from 'astro/config';
import db from '@astrojs/db';
import react from '@astrojs/react';
import node from '@astrojs/node';

// https://astro.build/config
export default defineConfig({
site: 'https://example.com',
integrations: [db(), react()],
output: 'hybrid',
adapter: node({
mode: 'standalone',
}),
experimental: {
actions: true,
},
});
21 changes: 21 additions & 0 deletions packages/astro/e2e/fixtures/actions-react-19/db/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { column, defineDb, defineTable } from "astro:db";

const Comment = defineTable({
columns: {
postId: column.text(),
author: column.text(),
body: column.text(),
},
});

const Likes = defineTable({
columns: {
postId: column.text(),
likes: column.number(),
},
});

// https://astro.build/db/config
export default defineDb({
tables: { Comment, Likes },
});
15 changes: 15 additions & 0 deletions packages/astro/e2e/fixtures/actions-react-19/db/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { db, Likes, Comment } from "astro:db";

// https://astro.build/db/seed
export default async function seed() {
await db.insert(Likes).values({
postId: "first-post.md",
likes: 10,
});

await db.insert(Comment).values({
postId: "first-post.md",
author: "Alice",
body: "Great post!",
});
}
28 changes: 28 additions & 0 deletions packages/astro/e2e/fixtures/actions-react-19/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@e2e/actions-react-19",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.6.0",
"@astrojs/db": "workspace:*",
"@astrojs/node": "workspace:*",
"@astrojs/react": "workspace:*",
"@types/react": "npm:types-react",
"@types/react-dom": "npm:types-react-dom",
"astro": "workspace:*",
"react": "19.0.0-beta-26f2496093-20240514",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"typescript": "^5.4.5"
},
"overrides": {
"@types/react": "npm:types-react",
"@types/react-dom": "npm:types-react-dom"
}
}
47 changes: 47 additions & 0 deletions packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { db, Likes, eq, sql } from 'astro:db';
import { defineAction, getApiContext, z } from 'astro:actions';
import { experimental_getActionState } from '@astrojs/react/actions';

export const server = {
blog: {
like: defineAction({
accept: 'form',
input: z.object({ postId: z.string() }),
handler: async ({ postId }) => {
await new Promise((r) => setTimeout(r, 1000));

const { likes } = await db
.update(Likes)
.set({
likes: sql`likes + 1`,
})
.where(eq(Likes.postId, postId))
.returning()
.get();

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<number>(context);

const { likes } = await db
.update(Likes)
.set({
likes: state + 1,
})
.where(eq(Likes.postId, postId))
.returning()
.get();

return likes;
},
}),
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of the <BaseHead /> component.
import '../styles/global.css';

interface Props {
title: string;
description: string;
image?: string;
}

const canonicalURL = new URL(Astro.url.pathname, Astro.site);

const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
---

<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />

<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />

<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />

<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
const today = new Date();
---

<footer>
&copy; {today.getFullYear()} Your name here. All rights reserved.
<div class="social-links">
<a href="https://m.webtoo.ls/@astro" target="_blank">
<span class="sr-only">Follow Astro on Mastodon</span>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
width="32"
height="32"
astro-icon="social/mastodon"
><path
fill="currentColor"
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
></path></svg
>
</a>
<a href="https://twitter.com/astrodotbuild" target="_blank">
<span class="sr-only">Follow Astro on Twitter</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/twitter"
><path
fill="currentColor"
d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"
></path></svg
>
</a>
<a href="https://github.com/withastro/astro" target="_blank">
<span class="sr-only">Go to Astro's GitHub repo</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/github"
><path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
></path></svg
>
</a>
</div>
</footer>
<style>
footer {
padding: 2em 1em 6em 1em;
background: linear-gradient(var(--gray-gradient)) no-repeat;
color: rgb(var(--gray));
text-align: center;
}
.social-links {
display: flex;
justify-content: center;
gap: 1em;
margin-top: 1em;
}
.social-links a {
text-decoration: none;
color: rgb(var(--gray));
}
.social-links a:hover {
color: rgb(var(--gray-dark));
}
</style>
Loading
Loading