diff --git a/.changeset/preview-props-editor.md b/.changeset/preview-props-editor.md new file mode 100644 index 0000000000..665146b87c --- /dev/null +++ b/.changeset/preview-props-editor.md @@ -0,0 +1,6 @@ +--- +'@react-email/ui': minor +'react-email': minor +--- + +Add a Props tab to the preview toolbar for live-editing the props a template is rendered with. Edits re-render the preview without changing the template's `PreviewProps`, invalid JSON is flagged inline, and a reset restores the template defaults. diff --git a/packages/ui/src/actions/render-email-by-path.spec.ts b/packages/ui/src/actions/render-email-by-path.spec.ts index 38ee12c89f..560bd9ca9b 100644 --- a/packages/ui/src/actions/render-email-by-path.spec.ts +++ b/packages/ui/src/actions/render-email-by-path.spec.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { renderEmailByPath } from './render-email-by-path'; describe('renderEmailByPath() with raw .html templates', () => { @@ -67,3 +67,74 @@ describe('renderEmailByPath() with raw .html templates', () => { expect('error' in result).toBe(true); }); }); + +describe('renderEmailByPath() with preview props overrides', () => { + const emailsRoot = path.resolve(__dirname, '../utils/testing'); + const emailPath = path.join(emailsRoot, 'vercel-invite-user.tsx'); + const previewServerRoot = path.resolve(__dirname, '../..'); + + const managedEnv = { + REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH: emailsRoot, + REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerRoot, + REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: previewServerRoot, + }; + const previousEnvValues: Record = {}; + + // src/app/env.ts reads these at module evaluation, so the module graph is + // re-imported after the environment is prepared. + let render: typeof renderEmailByPath; + + beforeAll(async () => { + for (const [name, value] of Object.entries(managedEnv)) { + previousEnvValues[name] = process.env[name]; + process.env[name] = value; + } + vi.resetModules(); + ({ renderEmailByPath: render } = await import('./render-email-by-path')); + }); + + afterAll(() => { + for (const name of Object.keys(managedEnv)) { + if (previousEnvValues[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = previousEnvValues[name]; + } + } + }); + + it('exposes the resolved PreviewProps of the render', { + timeout: 15_000, + }, async () => { + const result = await render(emailPath, true); + + expect('error' in result).toBe(false); + if ('error' in result) return; + + expect(result.previewProps.username).toBe('alanturing'); + expect(result.markup).toContain('alanturing'); + }); + + it('renders with overridden props without corrupting the default cache', { + timeout: 15_000, + }, async () => { + const overridden = await render(emailPath, false, { + username: 'adalovelace', + }); + + expect('error' in overridden).toBe(false); + if ('error' in overridden) return; + + expect(overridden.previewProps.username).toBe('adalovelace'); + expect(overridden.markup).toContain('adalovelace'); + expect(overridden.markup).not.toContain('alanturing'); + + const defaults = await render(emailPath, false); + + expect('error' in defaults).toBe(false); + if ('error' in defaults) return; + + expect(defaults.previewProps.username).toBe('alanturing'); + expect(defaults.markup).toContain('alanturing'); + }); +}); diff --git a/packages/ui/src/actions/render-email-by-path.tsx b/packages/ui/src/actions/render-email-by-path.tsx index d3bbda0846..aafe86b08f 100644 --- a/packages/ui/src/actions/render-email-by-path.tsx +++ b/packages/ui/src/actions/render-email-by-path.tsx @@ -25,6 +25,11 @@ import { styleText } from '../utils/style-text'; import type { ErrorObject } from '../utils/types/error-object'; export interface RenderedEmailMetadata { + /** + * JSON-safe props this render used: the `previewPropsOverride` when one was + * given, otherwise the template's own `PreviewProps`. + */ + previewProps: Record; prettyMarkup: string; markup: string; /** @@ -47,6 +52,39 @@ export type EmailRenderingResult = const cache = new Map(); +const getCacheKey = ( + emailPath: string, + previewPropsOverride: Record | undefined, +) => + previewPropsOverride === undefined + ? emailPath + : `${emailPath}\0${JSON.stringify(previewPropsOverride)}`; + +const invalidateCacheFor = (emailPath: string) => { + for (const key of cache.keys()) { + if (key === emailPath || key.startsWith(`${emailPath}\0`)) { + cache.delete(key); + } + } +}; + +const toJsonSafeProps = (props: unknown): Record => { + try { + const serialized = JSON.parse(JSON.stringify(props)) as unknown; + if ( + serialized !== null && + typeof serialized === 'object' && + !Array.isArray(serialized) + ) { + return serialized as Record; + } + } catch (_exception) { + // Props containing non-serializable values (functions, elements) cannot + // round-trip into the editor; fall through to an empty object. + } + return {}; +}; + const createLogBufferer = ( originalLogger: (...args: any[]) => void, overwriteLogger: (logger: (...args: any[]) => void) => void, @@ -95,6 +133,7 @@ const warnBufferer = createLogBufferer( export const renderEmailByPath = async ( emailPath: string, invalidatingCache = false, + previewPropsOverride?: Record, ): Promise => { if (!isPathWithinEmailsDirectory(emailPath)) { return { @@ -107,11 +146,12 @@ export const renderEmailByPath = async ( } if (invalidatingCache) { - cache.delete(emailPath); + invalidateCacheFor(emailPath); } - if (cache.has(emailPath)) { - return cache.get(emailPath)!; + const cacheKey = getCacheKey(emailPath, previewPropsOverride); + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; } const emailFilename = path.basename(emailPath); @@ -149,7 +189,7 @@ export const renderEmailByPath = async ( warnBufferer.flush(); if (!('error' in renderingResult)) { - cache.set(emailPath, renderingResult); + cache.set(cacheKey, renderingResult); } return renderingResult; @@ -188,7 +228,7 @@ export const renderEmailByPath = async ( sourceMapToOriginalFile, } = componentResult; - const previewProps = Email.PreviewProps || {}; + const previewProps = previewPropsOverride ?? Email.PreviewProps ?? {}; const EmailComponent = Email as React.FunctionComponent; try { const timeBeforeEmailRendered = performance.now(); @@ -227,6 +267,7 @@ export const renderEmailByPath = async ( warnBufferer.flush(); const renderingResult: RenderedEmailMetadata = { + previewProps: toJsonSafeProps(previewProps), prettyMarkup, // This ensures that no null byte character ends up in the rendered // markup making users suspect of any issues. These null byte characters @@ -240,7 +281,7 @@ export const renderEmailByPath = async ( extname: path.extname(emailPath).slice(1), }; - cache.set(emailPath, renderingResult); + cache.set(cacheKey, renderingResult); return renderingResult; } catch (exception) { @@ -368,6 +409,7 @@ const renderRawHtmlEmailByPath = async ( const plainText = toPlainText(markup); return { + previewProps: {}, prettyMarkup, markup, plainText, diff --git a/packages/ui/src/components/toolbar.tsx b/packages/ui/src/components/toolbar.tsx index 0c4f4a7f7b..f0df7fb636 100644 --- a/packages/ui/src/components/toolbar.tsx +++ b/packages/ui/src/components/toolbar.tsx @@ -20,6 +20,7 @@ import { IconReload } from './icons/icon-reload'; import { Compatibility, useCompatibility } from './toolbar/compatibility'; import { CopyForAI } from './toolbar/copy-for-ai'; import { Linter, type LintingRow, useLinter } from './toolbar/linter'; +import { PreviewPropsEditor } from './toolbar/preview-props-editor'; import { ResendIntegration } from './toolbar/resend'; import { SpamAssassin, @@ -32,6 +33,7 @@ export type ToolbarTabValue = | 'linter' | 'compatibility' | 'spam-assassin' + | 'props' | 'resend'; export const useToolbarState = () => { @@ -179,6 +181,13 @@ const ToolbarInner = ({ Spam + {isRawHtmlEmail || isBuilding ? null : ( + + + Props + + + )} Resend @@ -203,6 +212,8 @@ const ToolbarInner = ({ 'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') || (activeTab === 'compatibility' && 'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') || + (activeTab === 'props' && + 'The Props tab lets you edit the props the preview renders with, to try out different content without changing the template.') || (activeTab === 'resend' && 'The Resend tab allows you to upload your React Email code using the Resend Templates API.') || 'Info' @@ -210,7 +221,9 @@ const ToolbarInner = ({ > - {isBuilding || activeTab === 'resend' ? null : ( + {isBuilding || + activeTab === 'resend' || + activeTab === 'props' ? null : ( )} + {isRawHtmlEmail || isBuilding ? null : ( + + + + )} {hasSetupResendIntegration ? ( { + const { + renderedEmailMetadata, + previewPropsOverride, + setPreviewPropsOverride, + } = usePreviewContext(); + + const renderedPropsJson = JSON.stringify( + renderedEmailMetadata?.previewProps ?? {}, + null, + 2, + ); + + const [value, setValue] = React.useState(renderedPropsJson); + const [parseError, setParseError] = React.useState( + undefined, + ); + + const hasOverride = previewPropsOverride !== undefined; + + // Without an override active, the rendered props follow the template file + // (initial load, hot reload, reset), so the editor mirrors them. + React.useEffect(() => { + if (!hasOverride) { + setValue(renderedPropsJson); + setParseError(undefined); + } + }, [hasOverride, renderedPropsJson]); + + const applyValue = useDebouncedCallback((newValue: string) => { + try { + const parsed = JSON.parse(newValue) as unknown; + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + setParseError('Props must be a JSON object'); + return; + } + setParseError(undefined); + setPreviewPropsOverride(parsed as Record); + } catch (exception) { + setParseError((exception as Error).message); + } + }, 400); + + return ( +
+
+

+ Edit the JSON props this preview renders with. Changes apply live and + do not modify the template's PreviewProps. +

+ +
+