Skip to content

Commit 9be3427

Browse files
committed
feat(render): with render options function
1 parent 2e42983 commit 9be3427

File tree

9 files changed

+169
-53
lines changed

9 files changed

+169
-53
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: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Options } from '../shared/options';
88
import { Preview } from '../shared/utils/preview';
99
import { Template } from '../shared/utils/template';
1010
import { render } from './render';
11+
import { withRenderOptions } from '../shared/with-render-options';
1112

1213
type Import = typeof import('react-dom/server') & {
1314
default: typeof import('react-dom/server');
@@ -148,21 +149,36 @@ describe('render on the browser environment', () => {
148149
await expect(render(element)).rejects.toThrowErrorMatchingSnapshot();
149150
});
150151

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

152-
it('passes render options to the rendered component', async () => {
153-
type TemplateWithOptionsProps = {
154-
reactEmailRenderOptions?: Options;
155-
};
156-
const TemplateWithOptions = ({
157-
reactEmailRenderOptions,
158-
}: TemplateWithOptionsProps) => {
159-
return JSON.stringify(reactEmailRenderOptions);
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);
160174
};
161175

162-
const actualOutput = await render(<TemplateWithOptions />, {
163-
plainText: true,
164-
});
176+
const actualOutput = await render(
177+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
178+
{ plainText: true },
179+
);
165180

166-
expect(actualOutput).toMatchInlineSnapshot('"{"plainText":true}"');
181+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"';
182+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
167183
});
168184
});

packages/render/src/browser/render.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +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 { withAdditionalProps } from '../shared/with-additional-props';
7+
import { injectRenderOptions } from '../shared/with-render-options';
88

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

@@ -40,8 +40,8 @@ const readStream = async (stream: ReactDOMServerReadableStream) => {
4040
};
4141

4242
export const render = async (node: React.ReactNode, options?: Options) => {
43-
const nodeWithAdditionalProps = withAdditionalProps(node, options);
44-
const suspendedElement = <Suspense>{nodeWithAdditionalProps}</Suspense>;
43+
const nodeWithRenderOptionsProps = injectRenderOptions(node, options);
44+
const suspendedElement = <Suspense>{nodeWithRenderOptionsProps}</Suspense>;
4545
const reactDOMServer = await import('react-dom/server.browser').then(
4646
// This is beacuse react-dom/server is CJS
4747
(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: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Options } from '../shared/options';
22
import { Preview } from '../shared/utils/preview';
33
import { Template } from '../shared/utils/template';
4+
import { withRenderOptions } from '../shared/with-render-options';
45
import { render } from './render';
56

67
type Import = typeof import('react-dom/server') & {
@@ -110,20 +111,35 @@ describe('render on the edge', () => {
110111
});
111112

112113

113-
it('passes render options to the rendered component', async () => {
114-
type TemplateWithOptionsProps = {
115-
reactEmailRenderOptions?: Options;
116-
};
117-
const TemplateWithOptions = ({
118-
reactEmailRenderOptions,
119-
}: TemplateWithOptionsProps) => {
120-
return JSON.stringify(reactEmailRenderOptions);
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);
121135
};
122136

123-
const actualOutput = await render(<TemplateWithOptions />, {
124-
plainText: true,
125-
});
137+
const actualOutput = await render(
138+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
139+
{ plainText: true },
140+
);
126141

127-
expect(actualOutput).toMatchInlineSnapshot('"{"plainText":true}"');
142+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"'
143+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
128144
});
129145
});

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

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Options } from '../shared/options';
88
import { Preview } from '../shared/utils/preview';
99
import { Template } from '../shared/utils/template';
1010
import { render } from './render';
11+
import { withRenderOptions } from '../browser';
1112

1213
type Import = typeof import('react-dom/server') & {
1314
default: typeof import('react-dom/server');
@@ -135,20 +136,36 @@ describe('render on node environments', () => {
135136
);
136137
});
137138

138-
it('passes render options to the rendered component', async () => {
139-
type TemplateWithOptionsProps = {
140-
reactEmailRenderOptions?: Options;
141-
};
142-
const TemplateWithOptions = ({
143-
reactEmailRenderOptions,
144-
}: TemplateWithOptionsProps) => {
145-
return JSON.stringify(reactEmailRenderOptions);
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);
146161
};
147162

148-
const actualOutput = await render(<TemplateWithOptions />, {
149-
plainText: true,
150-
});
163+
const actualOutput = await render(
164+
<TemplateWithOptions id="acbb4738-5a5e-4243-9b29-02cee9b8db57" />,
165+
{ plainText: true },
166+
);
151167

152-
expect(actualOutput).toMatchInlineSnapshot('"{"plainText":true}"');
168+
const expectedOutput = '"{"id":"acbb4738-5a5e-4243-9b29-02cee9b8db57"}"';
169+
expect(actualOutput).toMatchInlineSnapshot(expectedOutput);
153170
});
154171
});

packages/render/src/node/render.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +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 { withAdditionalProps } from '../shared/with-additional-props';
6+
import { injectRenderOptions } from '../shared/with-render-options';
77
import { readStream } from './read-stream';
88

99
export const render = async (node: React.ReactNode, options?: Options) => {
10-
const nodeWithAdditionalProps = withAdditionalProps(node, options);
11-
const suspendedElement = <Suspense>{nodeWithAdditionalProps}</Suspense>;
10+
const nodeWithRenderOptionsProps = injectRenderOptions(node, options);
11+
const suspendedElement = <Suspense>{nodeWithRenderOptionsProps}</Suspense>;
1212
const reactDOMServer = await import('react-dom/server').then(
1313
// This is beacuse react-dom/server is CJS
1414
(m) => m.default,

packages/render/src/shared/with-additional-props.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.
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)