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
6 changes: 6 additions & 0 deletions .changeset/preview-props-editor.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 72 additions & 1 deletion packages/ui/src/actions/render-email-by-path.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string, string | undefined> = {};

// 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');
});
});
54 changes: 48 additions & 6 deletions packages/ui/src/actions/render-email-by-path.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
prettyMarkup: string;
markup: string;
/**
Expand All @@ -47,6 +52,39 @@ export type EmailRenderingResult =

const cache = new Map<string, EmailRenderingResult>();

const getCacheKey = (
emailPath: string,
previewPropsOverride: Record<string, unknown> | 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<string, unknown> => {
try {
const serialized = JSON.parse(JSON.stringify(props)) as unknown;
if (
serialized !== null &&
typeof serialized === 'object' &&
!Array.isArray(serialized)
) {
return serialized as Record<string, unknown>;
}
} 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,
Expand Down Expand Up @@ -95,6 +133,7 @@ const warnBufferer = createLogBufferer(
export const renderEmailByPath = async (
emailPath: string,
invalidatingCache = false,
previewPropsOverride?: Record<string, unknown>,
): Promise<EmailRenderingResult> => {
if (!isPathWithinEmailsDirectory(emailPath)) {
return {
Expand All @@ -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);
Expand Down Expand Up @@ -149,7 +189,7 @@ export const renderEmailByPath = async (
warnBufferer.flush();

if (!('error' in renderingResult)) {
cache.set(emailPath, renderingResult);
cache.set(cacheKey, renderingResult);
}

return renderingResult;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -368,6 +409,7 @@ const renderRawHtmlEmailByPath = async (
const plainText = toPlainText(markup);

return {
previewProps: {},
prettyMarkup,
markup,
plainText,
Expand Down
20 changes: 19 additions & 1 deletion packages/ui/src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,7 @@ export type ToolbarTabValue =
| 'linter'
| 'compatibility'
| 'spam-assassin'
| 'props'
| 'resend';

export const useToolbarState = () => {
Expand Down Expand Up @@ -179,6 +181,13 @@ const ToolbarInner = ({
Spam
</ToolbarButton>
</Tabs.Trigger>
{isRawHtmlEmail || isBuilding ? null : (
<Tabs.Trigger asChild value="props">
<ToolbarButton active={activeTab === 'props'}>
Props
</ToolbarButton>
</Tabs.Trigger>
)}
<Tabs.Trigger asChild value="resend">
<ToolbarButton active={activeTab === 'resend'}>
Resend
Expand All @@ -203,14 +212,18 @@ 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'
}
>
<IconInfo size={24} />
</ToolbarButton>
{isBuilding || activeTab === 'resend' ? null : (
{isBuilding ||
activeTab === 'resend' ||
activeTab === 'props' ? null : (
<ToolbarButton
tooltip="Reload"
disabled={lintLoading || spamLoading || compatibilityLoading}
Expand Down Expand Up @@ -312,6 +325,11 @@ const ToolbarInner = ({
<SpamAssassin result={spamCheckingResult} />
)}
</Tabs.Content>
{isRawHtmlEmail || isBuilding ? null : (
<Tabs.Content className="h-full" value="props">
<PreviewPropsEditor />
</Tabs.Content>
)}
<Tabs.Content value="resend">
{hasSetupResendIntegration ? (
<ResendIntegration
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/toolbar/copy-for-ai.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type ActiveTab =
| 'linter'
| 'compatibility'
| 'spam-assassin'
| 'props'
| 'resend'
| undefined;

Expand Down
101 changes: 101 additions & 0 deletions packages/ui/src/components/toolbar/preview-props-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use client';

import * as React from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { usePreviewContext } from '../../contexts/preview';
import { cn } from '../../utils';

/**
* JSON editor for the props the preview renders with. Edits apply live and
* only affect the preview; the template's `PreviewProps` are not modified.
*/
export const PreviewPropsEditor = () => {
const {
renderedEmailMetadata,
previewPropsOverride,
setPreviewPropsOverride,
} = usePreviewContext();

const renderedPropsJson = JSON.stringify(
renderedEmailMetadata?.previewProps ?? {},
null,
2,
);

const [value, setValue] = React.useState(renderedPropsJson);
const [parseError, setParseError] = React.useState<string | undefined>(
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<string, unknown>);
} catch (exception) {
setParseError((exception as Error).message);
}
}, 400);

return (
<div className="flex h-full flex-col gap-2 pb-4">
<div className="flex items-center justify-between gap-4">
<p className="text-slate-11">
Edit the JSON props this preview renders with. Changes apply live and
do not modify the template&apos;s <code>PreviewProps</code>.
</p>
<button
className={cn(
'shrink-0 rounded-md border border-slate-6 px-2 py-1 text-slate-11 transition-colors',
'hover:border-slate-8 hover:text-slate-12',
'disabled:opacity-40 disabled:hover:border-slate-6 disabled:hover:text-slate-11',
)}
disabled={!hasOverride}
onClick={() => {
setPreviewPropsOverride(undefined);
}}
type="button"
>
Reset to defaults
</button>
</div>
<textarea
aria-invalid={parseError !== undefined}
aria-label="Preview props JSON"
className={cn(
'grow resize-none rounded-md border border-slate-6 bg-transparent p-2 font-mono text-xs text-slate-12 outline-none',
'focus:border-slate-8',
parseError !== undefined && 'border-red-9 focus:border-red-9',
)}
onChange={(event) => {
setValue(event.currentTarget.value);
applyValue(event.currentTarget.value);
}}
spellCheck={false}
value={value}
/>
{parseError !== undefined ? (
<p className="text-red-11">{parseError}</p>
) : null}
</div>
);
};
Loading