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.spec.ts b/src/templates/templates.spec.ts index 02ef1c41..f622cf4a 100644 --- a/src/templates/templates.spec.ts +++ b/src/templates/templates.spec.ts @@ -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'; @@ -151,7 +151,7 @@ describe('Templates', () => { props: { children: 'Welcome!' }, } as React.ReactElement; - mockRenderAsync.mockResolvedValueOnce('
Welcome!
'); + mockRender.mockResolvedValueOnce('
Welcome!
'); const payload: CreateTemplateOptions = { name: 'Welcome Email', @@ -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 () => { @@ -195,7 +195,7 @@ describe('Templates', () => { }, } as React.ReactElement; - mockRenderAsync.mockResolvedValueOnce( + mockRender.mockResolvedValueOnce( '

Welcome {{{name}}}!

Welcome to {{{company}}}.

', ); @@ -244,7 +244,7 @@ describe('Templates', () => { } `); - expect(mockRenderAsync).toHaveBeenCalledWith(mockReactComponent); + expect(mockRender).toHaveBeenCalledWith(mockReactComponent); }); it('throws error when React renderer fails to load', async () => { @@ -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`', ); diff --git a/src/templates/templates.ts b/src/templates/templates.ts index 85551e66..fedccd83 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,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> which wouldn't be chainable. private async performCreate( payload: CreateTemplateOptions, ): Promise { 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(