Skip to content

Commit 7e6ef4b

Browse files
committed
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
1 parent b2407a1 commit 7e6ef4b

File tree

8 files changed

+189
-2
lines changed

8 files changed

+189
-2
lines changed

packages/render/src/browser/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export const renderAsync = (element: React.ReactElement, options?: Options) => {
1111
export * from '../shared/options';
1212
export * from '../shared/plain-text-selectors';
1313
export * from '../shared/utils/pretty';
14+
export { type PropsWithRenderOptions, withRenderOptions } from '../shared/with-render-options';
1415
export * from './render';

packages/render/src/browser/render-web.spec.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
import { createElement } from 'react';
66
import usePromise from 'react-promise-suspense';
7+
import type { Options } from '../shared/options';
78
import { Preview } from '../shared/utils/preview';
89
import { Template } from '../shared/utils/template';
910
import { render } from './render';
11+
import { withRenderOptions } from '../shared/with-render-options';
1012

1113
type Import = typeof import('react-dom/server') & {
1214
default: typeof import('react-dom/server');
@@ -146,4 +148,37 @@ describe('render on the browser environment', () => {
146148
const element = createElement(undefined);
147149
await expect(render(element)).rejects.toThrowErrorMatchingSnapshot();
148150
});
151+
152+
it('passes render options to components wrapped with withRenderOptions', async () => {
153+
type TemplateWithOptionsProps = { id: string };
154+
const TemplateWithOptions = withRenderOptions<TemplateWithOptionsProps>(
155+
(props) => {
156+
return JSON.stringify(props);
157+
},
158+
);
159+
160+
const actualOutput = await render(
161+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
162+
{ plainText: true },
163+
);
164+
165+
const expectedOutput =
166+
'"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57","renderOptions":{"plainText":true}}"';
167+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
168+
});
169+
170+
it('does not pass render options to components not wrapped with withRenderOptions', async () => {
171+
type TemplateWithOptionsProps = { id: string };
172+
const TemplateWithOptions = (props: TemplateWithOptionsProps) => {
173+
return JSON.stringify(props);
174+
};
175+
176+
const actualOutput = await render(
177+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
178+
{ plainText: true },
179+
);
180+
181+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"';
182+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
183+
});
149184
});

packages/render/src/browser/render.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ReactDOMServerReadableStream } from 'react-dom/server';
44
import { pretty } from '../node';
55
import type { Options } from '../shared/options';
66
import { plainTextSelectors } from '../shared/plain-text-selectors';
7+
import { injectRenderOptions } from '../shared/with-render-options';
78

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

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

4142
export const render = async (node: React.ReactNode, options?: Options) => {
42-
const suspendedElement = <Suspense>{node}</Suspense>;
43+
const nodeWithRenderOptionsProps = injectRenderOptions(node, options);
44+
const suspendedElement = <Suspense>{nodeWithRenderOptionsProps}</Suspense>;
4345
const reactDOMServer = await import('react-dom/server.browser').then(
4446
// This is beacuse react-dom/server is CJS
4547
(m) => m.default,

packages/render/src/node/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export const renderAsync = (element: React.ReactElement, options?: Options) => {
1111
export * from '../shared/options';
1212
export * from '../shared/plain-text-selectors';
1313
export * from '../shared/utils/pretty';
14+
export { type PropsWithRenderOptions, withRenderOptions } from '../shared/with-render-options';
1415
export * from './render';

packages/render/src/node/render-edge.spec.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import type { Options } from '../shared/options';
12
import { Preview } from '../shared/utils/preview';
23
import { Template } from '../shared/utils/template';
4+
import { withRenderOptions } from '../shared/with-render-options';
35
import { render } from './render';
46

57
type Import = typeof import('react-dom/server') & {
@@ -107,4 +109,37 @@ describe('render on the edge', () => {
107109
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
108110
);
109111
});
112+
113+
114+
it('passes render options to components wrapped with withRenderOptions', async () => {
115+
type TemplateWithOptionsProps = { id: string };
116+
const TemplateWithOptions = withRenderOptions<TemplateWithOptionsProps>(
117+
(props) => {
118+
return JSON.stringify(props);
119+
},
120+
);
121+
122+
const actualOutput = await render(
123+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
124+
{ plainText: true },
125+
);
126+
127+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57","renderOptions":{"plainText":true}}"'
128+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
129+
});
130+
131+
it('does not pass render options to components not wrapped with withRenderOptions', async () => {
132+
type TemplateWithOptionsProps = { id: string };
133+
const TemplateWithOptions = (props: TemplateWithOptionsProps) => {
134+
return JSON.stringify(props);
135+
};
136+
137+
const actualOutput = await render(
138+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
139+
{ plainText: true },
140+
);
141+
142+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"'
143+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
144+
});
110145
});

packages/render/src/node/render-node.spec.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
import { Suspense } from 'react';
66
import usePromise from 'react-promise-suspense';
7+
import type { Options } from '../shared/options';
78
import { Preview } from '../shared/utils/preview';
89
import { Template } from '../shared/utils/template';
910
import { render } from './render';
11+
import { withRenderOptions } from '../browser';
1012

1113
type Import = typeof import('react-dom/server') & {
1214
default: typeof import('react-dom/server');
@@ -133,4 +135,37 @@ describe('render on node environments', () => {
133135
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
134136
);
135137
});
138+
139+
it('passes render options to components wrapped with withRenderOptions', async () => {
140+
type TemplateWithOptionsProps = { id: string };
141+
const TemplateWithOptions = withRenderOptions<TemplateWithOptionsProps>(
142+
(props) => {
143+
return JSON.stringify(props);
144+
},
145+
);
146+
147+
const actualOutput = await render(
148+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
149+
{ plainText: true },
150+
);
151+
152+
const expectedOutput =
153+
'"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57","renderOptions":{"plainText":true}}"';
154+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
155+
});
156+
157+
it('does not pass render options to components not wrapped with withRenderOptions', async () => {
158+
type TemplateWithOptionsProps = { id: string };
159+
const TemplateWithOptions = (props: TemplateWithOptionsProps) => {
160+
return JSON.stringify(props);
161+
};
162+
163+
const actualOutput = await render(
164+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
165+
{ plainText: true },
166+
);
167+
168+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"';
169+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
170+
});
136171
});

