Skip to content

@unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes - #69

Open
ivoIturrieta wants to merge 125 commits into
mainfrom
feat/react-elements-0.3
Open

ivoIturrieta wants to merge 125 commits into
mainfrom
feat/react-elements-0.3

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

What

The next @unlayer/react-elements release (0.3.0): a new render engine, formats developers already use, a design → code converter, and fixes so the HTML and the design that opens in the Unlayer editor always agree. No API is removed. One deliberate behavior change: a part that fails to render now fails the render (see below).

A new render engine

0.2 rendered templates with react-dom: one render collected the template through markers written into its HTML, then about 15 more rendered the rows. In Next.js App Router server code (two React copies) that broke: components using hooks were dropped and async emails came out with no rows, with only a warning.

0.3 walks the template itself and answers its hooks (useContext, useId, useMemo, use, …) as React's server renderer does. Elements never calls react-dom (it stays a peer dependency only because @unlayer/exporters imports it).

  • Works where React works (tested): Node, the edge runtime, Next.js route handlers, Server Components and Client Components (webpack and Turbopack), React 18 and 19.
  • Components run once per render; async siblings load in parallel and always land in tree order.
  • Its HTML is react-dom's: the markup it writes matches react-dom/server's static markup for the installed React (18 or 19), byte for byte: form controls (<select value>, <textarea>, inputs), <style>/<script>/<title> text, attribute order, void elements. A differential test checks every element and attribute kind React treats specially (about 115,000 cases) and random trees, on React 18.0, 18.3 and 19.3. Four differences are deliberate and listed in that test: React 19 moving <title>/<meta>/<link> to the top, the <head> it adds to a bare <html>, function form actions, and a failure inside <Suspense>, which follows Elements' error policy. In <Head>, a <style>'s or <script>'s CSS and JS are written as given.
  • Renders are isolated: a render's output and errors depend only on its own arguments. What it works out lives in its own scope, every public render starts a fresh one, and your code (components, wrappers, exporters, callbacks) runs outside it, so a render started inside another can't change it, and elements kept between renders can't carry anything over. A seeded test renders random pairs of templates that share hoisted elements, in both orders and at once, and checks each output never changes.
  • Errors name the component and where it sits: [Unlayer] boom (in <Hero> at Row 3 › Column 1 › Hero), a RenderError with the original as cause. A failure throws by default, in the sync and async renderers alike; onError: "omit" (or a function) leaves the failed part out of the HTML, the text and the design alike and lists it in renderToHtmlParts().omitted. That covers every part: components, Rows and Columns, exporters, template wrappers, a custom tool's head, and what's in a <Head>.
  • The design is the source of truth: the HTML is the exporters run on the design. The design no longer renders the HTML, so it's fast.
  • React compatibility is checked, not assumed: a once-per-process self-check of React's internals; an unknown React that passes works, one that doesn't fails with "unsupported React x.y.z" (UNLAYER_SKIP_REACT_CHECK=1 to bypass). CI runs the suite on React 18.0, 18.2, 18.3, 19.1, 19.2 and 19.3 in dev and prod (and the release job publishes only after that matrix passes), compares useId with react-dom's own, and runs a weekly canary.
  • useId() gives React's own ids (no internal prefix); idPrefix sets a prefix for emails embedded in a page.
  • Divider, Html, Menu and Social write their web and document HTML as strings, byte-identical to the pinned exporters (a 12k-case test that's also the drift alarm), so pages and documents render on the edge too.
  • packages/react/ARCHITECTURE.md explains the pipeline, the dispatcher, the error policy and the invariants.

New entries (separate imports, so the main bundle stays small)

Entry What it gives you
/tailwind <Tailwind>: classes become each component's props at render time, so the email gets inline styles and the design stays editable. Phone variants become phone settings, hover: becomes button hover colors, container text classes reach the text inside, card grids get gutters, and logical classes (ps-*, ms-*, border-s-*, rounded-s-*, text-start) follow the text direction. What it compiles stays bounded in a long-lived server (the last 50 configs; a fresh compiler past 2,000 classes).
/markdown <Markdown>: Markdown becomes editable blocks. Raw HTML in it (html="sanitize", the default) is read once, as a browser reads it, and written back with only display tags and attributes (allowlists, URLs and styles checked), so nothing removed can rejoin into a tag; a fuzz test reads its output back with a real HTML parser.
/code CodeBlock / CodeInline with highlight.js themes.
/font <Font>: web fonts with fallbacks. The URL is kept in the design.
/head <Head>: custom head tags. Components in it render as the template's do (context, async, onError).
/async Async templates and components (React.lazy too), Suspense, and pretty output. A <Suspense> around the root is waited for: its fallback can't make a document, so it never becomes the email.
/codegen designToElements(design): an Unlayer design becomes readable Elements code. The round trip is exact on 48 of 49 real designs and on fuzzed ones.

Main entry

  • Link.
  • style, className and skipInText on every component.
  • Plain-text options and toPlainText.
  • Prop aliases: Menu color, align/textAlign, Heading as/level. Close misspellings get "did you mean" warnings.
  • values accepts partial objects with strict keys (ValuesInput).
  • Rich inline JSX children.

HTML / editor parity fixes

  • <UnlayerProvider config> now applies. The most specific setting wins: render options, then the root's config, then the Provider, then defaults. Safe in Server Components and on the edge.
  • Paragraph text is stored as typed, so the editor no longer formats markup the email shows literally.
  • Exact px image widths.
  • Bare zeros are written as 0px in designs.
  • The Row background-image fullWidth default agrees between HTML and design.
  • The body link color applies in the HTML.
  • url() values are percent-encoded.
  • Right-to-left dir/lang on the body.
  • A Row whose Column count doesn't match its layout fails the render with an error naming it (or is omitted with onError: "omit"), instead of blanking the email.
  • Render errors keep their cause.
  • Checked against the real editor (load, save and export 152 templates in the Unlayer editor): a Paragraph's HTML is now the editor's text block (a percent line height, no <p> wrapper), so lines with mixed text sizes no longer shift; a root's fonts reach the design; the document has the editor's base stylesheet for each mode; images and video thumbnails are sized in the block's slot (the default 10px padding), not the column; a fixed-width image's percent is the same in HTML and design; and the design records displayMode, so code generated from it picks the right root. A per-block test checks every block's HTML equals the editor's export of its design, in all three modes.

Row padding keeps the editor's meaning (outer padding, in email, web and print). Elements now warns when it sits outside a content background; use Column padding to inset. Tailwind p-* on a Row insets, as CSS does.

Validation

  • Tests: react 1540, shared 126, convert-core 212, from-react-email 344, migrate 212. Typecheck, test:csp, test:browser-runtime, test:e2e, test:fidelity and test:differential pass. The React matrix (18.0.0, 18.2.0, 18.3.1, 19.1.0, 19.2.9, 19.3.1; dev and prod) passes.
  • Same output as the 0.2 engine: 956/956 outputs byte-identical on React 18.3, 19.1 and 19.3 across the corpus (examples, patterns, showcase, the 152 editor round-trip templates, migrate outputs, i18n), and 45,000 random templates with 0 differences, apart from the intended changes (errors throw, clean ids, react-intl templates that 0.2 rendered without their translations now render fully).
  • Visual: Storybook builds and test:visual matches on 138 stories × 2 viewports. The baseline was regenerated after attributing every changed render to intended changes (exact image widths, the Provider fix, the text block's markup, images sized in their slot).
  • Integrations: the packed tarball builds and verifies in the Next.js 14, 15 (webpack and Turbopack) and 16 apps and the Jest 30 app: route handlers, Server Components, Client Components, async routes and pages, App Router and Pages Router edge routes, with hooks used after Elements' renders.
  • Speed (median, production, vs 0.1.22): renderToHtml 0.97–0.99×, renderToPlainText about 0.96×, renderToJson 1.3–1.45× (0.11 ms for a 12-row email); against 0.2's engine, HTML takes about half the time and JSON about a third.
  • Bundle: the main entry is 198,363 / 198,500 bytes unminified (it now carries Elements' own renderer and HTML writer instead of using react-dom's).
  • Real editor round trip (152 templates: feature tests, showcase, CLI starters, all docs patterns and starters, MJML and Beefree imports; editor 1.524.0, re-run on the new engine with 1.526.0 with identical results): every design loads, saves and exports with nothing lost, and the code generated back from it type-checks and gives the same design. Elements' HTML looks identical to the editor's export on desktop for 134/152 (115 before these fixes). The rest are the editor's own behavior (its stylesheet for Google fonts it lists, phone settings that need mobile design editing), the HTML-only props, or the newer editor's page margin for documents. The one Elements difference left is sub-pixel: a fixed-width image keeps the exact px the author asked for (64px), where the editor derives 63.97px from the design's percent.
  • Tailwind accuracy, measured against real Tailwind 4.2.1 in Chrome on 263 classes: 96.9% render like Tailwind in email and 95.7% on web; 168/170 colors are byte-exact; line heights are within 0.5px.

Release notes

  • Releases as 0.3.0 (minor): a new engine, new entries and changed HTML output, no removed API. Failures now throw by default (onError: "omit" keeps omission, listed); the upgrade guide in the guides PR covers it. The version is committed in packages/react/package.json, which the release job publishes instead of its default patch. Without it, this would ship as 0.2.2 to every ^0.2 project.
  • @unlayer/migrate's Elements range goes to ^0.3.0 here. The release job refuses an Elements outside the published migrate's range, so migrate then republishes after it (0.1.3, the same converter) and asks projects on 0.2.x to upgrade first.
  • The CHANGELOG lists everything new in 0.3 (every new entry, <Link>, style/className, the plain-text options, inline JSX), what changed, and the history from 0.1.1 to 0.2.1.
  • After publishing, move the @unlayer/react-elements-previous alias to 0.3.0.

📖 Storybook Preview: https://unlayer.github.io/elements/pr/69/

ivoIturrieta and others added 11 commits October 15, 2026 11:20
…nd codegen

New entries, each its own import so the main bundle stays within budget:
- `/tailwind`: <Tailwind> turns classes into each component's props at render time, so the
  email gets inline styles and the design stays editable; classes no prop takes become inline
  styles (HTML only, warned); max-sm: → phone settings, hover: → button hover colors. Container
  text classes reach the text inside, Row padding classes inset content and card grids get
  gutters built from borders in the surrounding color.
- `/markdown` (Markdown → blocks), `/code` (CodeBlock, CodeInline, highlight.js themes), `/font`
  (<Font> with fallbacks, URL kept in the design), `/head` (<Head>), `/async` (async templates and
  components, Suspense, `pretty`), `/codegen` (designToElements: an Unlayer design → readable
  Elements code; exact round trip on 48 of 49 real designs and fuzzed ones).
- Main entry: Link, style/className/skipInText on every component, plain-text options and
  toPlainText, prop aliases (Menu color, align/textAlign, Heading as/level) with "did you mean"
  warnings, `values` as ValuesInput (partial, strict keys), rich inline children.

Editor parity fixes (HTML and design now agree): UnlayerProvider config applies (render options >
root config > Provider > defaults); Paragraph text is stored as typed; exact px image widths;
bare zeros written as 0px; Row background-image fullWidth default; the body link color selector;
url() values percent-encoded; right-to-left dir/lang on body; a Row whose Column count doesn't
match its layout is left out alone, with an error; render errors keep their cause. Row padding
stays the editor's outer padding, with a warning when it sits outside a content background.

The main bundle budget rises from 118 KB to 126 KB (125,962 bytes now).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Guides for Tailwind, Markdown, code blocks, fonts, async templates and codegen; README sections
for Link, Head, aliases, the Provider precedence and Row padding; CHANGELOG Unreleased entries.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
CI's main-entry budget goes to 126,000 bytes. The Next.js 14/15/16 integration apps also render
a template inside an UnlayerProvider (route handler, Server Component, Client Component and the
Pages edge route) and verify the right-to-left HTML and design.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ding

A container with margins around a dark header and footer must stay row-level padding in the
design, the outer container in the HTML and `padding` in the TSX, so the gap shows the page
background.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A round trip through the real editor (load, save, export) showed where
renderToHtml and the editor's export of renderToJson's design differ:

- A Paragraph rendered through the Lexical paragraph exporter (a
  unitless line height and a <p> wrapper) while its design holds a text
  block, which the editor exports with a percent line height and no
  wrapper, so lines with text of another size moved. Both now use the
  text block, and formatted textJson stays HTML in the design.
- A root's `fonts` never reached the design. Families a font URL names
  now get the URL on their fontFamily, as <Font> does; anything else is
  warned about once.
- The document shell lacked the editor's base stylesheet for each mode.
- Images and video thumbnails were sized against the column, not the
  slot the default 10px block padding leaves, so they ran 20px wide.
- A pinned image's percent was rounded up in the HTML and to the
  nearest in the design.
- The design now holds the body's displayMode, which the editor keeps
  and codegen reads to pick the root.

A per-block test checks each block's HTML equals its design's exporter
output in all three modes. The bundle budget grows by about 3.9KB.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…check

The converted template's fonts are already listed in the report, so the
new warning for a root font the design can't hold isn't a lost block.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…Mode

Also say that the editor links its own stylesheet for the Google fonts
it already lists, with weights 400 and 700.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ender

With async templates under way at once, <Head> and <Font> recorded into
the top of the shared collection stack, which can be another template's:
one request's head tags and fonts could land in another's email. They now
find their template's collection by the render's ids, as Rows and blocks
do. Font's pure helpers move to utils/font-design so codegen still imports
nothing that renders.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A component's lone <a> became a Paragraph holding only what text can
hold, so a linked image was lost, and an <hr> with a style became a
default Divider. Each becomes a block only when the block keeps all of
it; anything else stays an HTML block, as in 0.2.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
String.fromCodePoint threw on a reference past U+10FFFF, so untrusted
Markdown could make <Markdown> throw and its Column be left out.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.17095% with 141 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.83%. Comparing base (eb0a80d) to head (bc81fd5).

Files with missing lines Patch % Lines
packages/react/src/engine/walk.ts 91.19% 37 Missing ⚠️
packages/react/src/engine/dispatcher.ts 85.54% 25 Missing ⚠️
packages/react/src/markdown/Markdown.tsx 93.61% 12 Missing ⚠️
packages/react/src/codegen/blocks.ts 96.68% 8 Missing ⚠️
packages/react/src/engine/tree-id.ts 76.47% 8 Missing ⚠️
packages/react/src/async/index.ts 86.27% 7 Missing ⚠️
packages/react/src/codegen/test-harness.ts 95.10% 7 Missing ⚠️
packages/react/src/codegen/device.ts 89.65% 6 Missing ⚠️
packages/react/src/codegen/design-to-elements.ts 96.11% 4 Missing ⚠️
packages/react/src/codegen/print.ts 97.54% 4 Missing ⚠️
... and 10 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #69      +/-   ##
==========================================
+ Coverage   96.55%   97.83%   +1.28%     
==========================================
  Files          40      122      +82     
  Lines        2789     8466    +5677     
  Branches      795     2999    +2204     
==========================================
+ Hits         2693     8283    +5590     
- Misses         95      183      +88     
+ Partials        1        0       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… hold it

A bare <a> in a Column became a Paragraph only when its markup was
exactly what text holds, so a link with another attribute (aria-label)
was left out, call to action and all. A link whose tags text holds is a
Paragraph again (other attributes aside); one holding more (an image),
or a rule with a style, is an HTML block as it is, instead of a block
that loses part of it. A component's markup still becomes a block only
when nothing is lost, and is otherwise its own HTML block.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
@ivoIturrieta
ivoIturrieta marked this pull request as ready for review October 15, 2026 16:58
…o it

The release job bumps a patch unless a newer version is committed, so
this would have published 0.2.2 and given every ^0.2 project the
changed HTML. And it refuses an Elements outside the published
@unlayer/migrate's range (^0.2.0) unless the range is raised in the
same change. Committing 0.3.0 and raising migrate's range to ^0.3.0
lets Elements publish as 0.3.0 and migrate republish after it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta and others added 2 commits October 16, 2026 08:00
0.2.1 only exported the main entry, so Tailwind, Markdown, Code, Font,
Head and Async are new entries in 0.3, with <Link>, style/className/
skipInText, the plain-text options and inline JSX children. The
changelog listed few of them, and Tailwind's details as fixes. Also
adds the RTL alignment, Column markup and async error changes, and
drops mentions of CLI commands that aren't released.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The changelog went from Unreleased straight to 0.1.0. Each release since
has its entry now, from its release notes and pull requests.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
renderToHtml rethrew "[Unlayer] renderToHtml failed: …" without the error it caught, so tools
lost the template's stack and line numbers. The rethrown error now carries it as `cause`.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta and others added 9 commits October 16, 2026 18:36
…om's

In Next.js App Router (route handlers and Server Components run on two
React copies) and anywhere else the copies differ, the async renderers
never detected it, so Rows rendered as plain HTML: a web layout with 3
tables instead of 14 and a design with no rows, with only a warning.
They now probe as the sync path does and record without useId, kept
apart per render by AsyncLocalStorage (one at a time where there's
none). A render whose Rows still weren't collected now throws instead
of returning a broken email, sync and async alike; empty, text-only and
head-only templates don't.

The bundle budget grows by 1 KB for it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
An async route handler and an async Server Component in the Next 15 and
16 apps, checked to give the synchronous renderers' tables and rows.
Both failed before the fix (3 tables, no rows).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A part of a template that fails (a component that throws, a Row whose
Columns don't fit its layout, an exporter that fails) will be a
RenderError naming the component and where it is, with the original
error as its cause. `onError` on UnlayerConfig (so in render options,
a root's `config` and an UnlayerProvider) says what a render does with
it: "throw" (the default), "omit", or a function that's given it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A renderer of React element trees that writes what react-dom's static
server render writes, without react-dom: function components called
with Elements' own hooks dispatcher (put in React's slot around each
call and taken out after, never across an await), class components,
Context, Fragments, memo, forwardRef, lazy and Suspense, and host
markup with React's attributes, escaping and useId()s (Fizz's tree
ids, in the installed React's format).

The async walk leaves a hole where a component suspends and walks on,
so siblings' promises start at once; the output, the records and the
error a render fails with are read in tree order whatever settles
first. A collecting walk asks a collector about each element, so the
template's Rows, Columns and blocks can be recorded instead of called.

A table of the React versions it's tested with (18.3 to 19.3) and a
once-per-process check of the installed React's shapes: an unknown
version that passes renders (with a warning in development), one that
fails throws a named error unless UNLAYER_SKIP_REACT_CHECK=1. A hook
it doesn't know fails with a named error. Development builds get
React's key warnings for lists.

Nothing uses it yet. Tests compare it with react-dom/server on
hand-written and random trees.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Elements' own renders no longer use react-dom: the walker collects a
template (Rows, Columns, blocks, <Tailwind> scopes and <Head>/<Font>
entries recorded by their markers, of any copy of Elements) and writes
Elements' markup. The downstream is today's: marks parsed into the
collected tree, which the HTML, the head and the design are made from.

Gone with react-dom: the collecting react-dom render and the Sentinel
that told it apart by useId(); blind mode, its probe and the async
entry's AsyncLocalStorage runner (src/async/running.ts); the streaming
render (src/async/stream.ts); the second react-dom module hunting and
the per-render build stack (src/utils/server-renderer.ts); calling a
component again to say why it failed; the template wrappers' throwaway
renders; the shared collecting and async-collection stacks; the check
for Rows rendered as HTML, which can't happen now. Template wrappers
(class components too, as react-intl's IntlProvider) are called once,
inside the providers around them; components run once per render.

A part that fails now fails the render, the synchronous and async ones
alike, renderToJson and renderToPlainText too: a RenderError naming
the component and where it is ("in <Hero> at Row 3 › Column 1 ›
Hero"), its cause the original error. With `onError: "omit"` it's left
out of the HTML, the plain text and the design alike, said once with
console.error and listed in renderToHtmlParts' `omitted`; a function
gets it instead. renderToJson renders the body's HTML too, so an
exporter that fails can't be in the design and not in the HTML, and
takes an `onError` option. Tests that expected parts to be left out
silently now expect the throw, and the omission with `onError`.

A component's useId() is React's format with nothing of Elements' in
it (`:R1:`, `«R1»`, `_R_1_`), the content's ids following the template
wrappers'. Async components render on React 18 too.

The bundle grows (about 41 KB unminified): the renderer react-dom was.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The async walk: siblings start at once and come out in tree order,
byte-identical over random delays (ids too), two renders under way at
once stay apart, the first error in tree order wins without waiting
for what comes after it, and React's dispatcher is never the walker's
across an await or after a render, nested ones too. The error policy:
every renderer throws the same RenderError by default; with onError
"omit" in the options, a root's config or an UnlayerProvider, the
HTML, text and design leave out the same parts (ids still agreeing),
each said once and listed; a function gets them instead. React's
react-server build: its hooks work, useState is named.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Tests that assumed React 18 now follow the installed React: useId's
format, a memo'd function's defaultProps (React 19 doesn't fill them),
the React installs the Node scripts run with, React 19's image
preloads (react-dom adds them; the walker doesn't). Development-only
checks (key warnings, act()) are skipped in React's production build,
and a React newer than the version table renders if it passes the
self-check. Run locally on 18.3.1, 19.1, 19.2 and 19.3.1, development
and production: all pass.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A matrix job runs the react package's suite on React 18.3.1, 19.1,
19.2 and the latest 19, in React's development build (the full suite)
and its production build (the engine's, the renderers' and the
regression tests). A weekly workflow runs the engine's tests and a
longer fuzz (5,000 random trees against react-dom/server, 3,000
random templates against the last release) on React's canary,
reporting without blocking. The bundle budget is 173.5KB, for the
walker.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ARCHITECTURE.md maps the pipeline (unwrap, walk, model, design,
exporters, HTML and text), the engine's files, the hooks dispatcher,
the React version table and its self-check (and the escape hatch),
the async walk, ids, the error policy, what isn't supported (error
boundaries, portals, React 19's hoisting) and the invariants with the
tests that hold them. The README gets an Errors section and loses the
react-dom workarounds; the async and visual-editing guides, the
repository's CLAUDE.md and the changelog follow.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta and others added 30 commits October 18, 2026 11:30
ARCHITECTURE.md's invariant 4 said a render's output depends only on
its arguments, but a render a component starts while the walker calls
it begins at that component's place in the tree, inside the providers
around it, by design: its ids continue the document's, and a context it
has no provider for reads the outer render's value, as React's renders
inside a render do. The invariant now says so, and where a render
starts otherwise (a template wrapper, an exporter, a head, onError: at
the top). A test pins both, which nothing did: a caller cleared for
every render broke no test.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The fixes before this one add about 0.9KB to the main entry (194,331
bytes, over the 193.5KB budget): a <Suspense> around the template is
unwrapped, a root the walk of a template with no root meets is said,
and which Row or Column failed and what an async render compiled for
its <Tailwind>s are kept in the render's scope. CLAUDE.md,
CONTRIBUTING.md and the README give the new budget and size.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…s too

The async renderers took a template whose wrapper left its root inside
markup without a word of their own: only the walk said it, in other
words than the synchronous renderers'. They now say what the
synchronous ones say, naming the template and the render function, once
a render, and the walk says no more. (The main entry is 194,491 bytes,
inside the 194.5KB budget.)

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The fixes since the 193.5KB budget add about 1KB to the main entry
(194,491 bytes), not 0.9KB: the async renderers now say why a root's
settings go unread too.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Around a template's root, a <Suspense> is outside the document and its
fallback never renders; around a template with no root it's what the
template renders, as inside a root, so the synchronous renderers render
its fallback where what's in it suspends. The async guide and the
changelog say which, and a test holds both. Two comments are worded
more plainly.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A `class=` inside another attribute's value made the inliner look up a class
attribute the tag doesn't have and throw, failing the render (or leaving the
block out with onError: "omit"). The inliner now reads tags with the Markdown
sanitizer's tokenizer, moved to its own module, and writes again only a tag's
first class attribute and the style it gives: comments, raw text, what isn't a
tag and every other attribute stay as written. A seeded fuzz test holds it to
that, and to inlining again changing nothing.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A rule was read as the first class's in its selector, so a class under a
variant whose selector starts with another class (`[.group:hover_&]:x`, a
config's `.group:hover &`) was "a class Tailwind doesn't have", and stayed
in markup's class attribute, while `group` itself took that rule's reason
and was left out. Tailwind writes each class's rules inside a rule of its
own, so the class is that rule's, wherever it is in the selector; a class
under a variant's selector says it applies only there (`.group:hover &`).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…variants

In markup, `<div class="hidden md:block">` became `display: none`, with
`md:block` left out (it needs a stylesheet), so the text was hidden on
every screen. A class that sets on every screen what a phone or breakpoint
variant sets on one isn't inlined either now, and says why. A class a
`hover:` variant changes still is: without hover, the element is as it
says.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…dom does

`<select defaultValue="b">` lost its choice: the walker left the select's
value out but never marked option B `selected`. It now carries react-dom's
format context, so a select's value or default value (an array with
`multiple`) marks the options it holds, through optgroups, Fragments and
components, an option without a value read by its text, as react-dom
reads it for the installed React.

The other elements react-dom writes its own way match too: a textarea's
value with children and innerHTML throw, a pre's newline only for a
string, React 19's form settings after an element's other attributes and
an object's `data` as a URL, menuitem's end tag, a tag in capitals as an
element of its own. The differential test now writes every element and
attribute kind React special-cases, and random markup where templates
have it; what the walker means to write otherwise (React 19's hoisting,
its document singletons, function actions, an error in a Suspense) is
listed, each with why.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…d as given in Head

The walker escaped a `<style>`'s text as any text (`'` as `&#x27;`, `>` as
`&gt;`), which a browser never decodes there: rules broke. react-dom 19
writes a style's or a script's one child as given, escaping only what
would end it, and a title's one child; React 18 escapes them, so the walker
now writes them as the installed React does. In a <Head>, a style's CSS
and a script's text (JSON-LD on a page) are written as given in React 18
and 19 alike, from a component of yours too, and a title's text is all its
children's: Head's own rewrite of its direct children goes.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Writing markup as react-dom writes it for the installed React adds about
3.4KB to the main entry (197,899 bytes, over the 194.5KB budget): the
format context the walk carries for a select's value, the form controls'
rules, React 19's for a style's, script's and title's text, and a Head's
style and script text written as given. CLAUDE.md, CONTRIBUTING.md and
the README give the new budget and size.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The header now names an error in a Suspense among the differences the
walker means, and a comment says plainly that react-dom 18 writes the
listed trees as the walker does.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
React 18.2 began rejecting a `<title>`'s dangerouslySetInnerHTML; 18.0 and
18.1 write it, as React 19 does. The walker rejected it on every React 18,
which the differential test caught on 18.0: it now rejects it on 18.2 and
18.3 only.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A `<style` or `<script` that would start a tag is escaped too, not only
the end tag: the README says so.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A class component in a Column returning `<div style={...}>Hi</div>`
became an Html block holding only `Hi`: the walker kept what a function
component returned, not a class's, so the model took any `<div>` for
`dangerouslySetInnerHTML` and kept what's in it. A memo of a class or of
a forwardRef, a lazy component, a context's Consumer or Provider and a
StrictMode lost their wrappers the same way.

The walker now keeps what every kind of component returned, and the
model takes what's in an element only when it was given
`dangerouslySetInnerHTML`, so any other element is kept whole, with its
styles, as 0.2.1 kept it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
In markup, `class="px-4 md:pl-2"` lost all its padding: `md:pl-2` sets
`px-4`'s left padding on wider screens, so every declaration of `px-4`
was left out, its right padding too, and both classes left the
attribute. A shorthand such as `p-4` wasn't compared by its sides at
all, so `p-4 md:pl-2` kept its left padding on every screen.

Only what a variant sets is left out now, compared as email clients
read it (logical sides as physical ones, a shorthand by its sides); the
rest of the class is inline (`padding-right: 16px`), and the warning
names the property and the variant. A class inlined in part leaves the
class attribute, as every class Tailwind knows does: the docs say so.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The Markdown sanitizer keeps `class` and refuses a `style` with `url()`,
`expression()` and the like, but inside a `<Tailwind>` the classes of
its HTML were inlined after it: `<span class="bg-[url(...)]">` became
`style="background-image: url(...)"`, also when the `url(` was hidden
behind an HTML reference or a CSS escape.

Markdown now marks the blocks holding its HTML, and Tailwind checks each
declaration it would inline there with the sanitizer's own style check,
leaving out the unsafe ones with a warning. The mark isn't in the
design. A developer's own markup is inlined as written: it isn't
sanitized, and a background image there is meant.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A style's check let through `filter: progid:...AlphaImageLoader(src=...)`,
which loads an image in old Internet Explorer engines, and CSS's
`image()`. Both are now refused in a `style` attribute and in what a
<Tailwind> inlines into Markdown's HTML, as `expression()` and
`behavior` already were. A test also holds that a variant class
(`sm:`, `max-sm:`, `hover:`) on Markdown's HTML ends up in no style.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A class like `[color:x(]`, `[color:/*]` or one with an escaped quote in
its selector made every class Tailwind built after it unreadable, for
every render using that handle: the stylesheet reader took `\(` and `\"`
for a bracket and a quote, and Tailwind's compiler keeps every class it
has built.

The reader now reads CSS as CSS does (escapes, strings to their quote or
the line's end, comments, brackets of each kind), so one rule can't
change how the next reads. A class with a bracket, a parenthesis, a
brace or a quote left open, or a comment, is never built: it's ignored
as a class Tailwind can't read here. A build whose stylesheet still
doesn't read cleanly is left out, the classes read before keep reading,
and a fresh compiler takes over, on which that build's classes are built
alone first so the one that was why is never built again.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…riant

In markup, a class whose property a phone or breakpoint variant also
sets was left out (`px-4 md:pl-2` lost its left padding). Inline styles
can't follow a screen size, so leaving the class out made every screen
wrong where inlining it leaves only the variant's screen wrong.

The rule now holds only for what shows or hides the element, `display`
and `visibility`: `hidden md:block` still inlines neither, as showing
the element everywhere is the safer mistake. Any other class is inlined
as it is on every screen, and the variant is said: `px-4 md:pl-2` gives
`padding-left: 16px; padding-right: 16px`. The docs and the changelog
say so.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The sanitizer kept `class` whole, so `<div class="bg-[url(...)]">` from
Markdown stayed in the HTML and the design, where a later render in a
<Tailwind> (from code generated from that design, say) would inline it
unchecked.

In `html="sanitize"` mode, a class name whose arbitrary value (from its
first bracket, as the browser reads the attribute) would fail the style
check is now left out, also behind an HTML reference or a CSS escape;
the attribute is written as it was where nothing is left out. The check
of what a <Tailwind> inlines into Markdown's HTML stays, as a second
line. The sanitizer's fuzz has these vectors.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
`# [Pay invoice](https://example.com/pay)` was `PAY INVOICE` in the
plain text: the heading pass took the tags out before the link pass saw
them, so a Heading's links, and an h1 to h6 in markup, lost their URLs
in every `links` mode. A URL with a `'` was cut off at it, an `href`
after a `data-href` gave that one's URL, a link in preformatted text
lost its URL, and a heading's link whose text is its URL was put in
capitals.

Links are now read first, each attribute's value whole in either quote,
and their URLs set aside until the end, so a heading's capitals and the
other steps don't change them; `wordwrap` leaves the URLs listed with
`links: "after"` as they are. A property test renders every block and
place a link can be in, with seeded URLs, and checks each URL in the
HTML is in the design and in the text in each mode, or left out by
`skipInText`, `data-skip-in-text` or `skip`.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The React version matrix ran in test.yml, which nothing in publish.yml
waited for: a release could publish a commit before, or although, the
matrix failed for it.

The matrix is now a workflow of its own (react-versions.yml, called
with `workflow_call`), unchanged in what it tests. test.yml calls it for
every push and pull request, and publish.yml calls it for the commit it
releases, whenever Elements would publish; the Elements publish job
needs it. The docs point to the new file.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
When a fresh compiler failed to compile, the reader stayed marked as
compiling one for good: after a build it couldn't read, that `tw` read
no new class for the rest of the process.

A failed one is now asked for again by the next read that needs one (no
timer, so nothing is compiled while no class needs it), up to three in
a row, as compiling the same config again isn't likely to pass after
that. Classes left out while a fresh compiler is on its way say so, and
after three failures they say Tailwind's error. That a render meeting a
new class then renders without it, and the next ones with it, can't be
made synchronous: the code and docs/tailwind.md now say why.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The changelog and the README said the plain text without options is as
before, but preformatted text now keeps its spaces and line breaks, so
0.2 templates with a `<pre>` get other text.

0.2.1's text and this release's were compared on every story, on each
block in each root, and on the same HTML through both conversions. The
differences are preformatted text, what `data-skip-in-text` leaves
out, how each link's URL is read, links in Paragraph and Heading
children, and templates that now render as emails (an UnlayerProvider's
mode). Each is a fix or part of a listed change, and each is now said.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Keeping a heading's links in the plain text adds about 0.3KB to the
main entry (198,318 bytes, over the 198KB budget): links are read
before headings and preformatted text, each attribute whole, with
their URLs set aside till the end. CLAUDE.md and CONTRIBUTING.md give
the new budget; the README's sizes (about 198KB, 56KB gzipped) hold.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The class-history test builds thousands of Tailwind classes, and the
react-dom comparison renders over a hundred thousand elements twice.
Each takes under a second on a fast machine but 8 to 11 seconds on a
GitHub runner (React's development build, or coverage), past vitest's
5-second default, so the PR's test jobs failed. Both now say their own
timeout (60 seconds), as the other heavy tests do.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A Paragraph's, Heading's or Button's children kept a class only inside a
<Tailwind>, so a <Link className="mobile-link"> lost the class a <Head>
stylesheet styles, and a <Link className> on its own in a Column became
an HTML block instead of a Paragraph. The class now stays, escaped as
React writes it, in the HTML and the design, as the editor's text keeps
it; style was already kept.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The design's 0-to-0px pass looked at a key only when it ended in the last
letter of a px key, and that list missed margin's "n", so a body's
documentMargin or popupCloseButton_margin given as "0 0 12px" kept its
bare zeros. The letters are now taken from the list of px key endings
itself, so the two can't drift apart.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The comment on inline-html said a <Tailwind>'s classes style rich text
children. Only a Paragraph's are inlined as styles; a Heading's or a
Button's are kept as they are, as docs/tailwind.md says.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant