summaryrefslogtreecommitdiff
path: root/packages/integrations/react/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/react/src')
-rw-r--r--packages/integrations/react/src/actions.ts104
-rw-r--r--packages/integrations/react/src/index.ts153
-rw-r--r--packages/integrations/react/src/version.ts31
3 files changed, 288 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..06ecd4148
--- /dev/null
+++ b/packages/integrations/react/src/actions.ts
@@ -0,0 +1,104 @@
+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 = (incomingActionName: string) => {
+ const actionName = new URLSearchParams(action.toString()).get('_action');
+ return actionName === incomingActionName;
+ };
+
+ // 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;
+}
diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts
new file mode 100644
index 000000000..9e781d203
--- /dev/null
+++ b/packages/integrations/react/src/index.ts
@@ -0,0 +1,153 @@
+import react, { type Options as ViteReactPluginOptions } from '@vitejs/plugin-react';
+import type { AstroIntegration, ContainerRenderer } from 'astro';
+import type * as vite from 'vite';
+import {
+ type ReactVersionConfig,
+ type SupportedReactVersion,
+ getReactMajorVersion,
+ isUnsupportedVersion,
+ versionsConfig,
+} from './version.js';
+
+export type ReactIntegrationOptions = Pick<
+ ViteReactPluginOptions,
+ 'include' | 'exclude' | 'babel'
+> & {
+ experimentalReactChildren?: boolean;
+ /**
+ * Disable streaming in React components
+ */
+ experimentalDisableStreaming?: boolean;
+};
+
+const FAST_REFRESH_PREAMBLE = react.preambleCode;
+
+function getRenderer(reactConfig: ReactVersionConfig) {
+ return {
+ name: '@astrojs/react',
+ clientEntrypoint: reactConfig.client,
+ serverEntrypoint: reactConfig.server,
+ };
+}
+
+function optionsPlugin({
+ experimentalReactChildren = false,
+ experimentalDisableStreaming = false,
+}: {
+ experimentalReactChildren: boolean;
+ experimentalDisableStreaming: boolean;
+}): vite.Plugin {
+ const virtualModule = 'astro:react:opts';
+ const virtualModuleId = '\0' + virtualModule;
+ return {
+ name: '@astrojs/react:opts',
+ resolveId(id) {
+ if (id === virtualModule) {
+ return virtualModuleId;
+ }
+ },
+ load(id) {
+ if (id === virtualModuleId) {
+ return {
+ code: `export default {
+ experimentalReactChildren: ${JSON.stringify(experimentalReactChildren)},
+ experimentalDisableStreaming: ${JSON.stringify(experimentalDisableStreaming)}
+ }`,
+ };
+ }
+ },
+ };
+}
+
+function getViteConfiguration(
+ {
+ include,
+ exclude,
+ babel,
+ experimentalReactChildren,
+ experimentalDisableStreaming,
+ }: ReactIntegrationOptions = {},
+ reactConfig: ReactVersionConfig,
+) {
+ return {
+ optimizeDeps: {
+ include: [reactConfig.client],
+ exclude: [reactConfig.server],
+ },
+ plugins: [
+ react({ include, exclude, babel }),
+ optionsPlugin({
+ experimentalReactChildren: !!experimentalReactChildren,
+ experimentalDisableStreaming: !!experimentalDisableStreaming,
+ }),
+ ],
+ ssr: {
+ noExternal: [
+ // These are all needed to get mui to work.
+ '@mui/material',
+ '@mui/base',
+ '@babel/runtime',
+ 'use-immer',
+ '@material-tailwind/react',
+ ],
+ },
+ };
+}
+
+export default function ({
+ include,
+ exclude,
+ babel,
+ experimentalReactChildren,
+ experimentalDisableStreaming,
+}: ReactIntegrationOptions = {}): AstroIntegration {
+ const majorVersion = getReactMajorVersion();
+ if (isUnsupportedVersion(majorVersion)) {
+ throw new Error(`Unsupported React version: ${majorVersion}.`);
+ }
+ const versionConfig = versionsConfig[majorVersion as SupportedReactVersion];
+
+ return {
+ name: '@astrojs/react',
+ hooks: {
+ 'astro:config:setup': ({ command, addRenderer, updateConfig, injectScript }) => {
+ addRenderer(getRenderer(versionConfig));
+ updateConfig({
+ vite: getViteConfiguration(
+ { include, exclude, babel, experimentalReactChildren, experimentalDisableStreaming },
+ versionConfig,
+ ),
+ });
+ if (command === 'dev') {
+ const preamble = FAST_REFRESH_PREAMBLE.replace(`__BASE__`, '/');
+ injectScript('before-hydration', preamble);
+ }
+ },
+ 'astro:config:done': ({ logger, config }) => {
+ const knownJsxRenderers = ['@astrojs/react', '@astrojs/preact', '@astrojs/solid-js'];
+ const enabledKnownJsxRenderers = config.integrations.filter((renderer) =>
+ knownJsxRenderers.includes(renderer.name),
+ );
+
+ if (enabledKnownJsxRenderers.length > 1 && !include && !exclude) {
+ logger.warn(
+ 'More than one JSX renderer is enabled. This will lead to unexpected behavior unless you set the `include` or `exclude` option. See https://docs.astro.build/en/guides/integrations-guide/react/#combining-multiple-jsx-frameworks for more information.',
+ );
+ }
+ },
+ },
+ };
+}
+
+export function getContainerRenderer(): ContainerRenderer {
+ const majorVersion = getReactMajorVersion();
+ if (isUnsupportedVersion(majorVersion)) {
+ throw new Error(`Unsupported React version: ${majorVersion}.`);
+ }
+ const versionConfig = versionsConfig[majorVersion as SupportedReactVersion];
+
+ return {
+ name: '@astrojs/react',
+ serverEntrypoint: versionConfig.server,
+ };
+}
diff --git a/packages/integrations/react/src/version.ts b/packages/integrations/react/src/version.ts
new file mode 100644
index 000000000..29118f13b
--- /dev/null
+++ b/packages/integrations/react/src/version.ts
@@ -0,0 +1,31 @@
+import { version as ReactVersion } from 'react-dom';
+
+export type SupportedReactVersion = keyof typeof versionsConfig;
+export type ReactVersionConfig = (typeof versionsConfig)[SupportedReactVersion];
+
+export function getReactMajorVersion(): number {
+ const matches = /\d+\./.exec(ReactVersion);
+ if (!matches) {
+ return NaN;
+ }
+ return Number(matches[0]);
+}
+
+export function isUnsupportedVersion(majorVersion: number) {
+ return majorVersion < 17 || majorVersion > 19 || Number.isNaN(majorVersion);
+}
+
+export const versionsConfig = {
+ 17: {
+ server: '@astrojs/react/server-v17.js',
+ client: '@astrojs/react/client-v17.js',
+ },
+ 18: {
+ server: '@astrojs/react/server.js',
+ client: '@astrojs/react/client.js',
+ },
+ 19: {
+ server: '@astrojs/react/server.js',
+ client: '@astrojs/react/client.js',
+ },
+};