packages/render/src/node/render.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { Suspense } from 'react';
33
import type { Options } from '../shared/options';
44
import { plainTextSelectors } from '../shared/plain-text-selectors';
55
import { pretty } from '../shared/utils/pretty';
6+
import { injectRenderOptions } from '../shared/with-render-options';
67
import { readStream } from './read-stream';
78

89
export const render = async (node: React.ReactNode, options?: Options) => {
9-
const suspendedElement = <Suspense>{node}</Suspense>;
10+
const nodeWithRenderOptionsProps = injectRenderOptions(node, options);
11+
const suspendedElement = <Suspense>{nodeWithRenderOptionsProps}</Suspense>;
1012
const reactDOMServer = await import('react-dom/server').then(
1113
// This is beacuse react-dom/server is CJS
1214
(m) => m.default,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
type ComponentType,
3+
cloneElement,
4+
isValidElement,
5+
type ReactNode,
6+
} from 'react';
7+
import type { Options } from './options';
8+
9+
const RENDER_OPTIONS_SYMBOL = Symbol.for('react-email.withRenderOptions');
10+
11+
/** Extends component props with optional render options. */
12+
export type PropsWithRenderOptions<P = unknown> = P & {
13+
renderOptions?: Options;
14+
};
15+
16+
/** Component wrapped with withRenderOptions, marked with a symbol. */
17+
type ComponentWithRenderOptions<P = unknown> = ComponentType<
18+
PropsWithRenderOptions<P>
19+
> & {
20+
[RENDER_OPTIONS_SYMBOL]: true;
21+
};
22+
23+
/**
24+
* Wraps a component to receive render options as props.
25+
*
26+
* @param Component - The component to wrap.
27+
* @return A component that accepts `renderOptions` prop.
28+
*
29+
* @example
30+
* ```tsx
31+
* export const EmailTemplate = withRenderOptions(({ renderOptions }) => {
32+
* if (renderOptions?.plainText) {
33+
* return 'Plain text version';
34+
* }
35+
* return <div><h1>HTML version</h1></div>;
36+
* });
37+
* ```
38+
*/
39+
export function withRenderOptions<P = unknown>(
40+
Component: ComponentType<PropsWithRenderOptions<P>>,
41+
): ComponentWithRenderOptions<P> {
42+
const WrappedComponent = Component as ComponentWithRenderOptions<P>;
43+
WrappedComponent[RENDER_OPTIONS_SYMBOL] = true;
44+
WrappedComponent.displayName = `withRenderOptions(${Component.displayName || Component.name || 'Component'})`;
45+
return WrappedComponent;
46+
}
47+
48+
/** @internal */
49+
function isWithRenderOptionsComponent(
50+
component: unknown,
51+
): component is ComponentWithRenderOptions {
52+
return (
53+
!!component &&
54+
typeof component === 'function' &&
55+
RENDER_OPTIONS_SYMBOL in component &&
56+
component[RENDER_OPTIONS_SYMBOL] === true
57+
);
58+
}
59+
60+
/**
61+
* Injects render options into components wrapped with `withRenderOptions`.
62+
* Returns node unchanged if not wrapped or not a valid element.
63+
*
64+
* @param node - The React node to inject options into.
65+
* @param options - The render options to inject.
66+
* @returns The node with injected options if applicable, otherwise the original node.
67+
*/
68+
export function injectRenderOptions(
69+
node: ReactNode,
70+
options?: Options,
71+
): ReactNode {
72+
if (!isValidElement(node)) return node;
73+
if (!isWithRenderOptionsComponent(node.type)) return node;
74+
const renderOptionsProps = { renderOptions: options };
75+
return cloneElement(node, renderOptionsProps);
76+
}

0 commit comments

Comments
 (0)