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:
parent
29a207ce5f
commit
ffd14b90ce
8 changed files with 69 additions and 41 deletions
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -90,7 +90,7 @@ async function renderFrameworkComponent(
|
|||
const { renderers, clientDirectives } = result;
|
||||
const metadata: AstroComponentMetadata = {
|
||||
astroStaticSlot: true,
|
||||
getActionResult: result.getActionResult,
|
||||
reactServerActions: result._metadata.reactServerActions,
|
||||
displayName,
|
||||
};
|
||||
|
||||
|
|
|
@ -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 + '.');
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue