aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/react/src/server.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/react/src/server.ts')
-rw-r--r--packages/integrations/react/src/server.ts229
1 files changed, 229 insertions, 0 deletions
diff --git a/packages/integrations/react/src/server.ts b/packages/integrations/react/src/server.ts
new file mode 100644
index 000000000..f7e273e6b
--- /dev/null
+++ b/packages/integrations/react/src/server.ts
@@ -0,0 +1,229 @@
+import opts from 'astro:react:opts';
+import type { AstroComponentMetadata, NamedSSRLoadedRendererValue } from 'astro';
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import { incrementId } from './context.js';
+import StaticHtml from './static-html.js';
+import type { RendererContext } from './types.js';
+
+const slotName = (str: string) => 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(
+ this: RendererContext,
+ Component: any,
+ props: Record<string, any>,
+ children: any,
+) {
+ // 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: Array<any>) {
+ try {
+ const vnode = Component(...args);
+ if (
+ vnode &&
+ (vnode['$$typeof'] === reactTypeof || vnode['$$typeof'] === reactTransitionalTypeof)
+ ) {
+ isReactComponent = true;
+ }
+ } catch {}
+
+ return React.createElement('div');
+ }
+
+ await renderToStaticMarkup.call(this, Tester, props, children);
+
+ return isReactComponent;
+}
+
+async function getNodeWritable(): Promise<typeof import('node:stream').Writable> {
+ let nodeStreamBuiltinModuleName = 'node:stream';
+ let { Writable } = await import(/* @vite-ignore */ nodeStreamBuiltinModuleName);
+ return Writable;
+}
+
+function needsHydration(metadata?: AstroComponentMetadata) {
+ // Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
+ return metadata?.astroStaticSlot ? !!metadata.hydrate : true;
+}
+
+async function renderToStaticMarkup(
+ this: RendererContext,
+ Component: any,
+ props: Record<string, any>,
+ { default: children, ...slotted }: Record<string, any>,
+ metadata?: AstroComponentMetadata,
+) {
+ let prefix;
+ if (this && this.result) {
+ prefix = incrementId(this.result);
+ }
+ const attrs: Record<string, any> = { prefix };
+
+ delete props['class'];
+ const slots: Record<string, any> = {};
+ 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: string;
+ 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 };
+}
+
+async function getFormState({
+ result,
+}: RendererContext): Promise<
+ [actionResult: any, actionKey: string, actionName: string] | undefined
+> {
+ 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: any, options: Record<string, any>) {
+ const Writable = await getNodeWritable();
+ let html = '';
+ return new Promise<string>((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: ReactDOM.ReactDOMServerReadableStream) {
+ 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: any, options: Record<string, any>) {
+ return await readResult(await ReactDOM.renderToReadableStream(vnode, options));
+}
+
+const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+function isFormRequest(contentType: string | null) {
+ // 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);
+}
+
+const renderer: NamedSSRLoadedRendererValue = {
+ name: '@astrojs/react',
+ check,
+ renderToStaticMarkup,
+ supportsAstroStaticSlot: true,
+};
+
+export default renderer;