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

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>
22 changes: 21 additions & 1 deletion packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,25 @@ export interface AstroComponentMetadata {
componentUrl?: string;
componentExport?: { value: string; namespace?: boolean };
astroStaticSlot: true;
/**
* Store the action result for use with React server actions.
* This enables progressive enhancement when using React's `useActionState()`.
*/
reactServerActionResult?: {
/** The value returned by the action. */
value: any;
/**
* The action name returned by an action's `toString()` property.
* This matches the endpoint path.
* @example "/_actions/blog.like"
*/
name: string;
/**
* The key generated by React to identify each `useActionState()` call.
* @example "k511f74df5a35d32e7cf266450d85cb6c"
*/
key: string;
};
}

/** The flags supported by the Astro CLI */
Expand Down Expand Up @@ -2674,7 +2693,7 @@ interface AstroSharedContext<
TInputSchema extends InputSchema<TAccept>,
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
>(
action: TAction
action?: TAction
) => Awaited<ReturnType<TAction['safe']>> | undefined;
/**
* Route parameters for this request if this is a dynamic route.
Expand Down Expand Up @@ -3187,6 +3206,7 @@ export interface SSRMetadata {
headInTree: boolean;
extraHead: string[];
propagators: Set<AstroComponentInstance>;
reactServerActionResult?: AstroComponentMetadata['reactServerActionResult'];
}

/* Preview server stuff */
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/actions/runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const onRequest = defineMiddleware(async (context, next) => {

const actionsInternal: Locals['_actionsInternal'] = {
getActionResult: (actionFn) => {
if (!actionFn) return result;
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.
Expand Down
39 changes: 38 additions & 1 deletion packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ 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 { hasContentType, formContentTypes } from '../actions/runtime/utils.js';
import {
computeCurrentLocale,
computePreferredLocale,
Expand Down Expand Up @@ -276,6 +276,7 @@ export class RenderContext {

async createResult(mod: ComponentInstance) {
const { cookies, pathname, pipeline, routeData, status } = this;
const reactServerActionResult = await getReactServerActionResult(this);
const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } =
pipeline;
const { links, scripts, styles } = await pipeline.headElements(routeData);
Expand Down Expand Up @@ -317,6 +318,7 @@ export class RenderContext {
scripts,
styles,
_metadata: {
reactServerActionResult,
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set(),
hasRenderedHead: false,
Expand Down Expand Up @@ -521,3 +523,38 @@ export class RenderContext {
return (this.#preferredLocaleList ??= computePreferredLocaleList(request, i18n.locales));
}
}

async function getReactServerActionResult({
request,
locals,
}: { request: Request; locals: APIContext['locals'] }): Promise<
SSRResult['_metadata']['reactServerActionResult']
> {
if (!hasContentType(request.headers.get('Content-Type') ?? '', formContentTypes)) {
return;
}

const formData = await request.clone().formData();
const actionKey = formData.get('$ACTION_KEY')?.toString();
const actionName = formData.get('_astroAction')?.toString();
const actionResult = createGetActionResult(locals)();
if (!actionKey || !actionName || !actionResult) return;

const isUsingSafe = formData.has('_astroActionSafe');
if (!isUsingSafe && actionResult.error) {
throw new AstroError({
name: actionResult.error.name,
message: `Unhandled error calling action ${actionName.replace(/^\/_actions\//, '')}:\n${
actionResult.error.message
}`,
stack: actionResult.error.stack,
hint: 'use `.safe()` to handle from your React component.',
});
}

return {
value: isUsingSafe ? actionResult : actionResult.data,
key: actionKey,
name: actionName,
};
}
16 changes: 16 additions & 0 deletions packages/astro/src/runtime/server/astro-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Do not import this file directly, instead import the prebuilt one instead.
// pnpm --filter astro run prebuild

import type { AstroComponentMetadata } from '../../@types/astro.js';

type directiveAstroKeys = 'load' | 'idle' | 'visible' | 'media' | 'only';

declare const Astro: {
Expand Down Expand Up @@ -189,8 +191,10 @@ declare const Astro: {
let hydrationTimeStart;
const hydrator = this.hydrator(this);
if (process.env.NODE_ENV === 'development') hydrationTimeStart = performance.now();

await hydrator(this.Component, props, slots, {
client: this.getAttribute('client'),
reactServerActionResult: parseReactServerActionResult(this),
});
if (process.env.NODE_ENV === 'development' && hydrationTimeStart)
this.setAttribute(
Expand All @@ -215,3 +219,15 @@ declare const Astro: {
customElements.define('astro-island', AstroIsland);
}
}

function parseReactServerActionResult(
element: HTMLElement
): AstroComponentMetadata['reactServerActionResult'] {
const result = element.getAttribute('action-result');
const key = element.getAttribute('action-key');
const name = element.getAttribute('action-name');
if (!result || !key || !name) return undefined;

const value = JSON.parse(result);
return { key, name, value };
}
9 changes: 9 additions & 0 deletions packages/astro/src/runtime/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ export async function generateHydrateScript(

island.props['ssr'] = '';
island.props['client'] = hydrate;

if (renderer.name === '@astrojs/react' && metadata.reactServerActionResult) {
const { key, name, value } = metadata.reactServerActionResult;

island.props['action-result'] = JSON.stringify(value);
island.props['action-key'] = key;
island.props['action-name'] = name;
}

let beforeHydrationUrl = await result.resolve('astro:scripts/before-hydration.js');
if (beforeHydrationUrl.length) {
island.props['before-hydration-url'] = beforeHydrationUrl;
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/runtime/server/render/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async function renderFrameworkComponent(
const { renderers, clientDirectives } = result;
const metadata: AstroComponentMetadata = {
astroStaticSlot: true,
reactServerActionResult: result._metadata.reactServerActionResult,
displayName,
};

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
5 changes: 4 additions & 1 deletion packages/integrations/react/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ const getOrCreateRoot = (element, creator) => {
};

export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
(Component, props, { default: children, ...slotted }, { client, reactServerActionResult }) => {
if (!element.hasAttribute('ssr')) return;
const renderOptions = {
identifierPrefix: element.getAttribute('prefix'),
formState: reactServerActionResult
? [reactServerActionResult.value, reactServerActionResult.key, reactServerActionResult.name]
: undefined,
};
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
7 changes: 7 additions & 0 deletions packages/integrations/react/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
const vnode = React.createElement(Component, newProps);
const renderOptions = {
identifierPrefix: prefix,
formState: metadata.reactServerActionResult
? [
metadata.reactServerActionResult.value,
metadata.reactServerActionResult.key,
metadata.reactServerActionResult.name,
]
: undefined,
};
let html;
if ('renderToReadableStream' in ReactDOM) {
Expand Down
Loading
Loading