diff options
Diffstat (limited to 'packages/integrations/react/src')
-rw-r--r-- | packages/integrations/react/src/actions.ts | 104 | ||||
-rw-r--r-- | packages/integrations/react/src/index.ts | 153 | ||||
-rw-r--r-- | packages/integrations/react/src/version.ts | 31 |
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', + }, +}; |