From ffd14b90ce1ed681cf810610a7459475a8dc5400 Mon Sep 17 00:00:00 2001 From: bholmesdev Date: Wed, 15 May 2024 18:34:30 -0400 Subject: [PATCH] feat: useActionState progressive enhancement! --- .../actions-blog/src/actions/index.ts | 5 +-- packages/astro/src/@types/astro.ts | 14 ++++-- .../astro/src/actions/runtime/middleware.ts | 1 + packages/astro/src/core/render-context.ts | 16 ++++++- .../src/runtime/server/render/component.ts | 2 +- packages/astro/templates/actions.mjs | 12 +++++ packages/integrations/react/server.js | 15 ++++--- packages/integrations/react/src/actions.ts | 45 +++++++++---------- 8 files changed, 69 insertions(+), 41 deletions(-) diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts index 9e2ceb500a..36dc152531 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts @@ -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 diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index f74b1e9e6f..25d5a505af 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -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, TAction extends ActionClient, >( - action: TAction + action?: TAction ) => Awaited> | undefined; /** * Route parameters for this request if this is a dynamic route. @@ -3151,7 +3155,6 @@ export interface SSRResult { ): AstroGlobal; resolve: (s: string) => Promise; 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; + reactServerActions: { + actionKey?: string; + actionName?: string; + actionResult?: any; + }; } /* Preview server stuff */ diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts index fced367aec..56de19e916 100644 --- a/packages/astro/src/actions/runtime/middleware.ts +++ b/packages/astro/src/actions/runtime/middleware.ts @@ -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. diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 88ab27ecf7..1bc048ea6f 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -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, diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index 5e13165b95..3c35eeed23 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -90,7 +90,7 @@ async function renderFrameworkComponent( const { renderers, clientDirectives } = result; const metadata: AstroComponentMetadata = { astroStaticSlot: true, - getActionResult: result.getActionResult, + reactServerActions: result._metadata.reactServerActions, displayName, }; diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index 9c921fa4a7..2e3ba6a448 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -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 + '.'); diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index e4b8c989bd..216bc81f42 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -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) { diff --git a/packages/integrations/react/src/actions.ts b/packages/integrations/react/src/actions.ts index 1ce29c1fc4..b727d8c8cc 100644 --- a/packages/integrations/react/src/actions.ts +++ b/packages/integrations/react/src/actions.ts @@ -1,45 +1,40 @@ -import { createContext, useContext } from 'react'; - type FormFn = (formData: FormData) => Promise; -export const GetActionResultContext = createContext< - undefined | ((action: FormFn) => unknown) ->(undefined); - export function withState(action: FormFn) { - 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) => preserveFormActions(callback, ...args), + value: (...args: Parameters) => + injectStateIntoFormActionData(callback, ...args), }); return callback; } -function preserveFormActions( +function injectStateIntoFormActionData( 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; }