0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-02-10 22:38:53 -05:00

feat: useActionState progressive enhancement!

This commit is contained in:
bholmesdev 2024-05-15 18:34:30 -04:00
parent 29a207ce5f
commit ffd14b90ce
8 changed files with 69 additions and 41 deletions

View file

@ -6,9 +6,8 @@ export const server = {
blog: {
like: defineAction({
accept: 'form',
input: z.object({ postId: z.string(), state: z.string().transform(t => JSON.parse(t)) }),
handler: async ({ postId, state }) => {
console.log('state', state);
input: z.object({ postId: z.string() }),
handler: async ({ postId }) => {
await new Promise((r) => setTimeout(r, 200));
const { likes } = await db

View file

@ -157,8 +157,12 @@ export interface AstroComponentMetadata {
hydrateArgs?: any;
componentUrl?: string;
componentExport?: { value: string; namespace?: boolean };
getActionResult: AstroGlobal['getActionResult'];
astroStaticSlot: true;
reactServerActions: {
actionName?: string;
actionKey?: string;
actionResult?: any;
};
}
/** The flags supported by the Astro CLI */
@ -2675,7 +2679,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.
@ -3151,7 +3155,6 @@ export interface SSRResult {
): AstroGlobal;
resolve: (s: string) => Promise<string>;
response: AstroGlobal['response'];
getActionResult: AstroGlobal['getActionResult'];
renderers: SSRLoadedRenderer[];
/**
* Map of directive name (e.g. `load`) to the directive script code
@ -3189,6 +3192,11 @@ export interface SSRMetadata {
headInTree: boolean;
extraHead: string[];
propagators: Set<AstroComponentInstance>;
reactServerActions: {
actionKey?: string;
actionName?: string;
actionResult?: any;
};
}
/* Preview server stuff */

View file

@ -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.

View file

@ -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,
@ -295,6 +295,18 @@ export class RenderContext {
},
} satisfies AstroGlobal['response'];
const reactServerActions: SSRResult['_metadata']['reactServerActions'] = {};
if (hasContentType(this.request.headers.get('Content-Type') ?? '', formContentTypes)) {
const formData = await this.request.clone().formData();
reactServerActions.actionKey = formData.get('$ACTION_KEY')?.toString();
reactServerActions.actionName = formData.get('_astroAction')?.toString();
const isUsingSafe = formData.has('_astroActionSafe');
const actionResult = createGetActionResult(this.locals)();
reactServerActions.actionResult = isUsingSafe ? actionResult : actionResult?.data;
}
// 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.
@ -314,10 +326,10 @@ export class RenderContext {
renderers,
resolve,
response,
getActionResult: createGetActionResult(this.locals),
scripts,
styles,
_metadata: {
reactServerActions,
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set(),
hasRenderedHead: false,

View file

@ -90,7 +90,7 @@ async function renderFrameworkComponent(
const { renderers, clientDirectives } = result;
const metadata: AstroComponentMetadata = {
astroStaticSlot: true,
getActionResult: result.getActionResult,
reactServerActions: result._metadata.reactServerActions,
displayName,
};

View file

@ -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();
@ -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 + '.');

View file

@ -3,7 +3,6 @@ import React from 'react';
import ReactDOM from 'react-dom/server';
import { incrementId } from './context.js';
import StaticHtml from './static-html.js';
import { GetActionResultContext } from './dist/actions.js';
const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');
@ -102,15 +101,17 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
value: newChildren,
});
}
const vnode = metadata.getActionResult
? React.createElement(GetActionResultContext.Provider, {
value: metadata.getActionResult,
children: React.createElement(Component, newProps),
})
: React.createElement(Component, newProps);
const vnode = React.createElement(Component, newProps);
const renderOptions = {
identifierPrefix: prefix,
formState: metadata.reactServerActions
? [
metadata.reactServerActions.actionResult,
metadata.reactServerActions.actionKey,
metadata.reactServerActions.actionName,
]
: undefined,
};
let html;
if ('renderToReadableStream' in ReactDOM) {

View file

@ -1,45 +1,40 @@
import { createContext, useContext } from 'react';
type FormFn<T> = (formData: FormData) => Promise<T>;
export const GetActionResultContext = createContext<
undefined | ((action: FormFn<unknown>) => unknown)
>(undefined);
export function withState<T>(action: FormFn<T>) {
const { $$FORM_ACTION } = action as any;
const getActionResult = useContext(GetActionResultContext);
console.log('getActionResult', getActionResult);
const callback = async function (state: T, formData: FormData) {
formData.set('state', JSON.stringify(state));
return action(formData);
};
Object.assign(callback, {
$$FORM_ACTION: () => {
const formActionMetadata = $$FORM_ACTION();
if (!getActionResult) return formActionMetadata;
const result = getActionResult(action);
if (!result) return formActionMetadata;
if (!('$$FORM_ACTION' in action)) return callback;
const data = formActionMetadata.data ?? new FormData();
data.set('state', JSON.stringify(result));
Object.assign(formActionMetadata, { data });
callback.$$FORM_ACTION = action.$$FORM_ACTION;
callback.$$IS_SIGNATURE_EQUAL = (actionName: string) => {
return action.toString() === actionName;
};
return formActionMetadata;
},
});
Object.defineProperty(callback, 'bind', {
value: (...args: Parameters<typeof callback>) => preserveFormActions(callback, ...args),
value: (...args: Parameters<typeof callback>) =>
injectStateIntoFormActionData(callback, ...args),
});
return callback;
}
function preserveFormActions<R extends [this: unknown, ...unknown[]]>(
function injectStateIntoFormActionData<R extends [this: unknown, state: unknown, ...unknown[]]>(
fn: (...args: R) => unknown,
...args: R
) {
const boundFn = Function.prototype.bind.call(fn, ...args);
Object.assign(boundFn, fn);
const [, state] = args;
if ('$$FORM_ACTION' in fn && typeof fn.$$FORM_ACTION === 'function') {
const metadata = fn.$$FORM_ACTION();
boundFn.$$FORM_ACTION = () => {
const data = (metadata.data as FormData) ?? new FormData();
data.set('_astroActionState', JSON.stringify(state));
metadata.data = data;
return metadata;
};
}
return boundFn;
}