summaryrefslogtreecommitdiff
path: root/packages/integrations/react/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/react/server.js')
-rw-r--r--packages/integrations/react/server.js213
1 files changed, 213 insertions, 0 deletions
diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js
new file mode 100644
index 000000000..67d9e9386
--- /dev/null
+++ b/packages/integrations/react/server.js
@@ -0,0 +1,213 @@
+import opts from 'astro:react:opts';
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import { incrementId } from './context.js';
+import StaticHtml from './static-html.js';
+
+const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
+const reactTypeof = Symbol.for('react.element');
+const reactTransitionalTypeof = Symbol.for('react.transitional.element');
+
+async function check(Component, props, children) {
+ // Note: there are packages that do some unholy things to create "components".
+ // Checking the $$typeof property catches most of these patterns.
+ if (typeof Component === 'object') {
+ return Component['$$typeof'].toString().slice('Symbol('.length).startsWith('react');
+ }
+ if (typeof Component !== 'function') return false;
+ if (Component.name === 'QwikComponent') return false;
+
+ // Preact forwarded-ref components can be functions, which React does not support
+ if (typeof Component === 'function' && Component['$$typeof'] === Symbol.for('react.forward_ref'))
+ return false;
+
+ if (Component.prototype != null && typeof Component.prototype.render === 'function') {
+ return React.Component.isPrototypeOf(Component) || React.PureComponent.isPrototypeOf(Component);
+ }
+
+ let isReactComponent = false;
+ function Tester(...args) {
+ try {
+ const vnode = Component(...args);
+ if (
+ vnode &&
+ (vnode['$$typeof'] === reactTypeof || vnode['$$typeof'] === reactTransitionalTypeof)
+ ) {
+ isReactComponent = true;
+ }
+ } catch {}
+
+ return React.createElement('div');
+ }
+
+ await renderToStaticMarkup(Tester, props, children, {});
+
+ return isReactComponent;
+}
+
+async function getNodeWritable() {
+ let nodeStreamBuiltinModuleName = 'node:stream';
+ let { Writable } = await import(/* @vite-ignore */ nodeStreamBuiltinModuleName);
+ return Writable;
+}
+
+function needsHydration(metadata) {
+ // Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
+ return metadata.astroStaticSlot ? !!metadata.hydrate : true;
+}
+
+async function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
+ let prefix;
+ if (this && this.result) {
+ prefix = incrementId(this.result);
+ }
+ const attrs = { prefix };
+
+ delete props['class'];
+ const slots = {};
+ for (const [key, value] of Object.entries(slotted)) {
+ const name = slotName(key);
+ slots[name] = React.createElement(StaticHtml, {
+ hydrate: needsHydration(metadata),
+ value,
+ name,
+ });
+ }
+ // Note: create newProps to avoid mutating `props` before they are serialized
+ const newProps = {
+ ...props,
+ ...slots,
+ };
+ const newChildren = children ?? props.children;
+ if (children && opts.experimentalReactChildren) {
+ attrs['data-react-children'] = true;
+ const convert = await import('./vnode-children.js').then((mod) => mod.default);
+ newProps.children = convert(children);
+ } else if (newChildren != null) {
+ newProps.children = React.createElement(StaticHtml, {
+ hydrate: needsHydration(metadata),
+ 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 (opts.experimentalDisableStreaming) {
+ html = ReactDOM.renderToString(vnode);
+ } else if ('renderToReadableStream' in ReactDOM) {
+ html = await renderToReadableStreamAsync(vnode, renderOptions);
+ } else {
+ html = await renderToPipeableStreamAsync(vnode, renderOptions);
+ }
+ 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 { searchParams } = new URL(request.url);
+ 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 = searchParams.get('_action');
+
+ if (!actionKey || !actionName) return undefined;
+
+ return [actionResult, actionKey, actionName];
+}
+
+async function renderToPipeableStreamAsync(vnode, options) {
+ const Writable = await getNodeWritable();
+ let html = '';
+ return new Promise((resolve, reject) => {
+ let error = undefined;
+ let stream = ReactDOM.renderToPipeableStream(vnode, {
+ ...options,
+ onError(err) {
+ error = err;
+ reject(error);
+ },
+ onAllReady() {
+ stream.pipe(
+ new Writable({
+ write(chunk, _encoding, callback) {
+ html += chunk.toString('utf-8');
+ callback();
+ },
+ destroy() {
+ resolve(html);
+ },
+ }),
+ );
+ },
+ });
+ });
+}
+
+/**
+ * Use a while loop instead of "for await" due to cloudflare and Vercel Edge issues
+ * See https://github.com/facebook/react/issues/24169
+ */
+async function readResult(stream) {
+ const reader = stream.getReader();
+ let result = '';
+ const decoder = new TextDecoder('utf-8');
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ if (value) {
+ result += decoder.decode(value);
+ } else {
+ // This closes the decoder
+ decoder.decode(new Uint8Array());
+ }
+
+ return result;
+ }
+ result += decoder.decode(value, { stream: true });
+ }
+}
+
+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 {
+ name: '@astrojs/react',
+ check,
+ renderToStaticMarkup,
+ supportsAstroStaticSlot: true,
+};