From 7e6ef4b0192cb56d54e4a7c75785a797efdd3cf8 Mon Sep 17 00:00:00 2001 From: Emilio Heinzmann Date: Mon, 11 Aug 2025 07:35:11 -0300 Subject: [PATCH 1/2] feat(render): add withRenderOptions higher-order function This commit introduces `withRenderOptions`, a higher-order function that enables email templates to access render options (like plainText mode) directly as props. This allows templates to conditionally render different content based on the rendering context. Key changes: - Added `withRenderOptions` higher-order function that marks components to receive render options - Implemented `injectRenderOptions` to automatically pass options to wrapped components during rendering - Updated both browser and node render functions to inject options before rendering - Added comprehensive tests for both wrapped and unwrapped components - Exported new types and utilities from package entry points --- packages/render/src/browser/index.ts | 1 + .../render/src/browser/render-web.spec.tsx | 35 +++++++++ packages/render/src/browser/render.tsx | 4 +- packages/render/src/node/index.ts | 1 + packages/render/src/node/render-edge.spec.tsx | 35 +++++++++ packages/render/src/node/render-node.spec.tsx | 35 +++++++++ packages/render/src/node/render.tsx | 4 +- .../render/src/shared/with-render-options.tsx | 76 +++++++++++++++++++ 8 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 packages/render/src/shared/with-render-options.tsx 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); +} From db6fab1a290c841cfadadc8e749f2dd63b413ac7 Mon Sep 17 00:00:00 2001 From: Emilio Heinzmann Date: Thu, 14 Aug 2025 21:19:43 -0300 Subject: [PATCH 2/2] docs(render): add documentation about withRenderOptions --- apps/docs/utilities/render.mdx | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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