Skip to content

Commit

Permalink
Actions: support React 19 useActionState() with progressive enhance…
Browse files Browse the repository at this point in the history
…ment (#11074)

* feat(ex): Like with useActionState

* feat: useActionState progressive enhancement!

* feat: getActionState utility

* chore: revert actions-blog fixture experimentation

* fix: add back actions.ts export

* feat(test): Like with use action state test

* fix: stub form state client-side to avoid hydration error

* fix: bad .safe chaining

* fix: update actionState for client call

* fix: correctly resume form state client side

* refactor: unify and document reactServerActionResult

* feat(test): useActionState assertions

* feat(docs): explain my mess

* refactor: add experimental_ prefix

* refactor: move all react internals to integration

* chore: remove unused getIslandProps

* chore: remove unused imports

* chore: undo format changes

* refactor: get actionResult from middleware directly

* refactor: remove bad result type

* fix: like button disabled timeout

* chore: changeset

* refactor: remove request cloning
  • Loading branch information
bholmesdev authored May 21, 2024
1 parent ecb0c18 commit 170722c
Show file tree
Hide file tree
Showing 15 changed files with 312 additions and 8 deletions.
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
---

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 (
<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;
}
})
}
29 changes: 27 additions & 2 deletions packages/astro/e2e/actions-react-19.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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 }) => {
Expand All @@ -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');
});
});
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
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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<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
@@ -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 (
Expand All @@ -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 (
<form action={action}>
<input type="hidden" name="postId" value={postId} />
<Button likes={likes} label={label} />
</form>
);
}

function Button({likes, label}: {likes: number; label: string}) {
const { pending } = useFormStatus();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import { db, eq, Likes } from 'astro:db';
import { Like } from '../../components/Like';
import { Like, LikeWithActionState } from '../../components/Like';
export const prerender = false;
Expand All @@ -23,13 +23,18 @@ const likesRes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).
---

<BlogPost {...post.data}>
<h2>Like</h2>
{
likesRes && (
<Like postId={post.id} likes={likesRes.likes} label="likes-client" client:load />
<Like postId={post.id} likes={likesRes.likes} label="likes-server" />
)
}

<h2>Like with action state</h2>
<LikeWithActionState postId={post.id} likes={10} label="likes-action-client" client:load />
<LikeWithActionState postId={post.id} likes={10} label="likes-action-server" />

<Content />

</BlogPost>
2 changes: 2 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3150,6 +3150,8 @@ export interface SSRResult {
): AstroGlobal;
resolve: (s: string) => Promise<string>;
response: AstroGlobal['response'];
request: AstroGlobal['request'];
actionResult?: ReturnType<AstroGlobal['getActionResult']>;
renderers: SSRLoadedRenderer[];
/**
* Map of directive name (e.g. `load`) to the directive script code
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/actions/runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { callSafely } from './virtual/shared.js';
export type Locals = {
_actionsInternal: {
getActionResult: APIContext['getActionResult'];
actionResult?: ReturnType<APIContext['getActionResult']>;
};
};

Expand Down Expand Up @@ -45,6 +46,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Cast to `any` to satisfy `getActionResult()` type.
return result as any;
},
actionResult: result,
};
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
return next();
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/actions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { APIContext } from '../@types/astro.js';
import { AstroError } from '../core/errors/errors.js';
import type { Locals } from './runtime/middleware.js';

function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
export function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
return '_actionsInternal' in locals;
}

Expand Down
9 changes: 7 additions & 2 deletions packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import type {
AstroGlobalPartial,
ComponentInstance,
MiddlewareHandler,
MiddlewareNext,
RewritePayload,
RouteData,
SSRResult,
} from '../@types/astro.js';
import type { ActionAPIContext } from '../actions/runtime/store.js';
import { createGetActionResult } from '../actions/utils.js';
import { createGetActionResult, hasActionsInternal } from '../actions/utils.js';
import {
computeCurrentLocale,
computePreferredLocale,
Expand Down Expand Up @@ -295,6 +294,10 @@ export class RenderContext {
},
} satisfies AstroGlobal['response'];

const actionResult = hasActionsInternal(this.locals)
? this.locals._actionsInternal?.actionResult
: undefined;

// Create the result object that will be passed into the renderPage function.
// This object starts here as an empty shell (not yet the result) but then
// calling the render() function will populate the object with scripts, styles, etc.
Expand All @@ -314,8 +317,10 @@ export class RenderContext {
renderers,
resolve,
response,
request: this.request,
scripts,
styles,
actionResult,
_metadata: {
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set(),
Expand Down
12 changes: 12 additions & 0 deletions packages/astro/templates/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
action.safe = (input) => {
return callSafely(() => action(input));
};
action.safe.toString = () => path;

// Add progressive enhancement info for React.
action.$$FORM_ACTION = function () {
const data = new FormData();
Expand All @@ -22,6 +24,16 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
data,
}
};
action.safe.$$FORM_ACTION = function () {
const data = new FormData();
data.set('_astroAction', action.toString());
data.set('_astroActionSafe', 'true');
return {
method: 'POST',
name: action.toString(),
data,
}
}
// recurse to construct queries for nested object paths
// ex. actions.user.admins.auth()
return toActionProxy(action, path + '.');
Expand Down
11 changes: 11 additions & 0 deletions packages/integrations/react/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,19 @@ const getOrCreateRoot = (element, creator) => {
export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
if (!element.hasAttribute('ssr')) return;

const actionKey = element.getAttribute('data-action-key');
const actionName = element.getAttribute('data-action-name');
const stringifiedActionResult = element.getAttribute('data-action-result');

const formState =
actionKey && actionName && stringifiedActionResult
? [JSON.parse(stringifiedActionResult), actionKey, actionName]
: undefined;

const renderOptions = {
identifierPrefix: element.getAttribute('prefix'),
formState,
};
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"homepage": "https://docs.astro.build/en/guides/integrations-guide/react/",
"exports": {
".": "./dist/index.js",
"./actions": "./dist/actions.js",
"./client.js": "./client.js",
"./client-v17.js": "./client-v17.js",
"./server.js": "./server.js",
Expand Down
Loading

0 comments on commit 170722c

Please sign in to comment.