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: support React 19 useActionState() with progressive enhancement #11074

Merged
merged 23 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0f45b56
feat(ex): Like with useActionState
bholmesdev May 14, 2024
4493b37
feat: useActionState progressive enhancement!
bholmesdev May 15, 2024
3787410
feat: getActionState utility
bholmesdev May 16, 2024
7dc8276
chore: revert actions-blog fixture experimentation
bholmesdev May 16, 2024
502b164
fix: add back actions.ts export
bholmesdev May 16, 2024
19939db
feat(test): Like with use action state test
bholmesdev May 16, 2024
c4c5918
fix: stub form state client-side to avoid hydration error
bholmesdev May 16, 2024
1c0a3fa
fix: bad .safe chaining
bholmesdev May 16, 2024
b3488ed
fix: update actionState for client call
bholmesdev May 16, 2024
cbb3f47
fix: correctly resume form state client side
bholmesdev May 16, 2024
e56655c
refactor: unify and document reactServerActionResult
bholmesdev May 16, 2024
24c76d1
feat(test): useActionState assertions
bholmesdev May 16, 2024
9ac35f4
feat(docs): explain my mess
bholmesdev May 16, 2024
9063732
refactor: add experimental_ prefix
bholmesdev May 16, 2024
1ed6e54
refactor: move all react internals to integration
bholmesdev May 20, 2024
abe868d
chore: remove unused getIslandProps
bholmesdev May 20, 2024
8ceea58
chore: remove unused imports
bholmesdev May 20, 2024
35eb988
chore: undo format changes
bholmesdev May 20, 2024
18c7669
refactor: get actionResult from middleware directly
bholmesdev May 20, 2024
f8fab7d
refactor: remove bad result type
bholmesdev May 20, 2024
eaeec9a
fix: like button disabled timeout
bholmesdev May 20, 2024
0bfff69
chore: changeset
bholmesdev May 20, 2024
93480e7
refactor: remove request cloning
bholmesdev May 21, 2024
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
---

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 () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is very small and probably can't be moved out. So I don't see any issue with having this part.

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
Loading