diff options
Diffstat (limited to 'packages/integrations/react')
-rw-r--r-- | packages/integrations/react/client.js | 11 | ||||
-rw-r--r-- | packages/integrations/react/package.json | 1 | ||||
-rw-r--r-- | packages/integrations/react/server.js | 55 | ||||
-rw-r--r-- | packages/integrations/react/src/actions.ts | 101 |
4 files changed, 168 insertions, 0 deletions
diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js index 76f9ead00..13515df40 100644 --- a/packages/integrations/react/client.js +++ b/packages/integrations/react/client.js @@ -67,8 +67,19 @@ const getOrCreateRoot = (element, creator) => { export default (element) => (Component, props, { default: children, ...slotted }, { client }) => { if (!element.hasAttribute('ssr')) return; + + const actionKey = element.getAttribute('data-action-key'); + const actionName = element.getAttribute('data-action-name'); + const stringifiedActionResult = element.getAttribute('data-action-result'); + + const formState = + actionKey && actionName && stringifiedActionResult + ? [JSON.parse(stringifiedActionResult), actionKey, actionName] + : undefined; + const renderOptions = { identifierPrefix: element.getAttribute('prefix'), + formState, }; for (const [key, value] of Object.entries(slotted)) { props[key] = createElement(StaticHtml, { value, name: key }); diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 7e08f03c0..373e8f848 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -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", diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index 2ff3f55fb..a0b2db744 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -1,6 +1,7 @@ import opts from 'astro:react:opts'; import React from 'react'; import ReactDOM from 'react-dom/server'; +import { AstroError } from 'astro/errors'; import { incrementId } from './context.js'; import StaticHtml from './static-html.js'; @@ -101,9 +102,16 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl value: newChildren, }); } + const formState = this ? await getFormState(this) : undefined; + if (formState) { + attrs['data-action-result'] = JSON.stringify(formState[0]); + attrs['data-action-key'] = formState[1]; + attrs['data-action-name'] = formState[2]; + } const vnode = React.createElement(Component, newProps); const renderOptions = { identifierPrefix: prefix, + formState, }; let html; if ('renderToReadableStream' in ReactDOM) { @@ -114,6 +122,43 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl return { html, attrs }; } +/** + * @returns {Promise<[actionResult: any, actionKey: string, actionName: string] | undefined>} + */ +async function getFormState({ result }) { + const { request, actionResult } = result; + + if (!actionResult) return undefined; + if (!isFormRequest(request.headers.get('content-type'))) return undefined; + + const formData = await request.clone().formData(); + /** + * The key generated by React to identify each `useActionState()` call. + * @example "k511f74df5a35d32e7cf266450d85cb6c" + */ + const actionKey = formData.get('$ACTION_KEY')?.toString(); + /** + * The action name returned by an action's `toString()` property. + * This matches the endpoint path. + * @example "/_actions/blog.like" + */ + const actionName = formData.get('_astroAction')?.toString(); + + if (!actionKey || !actionName) return undefined; + + const isUsingSafe = formData.has('_astroActionSafe'); + if (!isUsingSafe && actionResult.error) { + throw new AstroError( + `Unhandled error calling action ${actionName.replace(/^\/_actions\//, '')}:\n[${ + actionResult.error.code + }] ${actionResult.error.message}`, + 'use `.safe()` to handle from your React component.' + ); + } + + return [isUsingSafe ? actionResult : actionResult.data, actionKey, actionName]; +} + async function renderToPipeableStreamAsync(vnode, options) { const Writable = await getNodeWritable(); let html = ''; @@ -170,6 +215,16 @@ async function renderToReadableStreamAsync(vnode, options) { return await readResult(await ReactDOM.renderToReadableStream(vnode, options)); } +const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data']; + +function isFormRequest(contentType) { + // Split off parameters like charset or boundary + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms + const type = contentType?.split(';')[0].toLowerCase(); + + return formContentTypes.some((t) => type === t); +} + export default { check, renderToStaticMarkup, diff --git a/packages/integrations/react/src/actions.ts b/packages/integrations/react/src/actions.ts new file mode 100644 index 000000000..336d32220 --- /dev/null +++ b/packages/integrations/react/src/actions.ts @@ -0,0 +1,101 @@ +import { AstroError } from 'astro/errors'; + +type FormFn<T> = (formData: FormData) => Promise<T>; + +/** + * Use an Astro Action with React `useActionState()`. + * This function matches your action to the expected types, + * and preserves metadata for progressive enhancement. + * To read state from your action handler, use {@linkcode experimental_getActionState}. + */ +export function experimental_withState<T>(action: FormFn<T>) { + // React expects two positional arguments when using `useActionState()`: + // 1. The initial state value. + // 2. The form data object. + + // Map this first argument to a hidden input + // for retrieval from `getActionState()`. + const callback = async function (state: T, formData: FormData) { + formData.set('_astroActionState', JSON.stringify(state)); + return action(formData); + }; + if (!('$$FORM_ACTION' in action)) return callback; + + // Re-bind progressive enhancement info for React. + callback.$$FORM_ACTION = action.$$FORM_ACTION; + // Called by React when form state is passed from the server. + // If the action names match, React returns this state from `useActionState()`. + callback.$$IS_SIGNATURE_EQUAL = (actionName: string) => { + return action.toString() === actionName; + }; + + // React calls `.bind()` internally to pass the initial state value. + // Calling `.bind()` seems to remove our `$$FORM_ACTION` metadata, + // so we need to define our *own* `.bind()` method to preserve that metadata. + Object.defineProperty(callback, 'bind', { + value: (...args: Parameters<typeof callback>) => + injectStateIntoFormActionData(callback, ...args), + }); + return callback; +} + +/** + * Retrieve the state object from your action handler when using `useActionState()`. + * To ensure this state is retrievable, use the {@linkcode experimental_withState} helper. + */ +export async function experimental_getActionState<T>({ + request, +}: { request: Request }): Promise<T> { + const contentType = request.headers.get('Content-Type'); + if (!contentType || !isFormRequest(contentType)) { + throw new AstroError( + '`getActionState()` must be called with a form request.', + "Ensure your action uses the `accept: 'form'` option." + ); + } + const formData = await request.clone().formData(); + const state = formData.get('_astroActionState')?.toString(); + if (!state) { + throw new AstroError( + '`getActionState()` could not find a state object.', + 'Ensure your action was passed to `useActionState()` with the `experimental_withState()` wrapper.' + ); + } + return JSON.parse(state) as T; +} + +const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data']; + +function isFormRequest(contentType: string) { + // Split off parameters like charset or boundary + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms + const type = contentType.split(';')[0].toLowerCase(); + + return formContentTypes.some((t) => type === t); +} + +/** + * Override the default `.bind()` method to: + * 1. Inject the form state into the form data for progressive enhancement. + * 2. Preserve the `$$FORM_ACTION` metadata. + */ +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; +} |