diff --git a/apps/docs/utilities/render.mdx b/apps/docs/utilities/render.mdx index eefcb3239f..cb9210ce98 100644 --- a/apps/docs/utilities/render.mdx +++ b/apps/docs/utilities/render.mdx @@ -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(({ name, renderOptions }) => { + // Check if rendering as plain text + if (renderOptions?.plainText) { + return `Hello ${name}! This is the plain text version.`; + } + + // Default HTML rendering + return ( + + Hello {name}! + This is the HTML version with styling. + + ); +}); +``` + +Now when you render this component: + +```tsx +import { MyTemplate } from './email'; +import { render } from '@react-email/render'; + +// HTML version +const html = await render(); + +// Plain text version (component receives renderOptions.plainText = true) +const text = await render(, { plainText: true }); +``` + +The component will automatically receive the render options as the `renderOptions` prop, allowing you to conditionally render different content. + ## Options diff --git a/packages/render/src/browser/index.ts b/packages/render/src/browser/index.ts index 3d930a1cf7..f980c206d3 100644 --- a/packages/render/src/browser/index.ts +++ b/packages/render/src/browser/index.ts @@ -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'; diff --git a/packages/render/src/browser/render-web.spec.tsx b/packages/render/src/browser/render-web.spec.tsx index ff7752e7d9..4c688a7441 100644 --- a/packages/render/src/browser/render-web.spec.tsx +++ b/packages/render/src/browser/render-web.spec.tsx @@ -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'); @@ -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( + (props) => { + return JSON.stringify(props); + }, + ); + + const actualOutput = await render( + , + { 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( + , + { plainText: true }, + ); + + const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"'; + expect(actualOutput).toMatchInlineSnapshot(expectedOutput); + }); }); diff --git a/packages/render/src/browser/render.tsx b/packages/render/src/browser/render.tsx index 4d71696e6d..ee30fd5c3a 100644 --- a/packages/render/src/browser/render.tsx +++ b/packages/render/src/browser/render.tsx @@ -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'); @@ -39,7 +40,8 @@ const readStream = async (stream: ReactDOMServerReadableStream) => { }; export const render = async (node: React.ReactNode, options?: Options) => { - const suspendedElement = {node}; + const nodeWithRenderOptionsProps = injectRenderOptions(node, options); + const suspendedElement = {nodeWithRenderOptionsProps}; const reactDOMServer = await import('react-dom/server.browser').then( // This is beacuse react-dom/server is CJS (m) => m.default, diff --git a/packages/render/src/node/index.ts b/packages/render/src/node/index.ts index 3d930a1cf7..f980c206d3 100644 --- a/packages/render/src/node/index.ts +++ b/packages/render/src/node/index.ts @@ -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'; diff --git a/packages/render/src/node/render-edge.spec.tsx b/packages/render/src/node/render-edge.spec.tsx index 3586cdb83a..4fb9cd8b59 100644 --- a/packages/render/src/node/render-edge.spec.tsx +++ b/packages/render/src/node/render-edge.spec.tsx @@ -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') & { @@ -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( + (props) => { + return JSON.stringify(props); + }, + ); + + const actualOutput = await render( + , + { 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( + , + { plainText: true }, + ); + + const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"' + expect(actualOutput).toMatchInlineSnapshot(expectedOutput); + }); }); diff --git a/packages/render/src/node/render-node.spec.tsx b/packages/render/src/node/render-node.spec.tsx index 90a096e665..1601ff00cb 100644 --- a/packages/render/src/node/render-node.spec.tsx +++ b/packages/render/src/node/render-node.spec.tsx @@ -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'); @@ -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( + (props) => { + return JSON.stringify(props); + }, + ); + + const actualOutput = await render( + , + { 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( + , + { plainText: true }, + ); + + const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"'; + expect(actualOutput).toMatchInlineSnapshot(expectedOutput); + }); }); diff --git a/packages/render/src/node/render.tsx b/packages/render/src/node/render.tsx index b743d56131..9b70fc785f 100644 --- a/packages/render/src/node/render.tsx +++ b/packages/render/src/node/render.tsx @@ -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 = {node}; + const nodeWithRenderOptionsProps = injectRenderOptions(node, options); + const suspendedElement = {nodeWithRenderOptionsProps}; const reactDOMServer = await import('react-dom/server').then( // This is beacuse react-dom/server is CJS (m) => m.default, diff --git a/packages/render/src/shared/with-render-options.tsx b/packages/render/src/shared/with-render-options.tsx new file mode 100644 index 0000000000..a18d2d4e19 --- /dev/null +++ b/packages/render/src/shared/with-render-options.tsx @@ -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 & { + renderOptions?: Options; +}; + +/** Component wrapped with withRenderOptions, marked with a symbol. */ +type ComponentWithRenderOptions

= ComponentType< + PropsWithRenderOptions

+> & { + [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

HTML version

; + * }); + * ``` + */ +export function withRenderOptions

( + Component: ComponentType>, +): ComponentWithRenderOptions

{ + const WrappedComponent = Component as ComponentWithRenderOptions

; + 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); +}