Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .bitmap
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Comment thread
GiladShoham marked this conversation as resolved.
"ripple": {
"name": "ripple",
"scope": "teambit.cloud",
Expand Down
32 changes: 32 additions & 0 deletions components/rendering/ssr/browser-from-express.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
153 changes: 153 additions & 0 deletions components/rendering/ssr/browser-renderer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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<any, any>[],
options?: Partial<BrowserRendererOptions>
) {
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 = <Composer components={reactContexts}>{children}</Composer>;

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];
})
);
}
}
9 changes: 9 additions & 0 deletions components/rendering/ssr/index.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions components/rendering/ssr/react-ssr.compositions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
114 changes: 114 additions & 0 deletions components/rendering/ssr/react-ssr.docs.mdx
Original file line number Diff line number Diff line change
@@ -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(<MyApp />);
```

```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(<MyApp />);
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(
<BrowserRouter>
<MyApp />
</BrowserRouter>
);
```

```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(
<StaticRouter location={browser.location}>
<MyApp />
</StaticRouter>
);
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 <StaticRouter location={renderCtx.location}>{children}</StaticRouter>;
};

// needed for serialization
key: 'react-router-ssr-plugin';

serialize = (ctx, app) => {
// ... send data to the client
};

deserialize = (rawData) => {
// ... parse stored data
};

reactClientContext = ({ children }) => {
return <BrowserRouter>{children}</BrowserRouter>;
};
}
```

You can then use the plugin like so:

```tsx
// client
const renderer = new BrowserRenderer([myPlugin]);

// server
const renderer = new ServerRenderer([myPlugin]);
```
20 changes: 20 additions & 0 deletions components/rendering/ssr/react-ssr.tsx
Original file line number Diff line number Diff line change
@@ -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<ServerRendererOptions & BrowserRendererOptions>;

export class Ssr {
constructor(
// create array once, to keep consistent indexes between server and client
private plugins: RenderPlugin<any, any>[],
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);
}
Loading