Skip to content

feat(react-email): add prop controls to the props panel#3678

Open
gazjones00 wants to merge 9 commits into
resend:canaryfrom
gazjones00:feat/preview-props-controls
Open

feat(react-email): add prop controls to the props panel#3678
gazjones00 wants to merge 9 commits into
resend:canaryfrom
gazjones00:feat/preview-props-controls

Conversation

@gazjones00

@gazjones00 gazjones00 commented Jul 23, 2026

Copy link
Copy Markdown

Summary

The preview props panel gains a Controls tab alongside the raw JSON editor: per-prop form controls for editing a template's preview props without hand-writing JSON.

Templates can declare how each prop should be edited through a static PreviewControls property, mirroring how PreviewProps is already declared (inspired by Storybook's argTypes):

MyEmail.PreviewProps = {
  username: 'alanturing',
  plan: 'pro',
  teamSize: 3,
  isTrial: false,
};

MyEmail.PreviewControls = {
  username: { type: 'text', label: 'Username' },
  plan: { type: 'select', options: ['hobby', 'pro', 'enterprise'] },
  teamSize: { type: 'number', min: 1, max: 500 },
};

Props without a declared control get one inferred from their value (boolean → switch, finite number → number input, string → text input, everything else → raw JSON field), so existing templates get working controls with zero configuration.

Cap.2026-07-23.at.18.15.36.mp4

How it works

  • The control vocabulary (text, number with min/max/step, boolean, select, json) lives in one module, packages/ui/src/utils/preview-controls/: a zod schema validates the declaration and a pure resolver merges it with inference.
  • Validation happens server-side when the template renders; an invalid declaration logs a warning in the terminal and the panel falls back to inference — a typo never breaks the preview. Objects are strict, so misspelled fields fail loudly.
  • The validated declaration travels to the client in the rendering metadata and resolves against the applied props, so controls stay in sync with edits and hot reload picks up declaration changes.
  • Declared controls render first, in declaration order; labels default to a humanized form of the prop key (inviterName → “Inviter Name”).
  • Edits from the controls merge into a single debounced props override (booleans apply immediately); the JSON tab remains the escape hatch for values no control can express, and Reset returns to the template's defaults.

Commits

The branch is structured as three preparatory refactors (shared form-field primitives, identifier humanization, JsonEditor labelling/ref API) followed by the feature: the debounced override-commit hook, the declaration schema + resolver, and the panel itself.

Testing

  • Unit specs for the declaration schema, the resolver (declaration order, label defaults, inference fallback), the JSON draft parser, and identifier humanization; packages/ui suite passes (129 tests).
  • Verified end-to-end against the preview server: declared controls render with custom labels, select options, and number bounds; undeclared props fall back to a switch and a JSON field; role="switch" semantics via Radix Switch.
  • biome check clean; tsc clean apart from pre-existing baseline errors in untouched files.

Move the input and label styling duplicated across the send popover into
FieldLabel/TextInput and migrate the send form onto them. Also removes a
stray unlabeled checkbox left behind in the send form.
…le inference

inferEmailTitle's word-splitting core becomes a reusable humanizeIdentifier
so other surfaces can render code identifiers as readable labels.
…ling

Add a textareaRef so callers can track focus on the underlying control, and
require either an id or an aria-label at the type level so the editor always
ends up with an accessible name.
…rides

usePreviewPropsCommit owns how props-panel edits become a props override:
single-key edits merge into one pending object so edits within a debounce
window don't drop each other, raw JSON drafts are parsed after a debounce so
half-typed JSON doesn't flash errors, and both paths share a single debounced
apply since every applied value costs a server render.
Templates can declare per-prop controls for the preview props panel through
a static PreviewControls property, mirroring PreviewProps:

  MyEmail.PreviewControls = {
    username: { type: 'text', label: 'Username' },
    plan: { type: 'select', options: ['hobby', 'pro', 'enterprise'] },
    teamSize: { type: 'number', min: 1, max: 500 },
  };

The declaration is validated with zod on the server (strict, so typos fail
loudly), and a pure resolver merges it with controls inferred from prop
values: declared controls first in declaration order, inference as the
fallback so undeclared props keep working.
The props panel gains a Controls/JSON mode switch. Controls resolve from the
template's PreviewControls declaration first, then from inference: switches
for booleans (via Radix Switch), bounded number inputs, selects, text fields,
and a raw JSON field as the fallback for everything else. Labels humanize
the prop key unless the declaration provides one.

The declaration is validated when the template renders, travels to the
client through the rendering metadata, and resolves against the applied
props so controls stay in sync with edits; an invalid declaration warns in
the terminal and falls back to inference. Edits merge into a single
debounced props override, and the JSON tab remains the escape hatch for
values no control can express.
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@gazjones00 is attempting to deploy a commit to the resend Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4372de7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
react-email Minor
@react-email/editor Patch
@react-email/ui Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@socket-security

socket-security Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​radix-ui/​react-switch@​1.3.5991007099100

View full report

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f75f5dcf27

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/ui/src/components/field.tsx Outdated
Under React 18 a plain function component drops the ref prop, leaving
useFieldDraft's focus check against a null ref and letting text/number
controls snap back to applied props mid-typing.
@gazjones00 gazjones00 changed the title feat(react-email): Add per-prop controls to the preview props panel feat(react-email): add prop controls to the props panel Jul 24, 2026
@github-actions github-actions Bot added the linear-synced PR has been synced to Linear label Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linear-synced PR has been synced to Linear

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant