summaryrefslogtreecommitdiff
path: root/packages/integrations/react/src
diff options
context:
space:
mode:
authorGravatar Ben Holmes <hey@bholmes.dev> 2024-05-22 08:24:55 -0400
committerGravatar GitHub <noreply@github.com> 2024-05-22 13:24:55 +0100
commit8ca7c731dea894e77f84b314ebe3a141d5daa918 (patch)
treee7a89e1fa92436e3d56a11ace13f43ef7b8f7bfe /packages/integrations/react/src
parent3dd57f69e344e4bd1bd9da28a9391c4d62246f7d (diff)
downloadastro-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.ts101
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;
+}