Skip to content

Commit 293bfa2

Browse files
committed
feat(render): add withRenderOptions high-order function
1 parent b2407a1 commit 293bfa2

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)