Skip to content

feat(render): add withRenderOptions to enable templates to access render options as props #2417

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: canary
Choose a base branch
from
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
41 changes: 41 additions & 0 deletions apps/docs/utilities/render.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,47 @@ Some title
Click me [https://example.com]
```

## 5. Using withRenderOptions

The `withRenderOptions` higher-order function allows your email templates to access render options directly as props. This is useful when you want to customize the email content based on how it's being rendered.

```tsx
import { withRenderOptions } from '@react-email/render';
import { Html, Text } from '@react-email/components';

type MyTemplateProps = { name: string };

export const MyTemplate = withRenderOptions<MyTemplateProps>(({ name, renderOptions }) => {
// Check if rendering as plain text
if (renderOptions?.plainText) {
return `Hello ${name}! This is the plain text version.`;
}

// Default HTML rendering
return (
<Html>
<Text>Hello {name}!</Text>
<Text>This is the HTML version with styling.</Text>
</Html>
);
});
```

Now when you render this component:

```tsx
import { MyTemplate } from './email';
import { render } from '@react-email/render';

// HTML version
const html = await render(<MyTemplate name="John" />);

// Plain text version (component receives renderOptions.plainText = true)
const text = await render(<MyTemplate name="John" />, { plainText: true });
```

The component will automatically receive the render options as the `renderOptions` prop, allowing you to conditionally render different content.

## Options

<ResponseField name="pretty" type="boolean" deprecated>
Expand Down
1 change: 1 addition & 0 deletions packages/render/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export const renderAsync = (element: React.ReactElement, options?: Options) => {
export * from '../shared/options';
export * from '../shared/plain-text-selectors';
export * from '../shared/utils/pretty';
export { type PropsWithRenderOptions, withRenderOptions } from '../shared/with-render-options';
export * from './render';
35 changes: 35 additions & 0 deletions packages/render/src/browser/render-web.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import { createElement } from 'react';
import usePromise from 'react-promise-suspense';
import type { Options } from '../shared/options';
import { Preview } from '../shared/utils/preview';
import { Template } from '../shared/utils/template';
import { render } from './render';
import { withRenderOptions } from '../shared/with-render-options';

type Import = typeof import('react-dom/server') & {
default: typeof import('react-dom/server');
Expand Down Expand Up @@ -146,4 +148,37 @@ describe('render on the browser environment', () => {
const element = createElement(undefined);
await expect(render(element)).rejects.toThrowErrorMatchingSnapshot();
});

it('passes render options to components wrapped with withRenderOptions', async () => {
type TemplateWithOptionsProps = { id: string };
const TemplateWithOptions = withRenderOptions<TemplateWithOptionsProps>(
(props) => {
return JSON.stringify(props);
},
);

const actualOutput = await render(
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
{ plainText: true },
);

const expectedOutput =
'"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57","renderOptions":{"plainText":true}}"';
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
});

it('does not pass render options to components not wrapped with withRenderOptions', async () => {
type TemplateWithOptionsProps = { id: string };
const TemplateWithOptions = (props: TemplateWithOptionsProps) => {
return JSON.stringify(props);
};

const actualOutput = await render(
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
{ plainText: true },
);

const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"';
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
});
});
4 changes: 3 additions & 1 deletion packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ReactDOMServerReadableStream } from 'react-dom/server';
import { pretty } from '../node';
import type { Options } from '../shared/options';
import { plainTextSelectors } from '../shared/plain-text-selectors';
import { injectRenderOptions } from '../shared/with-render-options';

const decoder = new TextDecoder('utf-8');

Expand Down Expand Up @@ -39,7 +40,8 @@ const readStream = async (stream: ReactDOMServerReadableStream) => {
};

export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;
const nodeWithRenderOptionsProps = injectRenderOptions(node, options);
const suspendedElement = <Suspense>{nodeWithRenderOptionsProps}</Suspense>;
const reactDOMServer = await import('react-dom/server.browser').then(
// This is beacuse react-dom/server is CJS
(m) => m.default,
Expand Down
1 change: 1 addition & 0 deletions packages/render/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export const renderAsync = (element: React.ReactElement, options?: Options) => {
export * from '../shared/options';
export * from '../shared/plain-text-selectors';
export * from '../shared/utils/pretty';
export { type PropsWithRenderOptions, withRenderOptions } from '../shared/with-render-options';
export * from './render';
35 changes: 35 additions & 0 deletions packages/render/src/node/render-edge.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Options } from '../shared/options';
import { Preview } from '../shared/utils/preview';
import { Template } from '../shared/utils/template';
import { withRenderOptions } from '../shared/with-render-options';
import { render } from './render';

type Import = typeof import('react-dom/server') & {
Expand Down Expand Up @@ -107,4 +109,37 @@ describe('render on the edge', () => {
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});


it('passes render options to components wrapped with withRenderOptions', async () => {
type TemplateWithOptionsProps = { id: string };
const TemplateWithOptions = withRenderOptions<TemplateWithOptionsProps>(
(props) => {
return JSON.stringify(props);
},
);

const actualOutput = await render(
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
{ plainText: true },
);

const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57","renderOptions":{"plainText":true}}"'
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
});

it('does not pass render options to components not wrapped with withRenderOptions', async () => {
type TemplateWithOptionsProps = { id: string };
const TemplateWithOptions = (props: TemplateWithOptionsProps) => {
return JSON.stringify(props);
};

const actualOutput = await render(
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
{ plainText: true },
);

const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"'
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
});
});
35 changes: 35 additions & 0 deletions packages/render/src/node/render-node.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import { Suspense } from 'react';
import usePromise from 'react-promise-suspense';
import type { Options } from '../shared/options';
import { Preview } from '../shared/utils/preview';
import { Template } from '../shared/utils/template';
import { render } from './render';
import { withRenderOptions } from '../browser';

type Import = typeof import('react-dom/server') & {
default: typeof import('react-dom/server');
Expand Down Expand Up @@ -133,4 +135,37 @@ describe('render on node environments', () => {
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});

it('passes render options to components wrapped with withRenderOptions', async () => {
type TemplateWithOptionsProps = { id: string };
const TemplateWithOptions = withRenderOptions<TemplateWithOptionsProps>(
(props) => {
return JSON.stringify(props);
},
);

const actualOutput = await render(
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
{ plainText: true },
);

const expectedOutput =
'"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57","renderOptions":{"plainText":true}}"';
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
});

it('does not pass render options to components not wrapped with withRenderOptions', async () => {
type TemplateWithOptionsProps = { id: string };
const TemplateWithOptions = (props: TemplateWithOptionsProps) => {
return JSON.stringify(props);
};

const actualOutput = await render(
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
{ plainText: true },
);

const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"';
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
});
});
4 changes: 3 additions & 1 deletion packages/render/src/node/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { Suspense } from 'react';
import type { Options } from '../shared/options';
import { plainTextSelectors } from '../shared/plain-text-selectors';
import { pretty } from '../shared/utils/pretty';
import { injectRenderOptions } from '../shared/with-render-options';
import { readStream } from './read-stream';

export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;
const nodeWithRenderOptionsProps = injectRenderOptions(node, options);
const suspendedElement = <Suspense>{nodeWithRenderOptionsProps}</Suspense>;
const reactDOMServer = await import('react-dom/server').then(
// This is beacuse react-dom/server is CJS
(m) => m.default,
Expand Down
76 changes: 76 additions & 0 deletions packages/render/src/shared/with-render-options.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
type ComponentType,
cloneElement,
isValidElement,
type ReactNode,
} from 'react';
import type { Options } from './options';

const RENDER_OPTIONS_SYMBOL = Symbol.for('react-email.withRenderOptions');

/** Extends component props with optional render options. */
export type PropsWithRenderOptions<P = unknown> = P & {
renderOptions?: Options;
};

/** Component wrapped with withRenderOptions, marked with a symbol. */
type ComponentWithRenderOptions<P = unknown> = ComponentType<
PropsWithRenderOptions<P>
> & {
[RENDER_OPTIONS_SYMBOL]: true;
};

/**
* Wraps a component to receive render options as props.
*
* @param Component - The component to wrap.
* @return A component that accepts `renderOptions` prop.
*
* @example
* ```tsx
* export const EmailTemplate = withRenderOptions(({ renderOptions }) => {
* if (renderOptions?.plainText) {
* return 'Plain text version';
* }
* return <div><h1>HTML version</h1></div>;
* });
* ```
*/
export function withRenderOptions<P = unknown>(
Component: ComponentType<PropsWithRenderOptions<P>>,
): ComponentWithRenderOptions<P> {
const WrappedComponent = Component as ComponentWithRenderOptions<P>;
WrappedComponent[RENDER_OPTIONS_SYMBOL] = true;
WrappedComponent.displayName = `withRenderOptions(${Component.displayName || Component.name || 'Component'})`;
return WrappedComponent;
}

/** @internal */
function isWithRenderOptionsComponent(
component: unknown,
): component is ComponentWithRenderOptions {
return (
!!component &&
typeof component === 'function' &&
RENDER_OPTIONS_SYMBOL in component &&
component[RENDER_OPTIONS_SYMBOL] === true
);
}

/**
* Injects render options into components wrapped with `withRenderOptions`.
* Returns node unchanged if not wrapped or not a valid element.
*
* @param node - The React node to inject options into.
* @param options - The render options to inject.
* @returns The node with injected options if applicable, otherwise the original node.
*/
export function injectRenderOptions(
node: ReactNode,
options?: Options,
): ReactNode {
if (!isValidElement(node)) return node;
if (!isWithRenderOptionsComponent(node.type)) return node;
const renderOptionsProps = { renderOptions: options };
return cloneElement(node, renderOptionsProps);
}
Loading