diff options
author | 2024-05-22 08:24:55 -0400 | |
---|---|---|
committer | 2024-05-22 13:24:55 +0100 | |
commit | 8ca7c731dea894e77f84b314ebe3a141d5daa918 (patch) | |
tree | e7a89e1fa92436e3d56a11ace13f43ef7b8f7bfe /packages/integrations/react/src | |
parent | 3dd57f69e344e4bd1bd9da28a9391c4d62246f7d (diff) | |
download | astro-8ca7c731dea894e77f84b314ebe3a141d5daa918.tar.gz astro-8ca7c731dea894e77f84b314ebe3a141d5daa918.tar.zst astro-8ca7c731dea894e77f84b314ebe3a141d5daa918.zip |
Actions: React 19 progressive enhancement support (#11071)
* deps: react 19
* feat: react progressive enhancement with useActionState
* refactor: revert old action state implementation
* feat(test): react 19 action with useFormStatus
* fix: remove unused context arg
* fix: wrote actions to wrong test fixture!
* deps: revert react 19 beta to 18 for actions-blog fixture
* chore: remove unused overrides
* chore: remove unused actions export
* chore: spaces vs tabs ugh
* chore: fix conflicting fixture names
* chore: changeset
* chore: bump changeset to minor
* Actions: support React 19 `useActionState()` with progressive enhancement (#11074)
* feat(ex): Like with useActionState
* feat: useActionState progressive enhancement!
* feat: getActionState utility
* chore: revert actions-blog fixture experimentation
* fix: add back actions.ts export
* feat(test): Like with use action state test
* fix: stub form state client-side to avoid hydration error
* fix: bad .safe chaining
* fix: update actionState for client call
* fix: correctly resume form state client side
* refactor: unify and document reactServerActionResult
* feat(test): useActionState assertions
* feat(docs): explain my mess
* refactor: add experimental_ prefix
* refactor: move all react internals to integration
* chore: remove unused getIslandProps
* chore: remove unused imports
* chore: undo format changes
* refactor: get actionResult from middleware directly
* refactor: remove bad result type
* fix: like button disabled timeout
* chore: changeset
* refactor: remove request cloning
* Update .changeset/gentle-windows-enjoy.md
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* changeset grammar tense
---------
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Diffstat (limited to 'packages/integrations/react/src')
-rw-r--r-- | packages/integrations/react/src/actions.ts | 101 |
1 files changed, 101 insertions, 0 deletions
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; +} |