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: {
|
blog: {
|
||||||
like: defineAction({
|
like: defineAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
input: z.object({ postId: z.string(), state: z.string().transform(t => JSON.parse(t)) }),
|
input: z.object({ postId: z.string() }),
|
||||||
handler: async ({ postId, state }) => {
|
handler: async ({ postId }) => {
|
||||||
console.log('state', state);
|
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
const { likes } = await db
|
const { likes } = await db
|
||||||
|
|
|
@ -157,8 +157,12 @@ export interface AstroComponentMetadata {
|
||||||
hydrateArgs?: any;
|
hydrateArgs?: any;
|
||||||
componentUrl?: string;
|
componentUrl?: string;
|
||||||
componentExport?: { value: string; namespace?: boolean };
|
componentExport?: { value: string; namespace?: boolean };
|
||||||
getActionResult: AstroGlobal['getActionResult'];
|
|
||||||
astroStaticSlot: true;
|
astroStaticSlot: true;
|
||||||
|
reactServerActions: {
|
||||||
|
actionName?: string;
|
||||||
|
actionKey?: string;
|
||||||
|
actionResult?: any;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The flags supported by the Astro CLI */
|
/** The flags supported by the Astro CLI */
|
||||||
|
@ -2675,7 +2679,7 @@ interface AstroSharedContext<
|
||||||
TInputSchema extends InputSchema<TAccept>,
|
TInputSchema extends InputSchema<TAccept>,
|
||||||
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
|
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
|
||||||
>(
|
>(
|
||||||
action: TAction
|
action?: TAction
|
||||||
) => Awaited<ReturnType<TAction['safe']>> | undefined;
|
) => Awaited<ReturnType<TAction['safe']>> | undefined;
|
||||||
/**
|
/**
|
||||||
* Route parameters for this request if this is a dynamic route.
|
* Route parameters for this request if this is a dynamic route.
|
||||||
|
@ -3151,7 +3155,6 @@ export interface SSRResult {
|
||||||
): AstroGlobal;
|
): AstroGlobal;
|
||||||
resolve: (s: string) => Promise<string>;
|
resolve: (s: string) => Promise<string>;
|
||||||
response: AstroGlobal['response'];
|
response: AstroGlobal['response'];
|
||||||
getActionResult: AstroGlobal['getActionResult'];
|
|
||||||
renderers: SSRLoadedRenderer[];
|
renderers: SSRLoadedRenderer[];
|
||||||
/**
|
/**
|
||||||
* Map of directive name (e.g. `load`) to the directive script code
|
* Map of directive name (e.g. `load`) to the directive script code
|
||||||
|
@ -3189,6 +3192,11 @@ export interface SSRMetadata {
|
||||||
headInTree: boolean;
|
headInTree: boolean;
|
||||||
extraHead: string[];
|
extraHead: string[];
|
||||||
propagators: Set<AstroComponentInstance>;
|
propagators: Set<AstroComponentInstance>;
|
||||||
|
reactServerActions: {
|
||||||
|
actionKey?: string;
|
||||||
|
actionName?: string;
|
||||||
|
actionResult?: any;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preview server stuff */
|
/* Preview server stuff */
|
||||||
|
|
|
@ -40,6 +40,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
|
|
||||||
const actionsInternal: Locals['_actionsInternal'] = {
|
const actionsInternal: Locals['_actionsInternal'] = {
|
||||||
getActionResult: (actionFn) => {
|
getActionResult: (actionFn) => {
|
||||||
|
if (!actionFn) return result;
|
||||||
if (actionFn.toString() !== actionPath) return Promise.resolve(undefined);
|
if (actionFn.toString() !== actionPath) return Promise.resolve(undefined);
|
||||||
// The `action` uses type `unknown` since we can't infer the user's action type.
|
// The `action` uses type `unknown` since we can't infer the user's action type.
|
||||||
// Cast to `any` to satisfy `getActionResult()` type.
|
// Cast to `any` to satisfy `getActionResult()` type.
|
||||||
|
|
|
@ -4,13 +4,13 @@ import type {
|
||||||
AstroGlobalPartial,
|
AstroGlobalPartial,
|
||||||
ComponentInstance,
|
ComponentInstance,
|
||||||
MiddlewareHandler,
|
MiddlewareHandler,
|
||||||
MiddlewareNext,
|
|
||||||
RewritePayload,
|
RewritePayload,
|
||||||
RouteData,
|
RouteData,
|
||||||
SSRResult,
|
SSRResult,
|
||||||
} from '../@types/astro.js';
|
} from '../@types/astro.js';
|
||||||
import type { ActionAPIContext } from '../actions/runtime/store.js';
|
import type { ActionAPIContext } from '../actions/runtime/store.js';
|
||||||
import { createGetActionResult } from '../actions/utils.js';
|
import { createGetActionResult } from '../actions/utils.js';
|
||||||
|
import { hasContentType, formContentTypes } from '../actions/runtime/utils.js';
|
||||||
import {
|
import {
|
||||||
computeCurrentLocale,
|
computeCurrentLocale,
|
||||||
computePreferredLocale,
|
computePreferredLocale,
|
||||||
|
@ -295,6 +295,18 @@ export class RenderContext {
|
||||||
},
|
},
|
||||||
} satisfies AstroGlobal['response'];
|
} 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.
|
// 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
|
// 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.
|
// calling the render() function will populate the object with scripts, styles, etc.
|
||||||
|
@ -314,10 +326,10 @@ export class RenderContext {
|
||||||
renderers,
|
renderers,
|
||||||
resolve,
|
resolve,
|
||||||
response,
|
response,
|
||||||
getActionResult: createGetActionResult(this.locals),
|
|
||||||
scripts,
|
scripts,
|
||||||
styles,
|
styles,
|
||||||
_metadata: {
|
_metadata: {
|
||||||
|
reactServerActions,
|
||||||
hasHydrationScript: false,
|
hasHydrationScript: false,
|
||||||
rendererSpecificHydrationScripts: new Set(),
|
rendererSpecificHydrationScripts: new Set(),
|
||||||
hasRenderedHead: false,
|
hasRenderedHead: false,
|
||||||
|
|
|
@ -90,7 +90,7 @@ async function renderFrameworkComponent(
|
||||||
const { renderers, clientDirectives } = result;
|
const { renderers, clientDirectives } = result;
|
||||||
const metadata: AstroComponentMetadata = {
|
const metadata: AstroComponentMetadata = {
|
||||||
astroStaticSlot: true,
|
astroStaticSlot: true,
|
||||||
getActionResult: result.getActionResult,
|
reactServerActions: result._metadata.reactServerActions,
|
||||||
displayName,
|
displayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,8 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
|
||||||
action.safe = (input) => {
|
action.safe = (input) => {
|
||||||
return callSafely(() => action(input));
|
return callSafely(() => action(input));
|
||||||
};
|
};
|
||||||
|
action.safe.toString = () => path;
|
||||||
|
|
||||||
// Add progressive enhancement info for React.
|
// Add progressive enhancement info for React.
|
||||||
action.$$FORM_ACTION = function () {
|
action.$$FORM_ACTION = function () {
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
|
@ -22,6 +24,16 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
|
||||||
data,
|
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
|
// recurse to construct queries for nested object paths
|
||||||
// ex. actions.user.admins.auth()
|
// ex. actions.user.admins.auth()
|
||||||
return toActionProxy(action, path + '.');
|
return toActionProxy(action, path + '.');
|
||||||
|
|
|
@ -3,7 +3,6 @@ import React from 'react';
|
||||||
import ReactDOM from 'react-dom/server';
|
import ReactDOM from 'react-dom/server';
|
||||||
import { incrementId } from './context.js';
|
import { incrementId } from './context.js';
|
||||||
import StaticHtml from './static-html.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 slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
|
||||||
const reactTypeof = Symbol.for('react.element');
|
const reactTypeof = Symbol.for('react.element');
|
||||||
|
@ -102,15 +101,17 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
|
||||||
value: newChildren,
|
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 = {
|
const renderOptions = {
|
||||||
identifierPrefix: prefix,
|
identifierPrefix: prefix,
|
||||||
|
formState: metadata.reactServerActions
|
||||||
|
? [
|
||||||
|
metadata.reactServerActions.actionResult,
|
||||||
|
metadata.reactServerActions.actionKey,
|
||||||
|
metadata.reactServerActions.actionName,
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
let html;
|
let html;
|
||||||
if ('renderToReadableStream' in ReactDOM) {
|
if ('renderToReadableStream' in ReactDOM) {
|
||||||
|
|
|
@ -1,45 +1,40 @@
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
type FormFn<T> = (formData: FormData) => Promise<T>;
|
type FormFn<T> = (formData: FormData) => Promise<T>;
|
||||||
|
|
||||||
export const GetActionResultContext = createContext<
|
|
||||||
undefined | ((action: FormFn<unknown>) => unknown)
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export function withState<T>(action: FormFn<T>) {
|
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) {
|
const callback = async function (state: T, formData: FormData) {
|
||||||
formData.set('state', JSON.stringify(state));
|
|
||||||
return action(formData);
|
return action(formData);
|
||||||
};
|
};
|
||||||
Object.assign(callback, {
|
if (!('$$FORM_ACTION' in action)) return callback;
|
||||||
$$FORM_ACTION: () => {
|
|
||||||
const formActionMetadata = $$FORM_ACTION();
|
|
||||||
if (!getActionResult) return formActionMetadata;
|
|
||||||
const result = getActionResult(action);
|
|
||||||
if (!result) return formActionMetadata;
|
|
||||||
|
|
||||||
const data = formActionMetadata.data ?? new FormData();
|
callback.$$FORM_ACTION = action.$$FORM_ACTION;
|
||||||
data.set('state', JSON.stringify(result));
|
callback.$$IS_SIGNATURE_EQUAL = (actionName: string) => {
|
||||||
Object.assign(formActionMetadata, { data });
|
return action.toString() === actionName;
|
||||||
|
};
|
||||||
|
|
||||||
return formActionMetadata;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(callback, 'bind', {
|
Object.defineProperty(callback, 'bind', {
|
||||||
value: (...args: Parameters<typeof callback>) => preserveFormActions(callback, ...args),
|
value: (...args: Parameters<typeof callback>) =>
|
||||||
|
injectStateIntoFormActionData(callback, ...args),
|
||||||
});
|
});
|
||||||
return callback;
|
return callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function preserveFormActions<R extends [this: unknown, ...unknown[]]>(
|
function injectStateIntoFormActionData<R extends [this: unknown, state: unknown, ...unknown[]]>(
|
||||||
fn: (...args: R) => unknown,
|
fn: (...args: R) => unknown,
|
||||||
...args: R
|
...args: R
|
||||||
) {
|
) {
|
||||||
const boundFn = Function.prototype.bind.call(fn, ...args);
|
const boundFn = Function.prototype.bind.call(fn, ...args);
|
||||||
Object.assign(boundFn, fn);
|
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;
|
return boundFn;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue