From baf2cb6ec85e1700e469add272959b7604721439 Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Mon, 2 Mar 2026 21:20:46 +0530 Subject: [PATCH 1/3] fix(templates): improve error handling and mock render function for async rendering --- src/templates/templates.spec.ts | 1 + src/templates/templates.ts | 23 +++++++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/templates/templates.spec.ts b/src/templates/templates.spec.ts index 02ef1c41..397232ad 100644 --- a/src/templates/templates.spec.ts +++ b/src/templates/templates.spec.ts @@ -18,6 +18,7 @@ fetchMocker.enableMocks(); const mockRenderAsync = vi.fn(); vi.mock('@react-email/render', () => ({ + render: mockRenderAsync, renderAsync: mockRenderAsync, })); diff --git a/src/templates/templates.ts b/src/templates/templates.ts index 85551e66..e081445d 100644 --- a/src/templates/templates.ts +++ b/src/templates/templates.ts @@ -54,12 +54,23 @@ export class Templates { 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`', - ); + const mod = await import('@react-email/render'); + // @ts-expect-error - renderAsync exists in older @react-email/render, not in type defs + const renderFn = (mod.render ?? mod.renderAsync) as ( + component: React.ReactElement, + ) => Promise; + if (typeof renderFn !== 'function') { + throw new Error( + 'Failed to render React component. Make sure to install `@react-email/render`', + ); + } + this.renderAsync = renderFn; + } catch (err) { + throw err instanceof Error + ? err + : new Error( + 'Failed to render React component. Make sure to install `@react-email/render`', + ); } } From d098113f6ae16b062a1f118d1ce256cb597ef5f1 Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Fri, 6 Mar 2026 07:05:40 +0530 Subject: [PATCH 2/3] refactor(templates): replace async render function with synchronous render and update tests accordingly --- src/templates/templates.spec.ts | 15 +++++++-------- src/templates/templates.ts | 30 +++--------------------------- 2 files changed, 10 insertions(+), 35 deletions(-) diff --git a/src/templates/templates.spec.ts b/src/templates/templates.spec.ts index 397232ad..f622cf4a 100644 --- a/src/templates/templates.spec.ts +++ b/src/templates/templates.spec.ts @@ -16,10 +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', () => ({ - render: mockRenderAsync, - renderAsync: mockRenderAsync, + render: mockRender, })); const TEST_API_KEY = 're_test_api_key'; @@ -152,7 +151,7 @@ describe('Templates', () => { props: { children: 'Welcome!' }, } as React.ReactElement; - mockRenderAsync.mockResolvedValueOnce('
Welcome!
'); + mockRender.mockResolvedValueOnce('
Welcome!
'); const payload: CreateTemplateOptions = { name: 'Welcome Email', @@ -182,7 +181,7 @@ describe('Templates', () => { } `); - expect(mockRenderAsync).toHaveBeenCalledWith(mockReactComponent); + expect(mockRender).toHaveBeenCalledWith(mockReactComponent); }); it('creates template with React component and all optional fields', async () => { @@ -196,7 +195,7 @@ describe('Templates', () => { }, } as React.ReactElement; - mockRenderAsync.mockResolvedValueOnce( + mockRender.mockResolvedValueOnce( '

Welcome {{{name}}}!

Welcome to {{{company}}}.

', ); @@ -245,7 +244,7 @@ describe('Templates', () => { } `); - expect(mockRenderAsync).toHaveBeenCalledWith(mockReactComponent); + expect(mockRender).toHaveBeenCalledWith(mockReactComponent); }); it('throws error when React renderer fails to load', async () => { @@ -255,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`', ); diff --git a/src/templates/templates.ts b/src/templates/templates.ts index e081445d..4c22e36e 100644 --- a/src/templates/templates.ts +++ b/src/templates/templates.ts @@ -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 { @@ -35,7 +36,6 @@ import type { } from './interfaces/update-template.interface'; export class Templates { - private renderAsync?: (component: React.ReactElement) => Promise; constructor(private readonly resend: Resend) {} create( @@ -46,37 +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> which wouldn't be chainable. private async performCreate( payload: CreateTemplateOptions, ): Promise { if (payload.react) { - if (!this.renderAsync) { - try { - const mod = await import('@react-email/render'); - // @ts-expect-error - renderAsync exists in older @react-email/render, not in type defs - const renderFn = (mod.render ?? mod.renderAsync) as ( - component: React.ReactElement, - ) => Promise; - if (typeof renderFn !== 'function') { - throw new Error( - 'Failed to render React component. Make sure to install `@react-email/render`', - ); - } - this.renderAsync = renderFn; - } catch (err) { - throw err instanceof Error - ? err - : 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 as React.ReactElement); } return this.resend.post( From b05636eca93af5424d049acad12413841b9c6e4a Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Fri, 6 Mar 2026 19:02:21 +0530 Subject: [PATCH 3/3] refactor(render): enhance render function to support both synchronous and asynchronous rendering --- src/emails/emails.ts | 3 +- src/render.spec.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/render.ts | 20 +++++++++--- src/templates/templates.ts | 2 +- 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/render.spec.ts diff --git a/src/emails/emails.ts b/src/emails/emails.ts index 61a9dda7..9c0e60b6 100644 --- a/src/emails/emails.ts +++ b/src/emails/emails.ts @@ -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'; @@ -51,7 +50,7 @@ export class Emails { options: CreateEmailRequestOptions = {}, ): Promise { if (payload.react) { - payload.html = await render(payload.react as React.ReactElement); + payload.html = await render(payload.react); } const data = await this.resend.post( diff --git a/src/render.spec.ts b/src/render.spec.ts new file mode 100644 index 00000000..d0f24306 --- /dev/null +++ b/src/render.spec.ts @@ -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('Hello'); + mockRenderAsyncFn.mockReset().mockResolvedValue('Hello'); + }); + + it('calls render when @react-email/render exports render', async () => { + const html = await render(mockReactNode); + + expect(html).toBe('Hello'); + 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( + 'Rendered via renderAsync', + ); + + const html = await render(mockReactNode); + + expect(html).toBe('Rendered via renderAsync'); + expect(mockRenderAsyncFn).toHaveBeenCalledTimes(1); + expect(mockRenderAsyncFn).toHaveBeenCalledWith(mockReactNode); + expect(mockRenderFn).not.toHaveBeenCalled(); + }); + + it('prefers render over renderAsync when both are present', async () => { + mockRenderFn.mockResolvedValue('From render'); + mockRenderAsyncFn.mockResolvedValue('From renderAsync'); + + const html = await render(mockReactNode); + + expect(html).toBe('From render'); + expect(mockRenderFn).toHaveBeenCalled(); + expect(mockRenderAsyncFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/render.ts b/src/render.ts index 424025e3..dead77ce 100644 --- a/src/render.ts +++ b/src/render.ts @@ -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 { + let mod: { + render?: (node: React.ReactNode) => Promise; + renderAsync?: (node: React.ReactNode) => Promise; + }; 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).', + ); + } + + return renderFn(node); } diff --git a/src/templates/templates.ts b/src/templates/templates.ts index 4c22e36e..fedccd83 100644 --- a/src/templates/templates.ts +++ b/src/templates/templates.ts @@ -52,7 +52,7 @@ export class Templates { payload: CreateTemplateOptions, ): Promise { if (payload.react) { - payload.html = await render(payload.react as React.ReactElement); + payload.html = await render(payload.react); } return this.resend.post(