Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/emails/emails.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type * as React from 'react';
import { buildPaginationQuery } from '../common/utils/build-pagination-query';
import { parseEmailToApiOptions } from '../common/utils/parse-email-to-api-options';
import { render } from '../render';
Expand Down Expand Up @@ -51,7 +50,7 @@ export class Emails {
options: CreateEmailRequestOptions = {},
): Promise<CreateEmailResponse> {
if (payload.react) {
payload.html = await render(payload.react as React.ReactElement);
payload.html = await render(payload.react);
}

const data = await this.resend.post<CreateEmailResponseSuccess>(
Expand Down
64 changes: 64 additions & 0 deletions src/render.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type * as React from 'react';
import { vi } from 'vitest';
import { render } from './render';

const mockRenderFn = vi.fn();
const mockRenderAsyncFn = vi.fn();

let packageExports: 'both' | 'renderOnly' | 'renderAsyncOnly' = 'both';

vi.mock('@react-email/render', () => ({
get render() {
return packageExports === 'renderAsyncOnly' ? undefined : mockRenderFn;
},
get renderAsync() {
return packageExports === 'renderOnly' ? undefined : mockRenderAsyncFn;
},
}));

describe('render', () => {
const mockReactNode = {
type: 'div',
props: { children: 'Hello' },
} as React.ReactElement;

beforeEach(() => {
packageExports = 'both';
mockRenderFn.mockReset().mockResolvedValue('<html>Hello</html>');
mockRenderAsyncFn.mockReset().mockResolvedValue('<html>Hello</html>');
});

it('calls render when @react-email/render exports render', async () => {
const html = await render(mockReactNode);

expect(html).toBe('<html>Hello</html>');
expect(mockRenderFn).toHaveBeenCalledTimes(1);
expect(mockRenderFn).toHaveBeenCalledWith(mockReactNode);
expect(mockRenderAsyncFn).not.toHaveBeenCalled();
});

it('falls back to renderAsync when package only exports renderAsync', async () => {
packageExports = 'renderAsyncOnly';
mockRenderAsyncFn.mockResolvedValue(
'<html>Rendered via renderAsync</html>',
);

const html = await render(mockReactNode);

expect(html).toBe('<html>Rendered via renderAsync</html>');
expect(mockRenderAsyncFn).toHaveBeenCalledTimes(1);
expect(mockRenderAsyncFn).toHaveBeenCalledWith(mockReactNode);
expect(mockRenderFn).not.toHaveBeenCalled();
});

it('prefers render over renderAsync when both are present', async () => {
mockRenderFn.mockResolvedValue('<html>From render</html>');
mockRenderAsyncFn.mockResolvedValue('<html>From renderAsync</html>');

const html = await render(mockReactNode);

expect(html).toBe('<html>From render</html>');
expect(mockRenderFn).toHaveBeenCalled();
expect(mockRenderAsyncFn).not.toHaveBeenCalled();
});
});
20 changes: 16 additions & 4 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
export async function render(node: React.ReactNode) {
let render: typeof import('@react-email/render').render;
import type * as React from 'react';

export async function render(node: React.ReactNode): Promise<string> {
let mod: {
render?: (node: React.ReactNode) => Promise<string>;
renderAsync?: (node: React.ReactNode) => Promise<string>;
};
try {
({ render } = await import('@react-email/render'));
mod = await import('@react-email/render');
} catch {
throw new Error(
'Failed to render React component. Make sure to install `@react-email/render` or `@react-email/components`.',
);
}

return render(node);
const renderFn = mod.render ?? mod.renderAsync;
if (typeof renderFn !== 'function') {
throw new Error(
'Failed to render React component. Make sure to install `@react-email/render` (or an older version with renderAsync).',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'Failed to render React component. Make sure to install `@react-email/render` (or an older version with renderAsync).',
'Could not find the React Email render function, this is most likely a bug, please file an issue.',

);
}

return renderFn(node);
}
14 changes: 7 additions & 7 deletions src/templates/templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import type { UpdateTemplateOptions } from './interfaces/update-template.interfa
const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks();

const mockRenderAsync = vi.fn();
const mockRender = vi.fn();
vi.mock('@react-email/render', () => ({
renderAsync: mockRenderAsync,
render: mockRender,
}));

const TEST_API_KEY = 're_test_api_key';
Expand Down Expand Up @@ -151,7 +151,7 @@ describe('Templates', () => {
props: { children: 'Welcome!' },
} as React.ReactElement;

mockRenderAsync.mockResolvedValueOnce('<div>Welcome!</div>');
mockRender.mockResolvedValueOnce('<div>Welcome!</div>');

const payload: CreateTemplateOptions = {
name: 'Welcome Email',
Expand Down Expand Up @@ -181,7 +181,7 @@ describe('Templates', () => {
}
`);

expect(mockRenderAsync).toHaveBeenCalledWith(mockReactComponent);
expect(mockRender).toHaveBeenCalledWith(mockReactComponent);
});

it('creates template with React component and all optional fields', async () => {
Expand All @@ -195,7 +195,7 @@ describe('Templates', () => {
},
} as React.ReactElement;

mockRenderAsync.mockResolvedValueOnce(
mockRender.mockResolvedValueOnce(
'<div><h1>Welcome {{{name}}}!</h1><p>Welcome to {{{company}}}.</p></div>',
);

Expand Down Expand Up @@ -244,7 +244,7 @@ describe('Templates', () => {
}
`);

expect(mockRenderAsync).toHaveBeenCalledWith(mockReactComponent);
expect(mockRender).toHaveBeenCalledWith(mockReactComponent);
});

it('throws error when React renderer fails to load', async () => {
Expand All @@ -254,7 +254,7 @@ describe('Templates', () => {
} as React.ReactElement;

// Temporarily clear the mock implementation to simulate module load failure
mockRenderAsync.mockImplementationOnce(() => {
mockRender.mockImplementationOnce(() => {
throw new Error(
'Failed to render React component. Make sure to install `@react-email/render`',
);
Expand Down
19 changes: 3 additions & 16 deletions src/templates/templates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PaginationOptions } from '../common/interfaces';
import { getPaginationQueryProperties } from '../common/utils/get-pagination-query-properties';
import { parseTemplateToApiOptions } from '../common/utils/parse-template-to-api-options';
import { render } from '../render';
import type { Resend } from '../resend';
import { ChainableTemplateResult } from './chainable-template-result';
import type {
Expand Down Expand Up @@ -35,7 +36,6 @@ import type {
} from './interfaces/update-template.interface';

export class Templates {
private renderAsync?: (component: React.ReactElement) => Promise<string>;
constructor(private readonly resend: Resend) {}

create(
Expand All @@ -46,26 +46,13 @@ export class Templates {
}
// This creation process is being done separately from the public create so that
// the user can chain the publish operation after the create operation. Otherwise, due
// to the async nature of the renderAsync, the return type would be
// to the async nature of the render, the return type would be
// Promise<ChainableTemplateResult<CreateTemplateResponse>> which wouldn't be chainable.
private async performCreate(
payload: CreateTemplateOptions,
): Promise<CreateTemplateResponse> {
if (payload.react) {
if (!this.renderAsync) {
try {
const { renderAsync } = await import('@react-email/render');
this.renderAsync = renderAsync;
} catch {
throw new Error(
'Failed to render React component. Make sure to install `@react-email/render`',
);
}
}

payload.html = await this.renderAsync(
payload.react as React.ReactElement,
);
payload.html = await render(payload.react);
}

return this.resend.post<CreateTemplateResponseSuccess>(
Expand Down
Loading