diff --git a/.bitmap b/.bitmap index 2caa98913974..0afea5280d5f 100644 --- a/.bitmap +++ b/.bitmap @@ -1472,6 +1472,13 @@ "mainFile": "index.ts", "rootDir": "components/renderers/tuple-type" }, + "rendering/ssr": { + "name": "rendering/ssr", + "scope": "teambit.react", + "version": "1.0.3", + "mainFile": "index.ts", + "rootDir": "components/rendering/ssr" + }, "ripple": { "name": "ripple", "scope": "teambit.cloud", diff --git a/components/rendering/ssr/browser-from-express.ts b/components/rendering/ssr/browser-from-express.ts new file mode 100644 index 000000000000..704ed288feda --- /dev/null +++ b/components/rendering/ssr/browser-from-express.ts @@ -0,0 +1,32 @@ +import type { Request } from 'express'; +import { BrowserData, PartialLocation } from './request-browser'; + +/** extract BrowserData from Express request (convinience method) */ +export function browserFromExpress(req: Request, port: number): BrowserData { + return { + location: requestToLocation(req, port), + headers: req.headers, + }; +} + +function requestToLocation(request: Request, port: number): PartialLocation { + return { + host: `${request.hostname}:${port}`, + hostname: request.hostname, + href: `${request.protocol}://${request.hostname}:${port}${request.url}`, + origin: `${request.protocol}://${request.hostname}:${port}`, + pathname: request.path, + port: port.toString(), + protocol: `${request.protocol}:`, + + hash: '', + search: extractSearch(request.url), + }; +} + +function extractSearch(url: string) { + const [, after] = url.split('?'); + if (!after) return ''; + + return `?${after}`; +} diff --git a/components/rendering/ssr/browser-renderer.tsx b/components/rendering/ssr/browser-renderer.tsx new file mode 100644 index 000000000000..8aa77107033b --- /dev/null +++ b/components/rendering/ssr/browser-renderer.tsx @@ -0,0 +1,153 @@ +import React, { type ReactNode } from 'react'; +import { Html, mountPointId, ssrCleanup } from '@teambit/ui-foundation.ui.rendering.html'; +import { Composer, type Wrapper } from '@teambit/base-ui.utils.composer'; +import ReactDOM from 'react-dom'; +import compact from 'lodash.compact'; +import type { ClientRenderPlugin } from './render-plugins'; + +type ReactRoot = { render: (children: ReactNode) => void }; + +/** + * creates a React root using createRoot (React 18+) when available, + * falling back to ReactDOM.render (React 17) otherwise. + */ +function createReactRoot(container: HTMLElement): ReactRoot { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createRoot } = require('react-dom/client'); + return createRoot(container); + } catch { + return { + render: (children: ReactNode) => { + // @ts-ignore + ReactDOM.render(children as React.ReactElement, container); + }, + }; + } +} + +export type BrowserRendererOptions = { + /** load and remove dehydrated state from the dom */ + popAssets: () => Map; + /** mount point element or id */ + mountPointElement: string | HTMLElement; + /** runs after rehydration */ + cleanup: () => void; +}; +const defaultOptions: BrowserRendererOptions = { + popAssets: Html.popAssets, + mountPointElement: mountPointId, + cleanup: ssrCleanup, +}; + +export class BrowserRenderer { + options: BrowserRendererOptions; + private root: ReactRoot | undefined; + constructor( + /** effect rendering at key triggers. keep order consistent between server and browser */ + private plugins: ClientRenderPlugin[], + options?: Partial + ) { + this.options = { ...options, ...defaultOptions }; + } + + /** render and rehydrate client-side */ + async render(children: ReactNode) { + // (*) load state from the dom + const deserializedState = await this.deserialize(); + + // (1) init setup client plugins + let renderContexts = await this.triggerBrowserInit(deserializedState); + + // (2) make react dom + const reactContexts = this.getReactContexts(renderContexts); + const app = {children}; + + renderContexts = await this.triggerBeforeHydrateHook(renderContexts, app); + + // (3) render / rehydrate + const mountPoint = + typeof this.options.mountPointElement === 'string' + ? document.getElementById(this.options.mountPointElement) + : this.options.mountPointElement; + if (!this.root && mountPoint) { + this.root = createReactRoot(mountPoint); + } + this.root?.render(app); + + await this.triggerHydrateHook(renderContexts, mountPoint); + + // (3.1) remove ssr only styles + this.options.cleanup(); + } + + private async deserialize() { + const { plugins } = this; + const rawAssets = this.options.popAssets(); + + const deserialized = await Promise.all( + plugins.map(async (plugin) => { + if (!('deserialize' in plugin)) return undefined; + if (!plugin.key) throw new Error('Key is required for .deserialize()'); + + try { + const raw = rawAssets.get(plugin.key); + return plugin.deserialize?.(raw); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`failed deserializing server state for aspect "${plugin.key}"`, e); + return undefined; + } + }) + ); + + return deserialized; + } + + private triggerBrowserInit(deserializedState: any[]) { + const { plugins } = this; + + const initPromises = plugins.map((plugin, idx) => { + const state = deserializedState[idx]; + return plugin.browserInit?.(state); + }); + return Promise.all(initPromises); + } + + private triggerBeforeHydrateHook(renderContexts: any[], app: JSX.Element) { + const { plugins } = this; + + const promises = plugins.map(async (plugin, idx) => { + const ctx = renderContexts[idx]; + const nextCtx = await plugin.onBeforeHydrate?.(ctx, app); + return nextCtx || ctx; + }); + + return Promise.all(promises); + } + + private async triggerHydrateHook(renderContexts: any[], mountPoint: HTMLElement | null) { + const { plugins } = this; + + const promises = plugins.map((plugin, idx) => { + const renderCtx = renderContexts[idx]; + return plugin.onHydrate?.(renderCtx, mountPoint); + }); + + await Promise.all(promises); + } + + private getReactContexts(renderContexts: any[]): Wrapper[] { + const { plugins } = this; + + return compact( + plugins.map((plugin, idx) => { + const renderCtx = renderContexts[idx]; + const props = { renderCtx }; + const decorator = plugin.reactClientContext || plugin.reactContext; + if (!decorator) return undefined; + return [decorator, props]; + }) + ); + } +} diff --git a/components/rendering/ssr/index.ts b/components/rendering/ssr/index.ts new file mode 100644 index 000000000000..3c99b9ca8829 --- /dev/null +++ b/components/rendering/ssr/index.ts @@ -0,0 +1,9 @@ +export type { Assets as HtmlAssets } from '@teambit/ui-foundation.ui.rendering.html'; +export { browserFromExpress } from './browser-from-express'; +export { BrowserRenderer } from './browser-renderer'; +export { Ssr } from './react-ssr'; +export type { ReactSsrOptions } from './react-ssr'; +export type { ClientRenderPlugin, ContextProps, RenderPlugin, ServerRenderPlugin } from './render-plugins'; +export type { BrowserData, PartialLocation } from './request-browser'; +export { ServerRenderer } from './server-renderer'; +export type { SsrSession } from './ssr-session'; diff --git a/components/rendering/ssr/react-ssr.compositions.tsx b/components/rendering/ssr/react-ssr.compositions.tsx new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/components/rendering/ssr/react-ssr.compositions.tsx @@ -0,0 +1 @@ +export {}; diff --git a/components/rendering/ssr/react-ssr.docs.mdx b/components/rendering/ssr/react-ssr.docs.mdx new file mode 100644 index 000000000000..a488f3506c86 --- /dev/null +++ b/components/rendering/ssr/react-ssr.docs.mdx @@ -0,0 +1,114 @@ +--- +description: A complete and extendable server-side rendering solution. +labels: ['react', 'serverside rendering', 'ssr'] +--- + +Break down Server-side Rendering (SSR) into simple plugins. + +React version: `17.x.x` + +## Simple usage + +Out of the box, SSR is very simple: + +```tsx +// client/main.js +import { React } from 'react'; +import { BrowserRenderer } from '@teambit/react.rendering.ssr'; + +const renderer = new BrowserRenderer([]); +renderer.render(); +``` + +```tsx +// server/main.js +import { React } from 'react'; +import { ServerRenderer } from '@teambit/react.rendering.ssr'; + +const renderer = new ServerRenderer([]); + +// can be async +export default async function ServerSideApp({ response }) { + const content = await renderer.render(); + response.send(content); +} +``` + +and that's it! pretty simple. + +### React Router Dom example + +While rendering, you will have all the context you will need: + +```tsx +// client/main.js +import { React } from 'react'; +import { BrowserRenderer } from '@teambit/react.rendering.ssr'; + +const renderer = new BrowserRenderer([]); +renderer.render( + + + +); +``` + +```tsx +// server/main.js +import { React } from 'react'; +import { ServerRenderer } from '@teambit/react.rendering.ssr'; + +const renderer = new ServerRenderer([]); + +export default async function ServerSideApp({ response, browser }) { + const content = await renderer.render( + + + + ); + response.send(content); +} +``` + +## Plugins + +Instead of manually writing different code for server and client, you can write a single or double plugin that will manage the entire ssr lifecycle: + +> This is the _unified_ plugin. You can also use the `ClientRenderPlugin` and `ServerRenderPlugin` separately. + +```tsx +class UnifiedReactRouterPlugin implements RenderPlugin<{ location: PartialLocation }> { + serverInit = ({ browser }: SsrSession) => ({ location: browser.location }); + // not needed in this case + // clientInit = () => { return { location: window.location }} + + reactServerContext = ({ children, renderCtx }) => { + return {children}; + }; + + // needed for serialization + key: 'react-router-ssr-plugin'; + + serialize = (ctx, app) => { + // ... send data to the client + }; + + deserialize = (rawData) => { + // ... parse stored data + }; + + reactClientContext = ({ children }) => { + return {children}; + }; +} +``` + +You can then use the plugin like so: + +```tsx +// client +const renderer = new BrowserRenderer([myPlugin]); + +// server +const renderer = new ServerRenderer([myPlugin]); +``` diff --git a/components/rendering/ssr/react-ssr.tsx b/components/rendering/ssr/react-ssr.tsx new file mode 100644 index 000000000000..5573e9be0e1b --- /dev/null +++ b/components/rendering/ssr/react-ssr.tsx @@ -0,0 +1,20 @@ +import type { RenderPlugin } from './render-plugins'; +import { BrowserRenderer } from './browser-renderer'; +import type { BrowserRendererOptions } from './browser-renderer'; +import { ServerRenderer } from './server-renderer'; +import type { ServerRendererOptions } from './server-renderer'; + +export type ReactSsrOptions = Partial; + +export class Ssr { + constructor( + // create array once, to keep consistent indexes between server and client + private plugins: RenderPlugin[], + private options?: ReactSsrOptions + ) {} + private browser = new BrowserRenderer(this.plugins, this.options); + private server = new ServerRenderer(this.plugins, this.options); + + renderServer = this.server.render.bind(this.server); + renderBrowser = this.browser.render.bind(this.browser); +} diff --git a/components/rendering/ssr/render-plugins.tsx b/components/rendering/ssr/render-plugins.tsx new file mode 100644 index 000000000000..328cade2a518 --- /dev/null +++ b/components/rendering/ssr/render-plugins.tsx @@ -0,0 +1,96 @@ +import type { ReactNode, ComponentType } from 'react'; +import { SsrSession } from './ssr-session'; + +export type ContextProps = { renderCtx?: T; children: ReactNode }; + +export type ServerRenderPlugin = { + /** identifies plugin data during serialization / deserialization */ + key?: string; + + /** + * Initialize a context state for this specific rendering. + * Context state will only be available to the current Aspect, in the other hooks, as well as a prop to the react context component. + */ + serverInit?: (session: SsrSession) => RenderCtx | void | undefined | Promise; + + /** + * Executes before running ReactDOM.renderToString(). Return value will replace the existing context state. + */ + onBeforeRender?: ( + ctx: RenderCtx, + app: ReactNode + ) => RenderCtx | void | undefined | Promise; + + /** + * Produce html assets. Runs after the body is rendered, and before rendering the final html. + * @returns + * json: will be rendered to the dom as a `