Repository navigation
@unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes - #69
Open
ivoIturrieta wants to merge 289 commits into
Open
ivoIturrieta wants to merge 289 commits into
ivoIturrieta wants to merge 289 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #69 +/- ##
==========================================
+ Coverage 96.55% 97.58% +1.02%
==========================================
Files 40 139 +99
Lines 2789 10392 +7603
Branches 795 3818 +3023
==========================================
+ Hits 2693 10141 +7448
- Misses 95 251 +156
+ Partials 1 0 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ivoIturrieta
marked this pull request as ready for review
October 15, 2026 16:58
…ain text directly Each block's, Column's and Row's values in the design were merged, then copied into the object that adds its _meta and the editor's flags; they are now written into that object as they're merged. The defaults' own values, which every render copies, are worked out (their zeros written `0px`) once per defaults object and copied from there. A Paragraph's text given as text is Lexical JSON of one plain paragraph, which textJsonToText now reads by its text instead of parsing and walking the tree; the model's check of a block's props remembers which names a block takes; a block's `style`/`className`/`skipInText` check returns at once where it has none; and an id or class name that holds only letters, digits, `_` and `-` (as Elements gives them) isn't sanitized again. renderToJson on Big goes from 0.65 to 0.51 ms (React 19.1, production). The output is the same (corpus and 2,000 random templates). Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Flattening the walk's output made two bookkeeping objects for each record and each element of markup; it now makes one only for a part that can be left out. A production build of React checks no keys, so the walker keeps no list of the lists it checked there. The model's Row looks for a rewrite and reads its padding without copying its props, and the head tells every built-in head the same render settings (a custom tool's head gets its own), calls none for a Row or Column, and escapes an id for its phone CSS only where there's phone CSS. renderToHtml on StripeReceipt goes from 0.28 to 0.26 ms, on Big from 1.57 to 1.46 (React 19.1, production). The output is the same. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
renderToHtml rendered the root (Email > Body) through the walker, which wrote the body's HTML out once more inside Body's `<div>`, and then took the `<div>` off again with a regular expression over the whole body. A root of Elements' own is now called as the walker would call it (its components call no hooks, inside the render around it as before), and the document gets the HTML Body gives, as it is; renderToHtmlParts and renderToPlainText get it in the `<div>`, as before. renderToHtml on StripeReceipt goes from 0.26 to 0.24 ms, on Big from 1.46 to 1.27 (React 19.1, production): faster than 0.1.22 (0.25, 1.33). The output is the same. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The model's Rows, Columns and blocks were made with createElement, which copies the props object it's given; React 19's jsx takes it as it is (React 18's copies it, as before). A Row's and Column's children are given as static children, as the model's lists are (no keys to check). The design's internal props are a set, not a pattern. The output is the same. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry had the async walk's holes and the loop that flattens them once they settle, and awaited a template's async wrappers, though only "@unlayer/react-elements/async" uses them. The walker now takes how to wait (`wait`): the synchronous walk has none and throws to the nearest <Suspense> as before; the async entry gives its own (src/async/collect.ts), with the main entry's engine through its internals, so one copy of the engine renders, and takes the steps to a template's root itself. An unused Menu default goes too. The main bundle goes from 187,455 to 186,376 bytes, the async one from 3,655 to 5,170. The output is the same. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
An email or page placed in a page of the app's own (an iframe-less preview, a confirmation shown on the page that sent it) had useId()s that the page's own could repeat: Elements gives React's ids, from the start of the tree. `idPrefix` in a render's options (every renderer, the async ones and renderToJson too), a root's `config` or an <UnlayerProvider>'s config starts each of the template's useId()s with it, as React's identifierPrefix does (`«em-R1»` on React 19.1, `:em-R1:` on 18, `_em-R_1_` on 19.2 and later). A template component around the root takes it from the render's options or an <UnlayerProvider> around it: it's called before the root's `config` is read. It's typed on UnlayerConfig and RenderToJsonOptions, and documented in the README. A test compares the walker's prefixed ids with react-dom's identifierPrefix on random trees, and the renderers' with and without. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The Tailwind entry registered its compiler in one global slot, which the async renderers compiled every <Tailwind config> with: with two copies of Elements in one app (a package still on another version), the one loaded last compiled both copies' scopes, and a copy given the other's compiler can't rewrite its Rows. A <Tailwind> now carries its copy's compiler on its type; a collected scope records it, and the async renderers compile its config with it and give its handle to that <Tailwind>. An older copy's, which carries none, gets the compiler loaded last, as before. A test registers another copy's compiler after this one's and renders. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The React Email migration writes a list item that's several Rows as a
list of them (`articles.map((article) => [<Row key={0} />, <Row
key={1} />])`, the weekly-digest fixture's codemod output). Each list's
keys are its own, as in React (react-dom says nothing of them either), so
the same keys in each item aren't children with the same key: the
walker says nothing of them, in any renderer. This pins it.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Body rendered each Row with one function of the engine's and the renderers the root with another, which did the same: call one of Elements' own components (they call no hooks) as the walker would, and give the HTML of the `<div>` it returns as it is. They're one now (renderOwn), and the bundle budget is set to the bundle's size: 186,412 bytes, budget 187,000 (phase 1's was 173,500: the string exporters, the model and the work it saves). Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ARCHITECTURE.md's engine table names renderOwn and the async entry's walk, the unwrap step says who awaits a template's async wrappers, and the test table has the tests for renderToJson's exporters, the head's reuse and idPrefix. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The pinned Page and Document HTML of Divider, Html, Menu and Social is 0.3.0's, with React 18. On the CI's React 19 jobs one template differs, as react-dom 19 has it too: a Menu link to `javascript:alert(1)`, which React 19 writes as a URL that throws. The test now checks that case's React 19 digests and compares the rest with 0.3.0's. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
renderOwn made a new element (createElement) for an element whose type isn't one of Elements' own components, to walk it, and React 18's development build warned a second time where that type is invalid (a template whose root isn't a component). It now walks a clone of the element, as the walk did before, which React doesn't check again. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
renderToHtml takes about half of 0.3's time (as fast as 0.1.22's), renderToPlainText about three quarters, renderToJson about a third. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
UnlayerProvider imported createContext and useContext from react by name. React's react-server build (Next.js's Server Components, route handlers and edge routes) has neither, and webpack, Next.js 15's default bundler, fails the build of a module that imports a name the module it imports doesn't export: `next build` failed for an edge route that rendered an email, where Turbopack built it. Node's ESM loader fails it too, as it links the module. They're read off React now; the context is still made when it's first read. The react-server build test now imports each ESM entry with that build (six of the eight failed to load before) and renders. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…n each layer
The Next.js 15 app was built with Turbopack only. CI builds it with
webpack as well (`npm run build:webpack`, Next.js 15's default) and
checks it the same way. Each route renders with every entry of
Elements too (Font, Head, Markdown, code, codegen, and Tailwind with
the async renderers), and a Pages Router API route in the Node.js
runtime is checked. The Pages Router edge API route allows
@unlayer/exporters' dynamic code: webpack rejects lodash's
`Function("return this")()` there, which the edge runtime never
reaches.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…or: "omit" A Row's or Column's exporter that failed threw even with onError: "omit", and named the Row as "Row 1" wherever it was (the walk of Elements' own markup wrapped the error before the model saw it). What fails in a Row's or Column's own HTML is now a RenderError naming it and where it is (`Row failed to render: … (in <Column> at Row 3 › Column 2)`), and with "omit" it's left out as a block is, listed in `omitted`: a Row whole; a Column with its cell, its Row made again without it, so the Columns after it move up and the design's cells match its columns; a Row with its last Column. The HTML, the plain text and the design are the template's without that part, which the tests check against the template written without it. The bundle budget goes from 187KB to 189KB for it. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…nd check The app's async routes render Tailwind classes, which the async renderers compile with tailwindcss as they run: installed with the app, as an app using Tailwind installs it. In a scratch copy the check found another project's tailwindcss further up the folders; CI's fresh app has none. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
… skip its check The README didn't say that Elements checks the installed React before its first render, what it throws when the check fails, or that UNLAYER_SKIP_REACT_CHECK=1 skips it: only ARCHITECTURE.md and the changelog did. Installation now says which React versions it's tested with, what happens with a newer one, the escape hatch, and why react-dom stays a peer dependency. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The Tailwind guide said they load the compiler only for a template with a config. A <Tailwind> without one compiles too (Tailwind's default theme), so it needs tailwindcss installed as well. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A template's own component that throws a promise until its data is there (React 18's Suspense, react-i18next's `useTranslation` before its messages load) failed every renderer with `[Unlayer] [object Promise] (in <ShippedEmail>)`: the call of a wrapper took only `use()` for a suspension. The synchronous renderers now say what to do, as for an async template (`async and suspending templates need "@unlayer/react-elements/async"'s renderers, …`), and the async ones wait for it and call the component again once it settles, as they do for `use()`. The same goes for a class around the root and a class component in the content, which failed the same way; a function component in the content and in a block's text already suspended. A component that throws a promise again once it has rejected fails with what it rejected with, as `use()` of it does. It was waited for again and again, and the render never ended. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
With two copies of Elements in one process (the module and CommonJS builds loaded together, as tsx and @unlayer/migrate load them, or two installs in a monorepo), a template of one copy's components rendered by the other copy's renderers had its HTML made by its own copy's Body, in that copy's render scope. With `onError`, a block whose exporter failed was left out of the HTML but stayed in the design, `omitted` lacked it, and what the render said (the parts left out, a Row's padding) was said twice; the async renderers failed outright for an async component, which that Body walked again without waiting. The render functions now render a root of another copy (Body, Email, Page, Document, by name, as another copy's blocks are made this copy's) as their own copy's, so the copy whose renderer was called renders the whole template in one scope, whichever copy its components are of. A test loads both builds in one Node process and renders each one's template with the other's renderers, on React 18 and 19. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The two fixes before this one add about 1.2KB to the main entry (189.5KB, over the 189KB budget): a template, a component around its root and a class component that throw a promise are waited for by the async renderers and said by the synchronous ones, and a root of another copy of Elements renders as this copy's. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A <Tailwind> found a Row's Columns by its own copy's Column type. With the module and CommonJS builds loaded together, a template's <Tailwind> of one build rendered by the other build's renderers rewrote none of that render's Columns: a Row's padding classes stayed around the row, in the design (and, now that the renderer that was called renders the whole template, in the HTML too), where one build alone puts them in its Columns, and its text classes didn't reach their blocks. The rewrites find Columns by the marker every copy's Rows, Columns and blocks carry. The test of both builds in one process renders a <Tailwind> template too. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Elements knew its roots by their names: a component of the template's
own given one (a design-system wrapper, `const Email = (props) =>
<UEmail backgroundColor="#f4f4f4" {...props} />` with `Email.displayName
= "Email"`) was taken for the root. The HTML rendered it as this copy's
Email, and the design read its props as the root's, so what the wrapper
sets was in neither.
Elements' roots now carry a marker shared across copies
(`Symbol.for("@unlayer/react-elements/root")`, their name), and an older
copy's roots (0.2.1 and before, which carry none) are known by their
name and their code, as an older copy's Rows are: its Body reads the
`_config` it passes its rows, and its Email, Page and Document are that
Body in their mode. Anything else is called as any component is, in the
HTML and the design alike. Tests render such a wrapper for each root
name, and 0.1.22's roots with this release's renderers.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
With `onError: "omit"` (or a function), a component that suspended in a
synchronous render inside `<Suspense>` was left out by the boundary
around it (a component in a Column is in one) before the `<Suspense>`
saw it: `<Suspense fallback={…}><Row><Column><Price /></Column></Row>
</Suspense>` rendered neither the price nor the fallback, and said that
the component is async. By default the fallback rendered, as react-dom's
synchronous render does.
The walker counts the `<Suspense>`s open where it is, and a boundary
lets a suspension through to the nearest one, so the policy no longer
changes what a `<Suspense>` renders; one with no `<Suspense>` around it
is still a part that failed. Tests render an async component, a thrown
promise and (on React 19) `use()` in a `<Suspense>`, with each policy,
in the synchronous and the async renderers.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…nd two upgrade changes mergeTagState was documented as merge tags, as if it filled them in: it is the editor's merge tag state its Table exporter reads to repeat a table's rows, and holds no values (Elements writes a tag as given, for the email service). The README says so, with a Table repeat example, and that a display condition is written around its row or block. The changelog notes the display condition fix, that a heading in a Paragraph's html now takes the paragraph's line height, and that a <Suspense> inside a template still renders its fallback in the synchronous renderers, as in 0.2. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry is about 222.1 KB with display conditions written around rows and blocks, an email service's tags kept whole in rich text and background URLs, a tag that never ends left out of rich text, and a Table's typed props named. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…d encode references that would end the URL
A background image URL's tag with quotes, a comparison or brackets
(`{{ hero | default: 'hero.png' }}`, `{% if tier > 1 %}`, AMPscript's
`%%=v(@hero)=%%`, `<%hero%>`) was percent-encoded, so the email
service failed to parse the whole email (0.2.1 wrote it as given). And
a character reference that reads as a quote or a bracket (`'`,
`)`) got past the encoding, as the browser decodes it before the
CSS reads the style, so a URL from data could add declarations to the
row's style.
The URL is now written by cssUrlValue (utils/css-url.ts): a reference
that reads as a character that could end the attribute, the CSS string
or url(…) is that character encoded; outside a tag those characters are
encoded as before; a tag keeps all but `"`, `\` and control
characters, and where its quotes would take a bracket out of the CSS
string or leave it open, the whole URL is encoded instead.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A Heading's or Button's children with inline markup beside an email
service's tag with a double-quoted string (`{{ name | default: "friend" }}`,
`{% if tier == "gold" %}`) wrote the quotes as `"` in the HTML and
the design, which the service can't read (0.2.1 kept them, as string
children still do). Rich text's text now keeps `"` as written, as it
does `'`; a tag's attributes keep theirs, and the quotes come back
before the <%name%> tags do, so no `>` of one is read as a tag's end.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…ag or its text has a quote `<Link href="<%asm_group_unsubscribe_raw_url%>">Unsubscribe</Link>` on its own in a Column became an Html block with the tag escaped (`<%…%>`), so the unsubscribe link was dead: the block keeps a component's markup as a Paragraph only where its text is all of it, and the text writes the tag as written. The markup is compared as text writes it now, its quotes and <%name%> tags as written, so such a Link (and one whose text has a quote) is a Paragraph, its URL as written. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Markup a component writes as HTML in a Paragraph's, Heading's or Button's children kept a comment as written, and one left open (`<!-- ` from data) hid the rest of the email, the order total and the unsubscribe link with it, until the next `-->`. Comments, and what a browser reads as one (`<!…>`, `<?…>`), are left out now, one left open with what follows it, as a browser reads it. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…es as an Html block A design's timer, form or custom block, which Elements has no component for, became an <Html> block of its exported HTML without its display condition or anchor, and without a word: a VIP-only countdown went to everyone. Such a block now keeps the builder's settings no prop sets in its values, as every other block does (a display condition is written around it), and design-to-code says where its merge tag rule isn't applied. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…lain-text capitals
`<Heading>Welcome, {{first_name}}!</Heading>` was `WELCOME, {{FIRST_NAME}}!`
in the plain text (0.2.1 too), which the service doesn't fill in (and
`{% IF %}` failed Liquid's whole text part), though the README says a tag
is kept as written in the plain text. A heading's capitals leave every
common service's tags as written now: `<%name%>` as before, and
`{{ … }}`, `{{{ … }}}`, `{% … %}`, `*|…|*`, `%%…%%`, `%name%`,
`${…}` and `{$…}`.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…eir text A Table whose `data` came from JSON, with a quantity or a price as a number (`[name, qty, total]`), failed the whole render (`trim is not a function`): TypeScript types `data` as strings, but JSON's numbers reach it as numbers. 0.2.1 sent the email without the table. A cell or header that isn't a string (a number, a year, `0`, true) is now its text, in the HTML and the design; null is no text, as before. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…gs, and that headings' capitals keep them Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry is about 223.8 KB with an email service's tags kept whole in background URLs and their encoding checked, rich text's comments left out and its double quotes as written, a lone Link's tags kept, a Table's cells from data written as text, and plain text's heading tags kept. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
… or > Rich text keeps a few attributes of markup a component writes as HTML (a link's href and title, a style, a class), and writes its text's quotes back as written. An attribute value holding a raw `<` or `>` beside a quote reference was read in two parts when the quotes came back, so a reference inside it became a quote that ended it. A kept attribute's `<` and `>` are written as references now (the same value to a browser), so every tag is read whole and keeps its quotes. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
In an email, the exporters write a background image URL in Outlook's markup too, which sits in a conditional comment: a tag kept as written in the URL could hold `-->` and end it. Where the URL would end a comment, it's encoded whole now, as where its quotes would take a bracket out of its CSS string. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
… can hold A Row's, a Column's and a block's `values._meta` id and class names are kept to what an id and a class name can hold; the root's were written as given (0.2.1 too), so one from data could add markup in every mode. The root's go through safeMeta now, as theirs do. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…s children
`<Paragraph>Ordered on {order.createdAt}</Paragraph>` (a Date, or an
object like a price) rendered without it and without a word, in a
Paragraph's, Heading's or Button's text (0.2.1 too), though React's
render fails for it and the README says the render fails. The text's
children now fail the render as React's do, naming what they found; a
Set renders its items, as in React.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…ldren failing the render Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Markup a component writes as HTML in a Paragraph's, Heading's or Button's children is kept to inline formatting, the other tags taken out. A `<` or `>` in the text around them stayed as written, so what was left beside a tag taken out could make a new one. The tags kept are set apart while the markup is read, and every other `<` and `>` is written as a reference, so only rich text's own tags and attributes reach the email and the design. A seeded test builds markup of random pieces and checks that. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…rence
A `style` declaration with a `"` (a font stack's `"Helvetica Neue"`,
`url("…")`) was left out of the HTML without a word, on every component
(0.2.1 kept a Row's). It's written now with its `"` as `"`, which
the browser reads as one and which can't end the attribute.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…lwind width class
`<Image src={{ url, width, height }} className="w-8">` rendered at the
image's natural width: the check for a width given read `src.width` as
one, and the class's width went into `src`, under the natural size. An
image's `src` gives no width of its own now, and a width class is its
display width, the flat prop, as for an image whose `src` is a URL.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The README's Table repeat example, `values={{ mergeTagGroup, mergeTagRule }}`,
didn't type-check: Table's values type didn't have the two keys. It has
them now, and the shapes design-to-code writes values from are
regenerated.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The editor's exporters write a Heading's level as its tag's name, so one from data with a space or a quote in it (`level`, `as`, `headingType`, `values.headingType`) was written into the markup as given (0.2.1 too). Such a level is an h1 now, and said; a design's own level stays. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry is about 225 KB with rich text's stray brackets kept as text, a root's ids kept to what they hold, object children failing the render, style values' double quotes written as references, Tailwind widths on images with a natural size, and a Heading's level checked. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…lain text
`toPlainText(html)` of `renderToHtml(…, { pretty: true })`'s HTML gave
links the wrong URLs: prettier writes a long line's end tag as
`</a\n >`, which the plain text's steps didn't read as one, so a link
stayed open and the next link's text got its URL (block breaks and a
heading's capitals were lost too). Such end tags are read as `</a>`
first now, each once.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
An image's `src` with its natural size gives no width of its own, so a width class sizes it (the last fix); its `maxWidth`, though, is a display width given, which a class doesn't override, as a flat `maxWidth` isn't. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…codegen entries without it An app importing `@unlayer/react-elements/async`, `/tailwind` or `/codegen` failed to build with esbuild (wrangler's for Cloudflare Workers, a serverless bundler's) unless prettier, an optional peer dependency, was installed: "Could not resolve prettier/standalone". Their dynamic imports had literal names, which webpack and Vite are told to leave but esbuild follows. prettier is now loaded by a name that isn't a literal, so no bundler follows it, and a test bundles every entry with esbuild with prettier missing.
A `<Link>`'s, or an inline `<a>`'s, href with an email service's tag
with a quoted string (`{{ email | default: 'none' }}`) was written as
`'none'` in the HTML and the design, which the service's
Liquid rejects; a Button's href kept it. Rich text's tags are written
with double-quoted attributes, so their single quotes are as written
now, as the text's are; a `"` stays a reference, which can't end them.
…il writes
With its natural size given (`src={{ url, width, height }}`), an email
writes an Image twice: in Outlook's conditional comment and for every
other client. Its `style` and `className` went on the first only,
Outlook's, so every other client lost them (a `rounded-full` avatar
came out square, an `h-12` lost its height). They go on every <img> it
writes now.
…ot the value it left A react-dom renderToString that failed or suspended inside a provider leaves that provider's value in the context, till react-dom's static renderer renders again. A template that read the context without providing it read that value: another request's tenant, theme or locale. In Node, Elements now has react-dom/server render nothing before it reads a context's default, which puts the default back, and keeps nothing from one render to the next (the default it learned could be learned wrong for good). react-dom/server is loaded only in Node, the first time it's needed, through the package's fizz-cleanup export: its conditions give every other runtime (edge, Workers, browsers, React Server Components) a module that does nothing, and a fizz-cleanup/ folder gives it to webpack 4 and Jest before 28, which don't read "exports". Inside an app's stream or client render, and where nothing is loaded, the value such a render left is read till react-dom's next static render: a template's own provider, or a prop, is read wherever it renders.
…isn't put back In Deno, an app's import of react-dom/server is its browser build and Elements' require its Node one: another copy, whose render can't put back what the app's left.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The next
@unlayer/react-elementsrelease (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 doesn't render with react-dom: its one call, in Node, renders nothing, so that react-dom puts back a context value a failedrenderToStringleft (react-dom stays a peer dependency because@unlayer/exportersimports it).<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. Five 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, a failure inside<Suspense>, which follows Elements' error policy, and ajavascript:URL on React 18, which Elements writes as React 19 does (a URL that throws), with any React. In<Head>, a<style>'s or<script>'s CSS and JS are written as given, and nothing in them can end them early, whatever writes it (a component, a list, text in parts, markup): their end tag, and that of a<noscript>or<title>around them, is escaped as CSS or JS reads it, and under<svg>/<math>their text is escaped as any text. A fuzz test parses random hostile head content with a real HTML parser, with and without scripting, and checks no element appears and no text leaves the element it was written in.renderToStringthat failed or suspended inside a provider left, and nothing is kept from one render to the next, so no default is learned wrong; a render reads one value for it throughout. Inside an app'srenderToStringit reads the app's provider value, as 0.2 did. react-dom/server is loaded for this only in Node, the first time it's needed, through the package's./fizz-cleanupexport, whose conditions give edge runtimes, Workers, browsers and React Server Components a module that does nothing (afizz-cleanup/folder does the same for webpack 4 and Jest before 28, which don't readexports). Tests stream a page for one tenant, fail another'srenderToStringinside its provider, and then render emails (sync, async at once, every way a component reads context) on React 18 and 19, dev and prod; ARCHITECTURE.md lists the one rare leak case left: inside an app's stream or client render, where react-dom/server isn't loaded for this, or where the app's is another copy (Deno'simportof it), a template reads what a failedrenderToStringleft, till react-dom's next one. A template's own provider, or a prop, is read wherever it renders.[Unlayer] boom (in <Hero> at Row 3 › Column 1 › Hero), aRenderErrorwith the original ascause. 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 inrenderToHtmlParts().omitted. That covers every part: components, Rows and Columns, exporters, template wrappers, a custom tool's head, and what's in a<Head>. An async or suspending template given to a synchronous renderer is aRenderErrornaming it too.UNLAYER_SKIP_REACT_CHECK=1to 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), comparesuseIdwith react-dom's own, and runs a weekly canary.useId()gives React's own ids (no internal prefix);idPrefixsets a prefix for emails embedded in a page.javascript:URL, written as one that throws with React 18 too (a 12k-case test that's also the drift alarm), so pages and documents render on the edge too.packages/react/ARCHITECTURE.mdexplains the pipeline, the dispatcher, the error policy and the invariants.New entries (separate imports, so the main bundle stays small)
/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,w-*,max-w-*andmin-w-*on an Image or the root make one width as CSS sizes it (<Email className="w-[600px] max-w-[800px]">is 600px wide), a logo's height with an auto width (h-12 w-auto) is the width the image's aspect ratio gives wheresrchas its size (192px for 400×100), phones too, and is left out and said where it hasn't, so the image isn't stretched, and logical classes (ps-*,ms-*,border-s-*,rounded-s-*,text-start) follow the text direction. A prop given asundefined(a wrapper forwarding an optional prop) leaves the class's value, and a class value that could end an HTML attribute or start a tag ([color:'"><img …>'], a class string from data) is refused and said, as is aurl()that isn't a web, relative, inline attachment (cid:) or image data URL, base64 included (bg-[url(javascript:…)]). What it compiles stays bounded in a long-lived server (the last 50 configs; a fresh compiler past 2,000 classes, one compiled with the handle and kept ready, so a synchronous loop of renders, which never lets one compile, takes it at once; past 8,000 classes on one compiler with none ready, a render that needs a new class fails with aRenderErrorsaying to yield between renders or use the async renderers, which wait for one: a batch job's loop of one new class per render took 2.6 s for its first 1,000 renders and 32.6 s for its sixth, and 150 Markdowns of 64 KB of classes 164 s and 2.9 GB), and so does what a declaration's variables come to (4,096 characters, left out and said past that), so classes in untrusted Markdown HTML can't exhaust memory; a render's classes (its components', their markup's, a Markdown's, in its<Tailwind>s) are compiled at once, so thousands of them from data take time linear in their number, and a render reads its first 2,000 different ones (the others stay as written, said once), so they can't take more memory either. Tailwind's compiler, which a handle shares across renders and which keeps every class it built, is given only classes it builds in time: at most 512 characters, 8 variants (each part of a compound one), 4&in its variants and brackets 8 deep, and a render's first 64 KB of classes (one tenant's 100 KB[&:hover&:hover…]:failed every later render on the handle that needed a new class, 40 s later too, and 20 KB made each later build take 0.6 s; chained compound variants took 3 s for 8 KB). A class past them is left out as written and said once a render; within them a class takes Tailwind about a millisecond, and a seeded fuzz finds none that throws or that leaves the compiler unable to read the next. A class Tailwind's build throws for without keeping it is found by halves among the classes built with it and left out, and the render goes on. Each class is read in time linear in its size, whatever its arbitrary values and variants hold (a number inleading-[…],m-[…]or[@media(…)]:, the spaces before an!important: 20 KB took up to 13 s; a media query of many parts with two widths each, time exponential in their number). A plugin's function that throws for a class (or gives CSS that can't be read) never reaches Tailwind's compiler, which a handle shares across renders: the component whose class it is fails with the plugin's error, named, from the first render on and in every render that uses it, synchronous or async (left out withonError: "omit"), whatever CSS another utility gives the class (a static.boom-nowbeside the plugin'sboom) and inside compound variants (group-*,peer-*,not-*), and nothing changes in other renders (another tenant's) on the same handle. A function that gives nothing for a value (if (!ok) return;) gives the class no CSS, a class Tailwind doesn't have, in every render, and one that gives what Tailwind's compiler throws for, and then for every build after it (an@applyof a class or a variant Tailwind doesn't have, or of the class itself; a list holdingnull; a variant that isn't a selector), fails its component the same way, never reaching the compiler; an@applyin a function's output is read as Tailwind reads it. A plugin's error is tied to its class by the value and the modifier Tailwind gave the function (an arbitrary one as Tailwind decodes it:brand-(--x)isvar(--x)), and one that can't be tied to a class fails only the render that built it, so a user'sbrand-(--x)in Markdown can't fail the app's ownbrand-blue; a function that givesfalsegives no CSS, and one that gives what isn't CSS (a string, a number, a function) fails its component. Classes in a Heading's or a Button's children and on a<Link>or an<a>on its own in a Column are inline styles too, as in a Paragraph's; a style prop given asnulldoesn't count, as one given asundefineddoesn't (a content prop given asnull,text={null}, is no content, as in 0.2.1). Where Tailwind still compiles again after CSS it can't read (a config's own theme value), a synchronous render that needs a class meanwhile fails saying to render again, and the async renderers wait (and render again where their own class was the one): no render goes out without a class's styles./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. A table in a list or quote takes the samehtml,url,linkAttributesandstyles. Markdown from data takes no more than its size calls for: its tables can have, in all, as many cells as it has characters (or 1,000), since marked fills short rows out to the header's width (5 KB was 229 MB of HTML); a table past that fails as a component does (onError: "omit"leaves out just it), and quotes and lists nest 32 deep (deeper is text, where thousands deep overflowed the stack). Its inline syntax is read in time linear in its size: marked looks ahead from each emphasis, strikethrough, link, code or raw HTML opener nothing closes to the paragraph's end (100 KB of*atook a minute, and emphasis nested a few thousand deep overflowed the stack), so each of marked's rules is given only as much of the text as it can look at (checked against marked's own rules on random text) and what they look at is counted, 32 steps a character and about a million more, past which the rest of the inline syntax is text; emphasis, strikethrough and links nest 32 deep. 31 MB of READMEs, changelogs and documentation lex to the same tokens as before (at most 6 steps a character), and the worst shapes render 100 KB in about 0.2 s and a megabyte in 1.5 s. Quotes and task lists are read in time linear in their size too (100 KB of a quote holding a list item and a lazy line, every other line, took 6.6 s), counted per quote, past which a quote is its first chunk and the rest reads on outside it./codeCodeBlock/CodeInlinewith highlight.js themes. highlight.js takes time growing with the code's size squared on some code (40 KB of INI took 4.4 s, 100 KB in a Markdown fence 27 s), so it's given at most 8,192 characters at once: a longer block is shown without colors, said; a render's blocks share one budget, every Markdown's and CodeBlock's in it (their sizes squared up to 8,192²: 200 comments of 8 KB of Java, each its own Markdown, took 82 s, now 0.44 s), as do Markdown's inline syntax and quotes./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)./asyncReact.lazytoo), Suspense, andprettyoutput. A<Suspense>around the root is waited for: its fallback can't make a document, so it never becomes the email./codegendesignToElements(design): an Unlayer design becomes readable Elements code. The round trip is exact on 48 of 49 real designs and on fuzzed ones. The editor's email, phone call and text message links become the URLs its export writes (mailto:with the subject and body encoded,tel:,sms:) on Buttons, Image actions and Menu items, which the editor opens as the same actions again; a link with attributes no component's link takes (arel, atitle, code to run) is written as the design has it, with a warning, andcompareDesigns()compares it with them. A Menu item without a link comes back without one.Main entry
Link.style,classNameandskipInTexton every component.toPlainText, which reads HTML in time linear in its size whatever it holds (an element or a<nothing closes: 40 KB of<img alt="a"took a minute).color,align/textAlign, Headingas/level. Close misspellings get "did you mean" warnings.valuesaccepts partial objects with strict keys (ValuesInput).javascript:URL in them is left out however it's encoded (javascript:,javascript:), and an email service's<%name%>stays in the plain text too. Their markup is read in time linear in its size (a<style>or<script>at the end of a component's markup made 100 KB take up to 16 s).targetis its link's; one that could end its HTML attribute or isn't a string (or a Menu item's) opens in a new tab instead.HTML / editor parity fixes
<UnlayerProvider config>now applies. The most specific setting wins: render options, then the root'sconfig, then the Provider, then defaults. Safe in Server Components and on the edge.textis stored as typed, so the editor no longer formats markup the email shows literally.0pxin designs.fullWidthdefault agrees between HTML and design.url()values are percent-encoded.dir/langon the body.onError: "omit"), instead of blanking the email.cause.<p>wrapper), so lines with mixed text sizes no longer shift; a root'sfontsreach 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 recordsdisplayMode, 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
test:csp,test:browser-runtime,test:e2e,test:fidelityandtest:differentialpass, and so does a replay of the CI workflow on a clean checkout of this head (b35cc79), Storybook's visual gate included, and of the three stacked PRs' merges of it. The React matrix (18.0.0, 18.2.0, 18.3.1, 19.1.0, 19.2.9, 19.3.1; dev and prod) runs in CI on this head; it passed in CI on an earlier head, and at11ec4e2it was run locally on 18.0.0, 18.2.0, 18.3.1 and 19.3.1, dev and prod, its tests on Node 20 as in CI (react 1859 on 18.x and 1868 on 19.3.1 in development, 679 and 688 of the engine and regression suites in production). CI's runners had failed three tests and vitest's report of the results, Elements' code passing: the setup now turns the event loop between tests (a file's synchronous timing tests ran past vitest's 60 s report timeout), the Tailwind swap test reads the stylesheets where Elements reads them (vitest gave itstailwindcssmock to one of several imports under way at once, Tailwind itself to the others), the Markdown and rich text growth tests time a shape that looks slow twice more at both sizes and then at ten times the size again, each quote is timed against a quarter of its size on the same machine, the deep code test nests its tokens 1,024 deep (4,096 ran out of Node 20's stack in highlight.js before its code was optimized, which then writes the code without colors), the Tailwind class growth fuzz retimes a shape that looks slow on fresh handles and leaves out the one variant shape Tailwind's own compiler reads in time growing with its size squared (min-[var(--x)…]:, its-s each a variant lookup), which a test reads at the 512-character cap under 100 ms, and a test has 30 s (several took most of vitest's 5 s on the runners); each changed test still fails on the code before its fix. Every timing assertion in the react and shared tests now goes through one helper (src/test-timing.ts): a cost at two sizes that looks slow is timed again, each size's fastest of three samples of 20 ms at least, so a few milliseconds' baseline isn't noise; a cost over an absolute limit runs twice more, its fastest held to the limit times the machine's slowness, measured then; a retime that would read a handle's built classes runs on a fresh handle. Spot-checked on the code each was written against: the many-blocks test (6fc708b's parent), the 8,192-character highlight limit (e044386's parent) and plain text's tag names (c2d414b's parent) still fail. The streamed-context test's helper released its page's data on a 5 ms timer, which a busy machine could fire before React 18's shell (scheduled with setImmediate, run after due timers once a test starts in the setup's turn of the event loop): it now releases it from onShellReady./tailwindrow); Tailwind's stylesheet, which each build reads again whole, is read in time linear in its size on Node 20 too (a compiler's of 2,400 classes, 114 KB, took 118 ms a build, now 5.6 ms: the class growth fuzz failed on CI's Node 20 for it); a Tailwind class value that could end markup's escapedstyleor start a tag once read (font-['\22><img…>']), or a URL a component can't take (url(javascript:…)), isn't inlined in markup either, a Markdown's sanitized HTML included, while what markup can safely hold still is (a theme's double-quoted font names,url("…"), a string's;); a render's shared work is shared across copies of Elements and with all the code it runs (562 sparse-table comments were 195 MB of HTML and 733 MB RSS); plain text leaves out a link's blocked URL (Pay now (javascript:…)) and keeps its placeholders from text that decodes to them; a render's shared Markdown and code work stays its own (a template wrapper or a nested render reached another render's), and Markdown's table cells count against it (562 sparse tables were 196 MB of HTML and 746 MB of memory, now 35 MB and 246 MB); a code theme named__proto__and a plain-textskipof(no longer throw; one tenant's Tailwind class from data no longer breaks or slows other tenants' renders on a shared handle ([&:hover…]:of 100 KB, chained compound variants,max-sm:3,000 times: see the/tailwindrow), and a synchronous batch of 51 recipients renders all 51 where one recipient's class had the 50 after it told to render again; a Markdown's headings with a class each are compiled at once (2,000 took 4.7 s); an image's size given as a string is read once (30,000 digits took 1.1 s); a render of many Markdowns and CodeBlocks shares their work (12 comments of 8 KB of Java, each its own Markdown, took 4.8 s, 200 took 82 s; now 0.5 s and 0.44 s); plain text reads a tag name with a word boundary at each character once (40 KB took 1.3 s and 0.9 GB); a Video'svideoIdfrom data (abc" onload="…) ended its player'ssrcon a page or in a document and added an event handler (0.2.1 too), anddesignToElements()wrote it through: what an id doesn't hold is percent-encoded now, in the HTML, the design and the code, and the editor's ids stay as they are; a Tailwind class from data is read in time linear in its size (a number in an arbitrary value or variant, the spaces before!important: 20 KB took up to 13 s; a media query of many parts with two widths each, exponential); rich text's markup from a component of yours is read in time linear in its size (with a<style>or<script>at its end, 100 KB took up to 16 s); HTML's plain text is written in time linear in its size (40 KB of unclosed<img alt="a"took a minute, 80,000 spaces in a<pre>11 s); code's trailing whitespace is taken off in time linear in its size (a fence of 100,000 spaces thenx, 5.7 s); Markdown's would-be tables, link text, list items, paragraph lines and references are read in time linear in their size (100 KB ofaover-took 14 s,[and 25,000<!--7 s, 200 KB of list item lines 6 s); a design's lengths andmailto:fields indesignToElements(), a Lexical paragraph's style, a component'sstyleandpretty's comments are matched in time linear in their size (100,000;took 15 s, 400 KB of unclosed comments 12 s); seeded growth tests of random short shapes (10 times the size costs at most 20 times the time) cover code in every language, Markdown, HTML's plain text, Tailwind classes and rich text's markup, and hold in React's production build too; a user's class a Tailwind plugin throws for (brand-(--x), which Tailwind gives the plugin asvar(--x)) no longer fails the app's own classes of that utility (brand-blue) for every render after it on the handle; Tailwind classes in a Heading's or a Button's children and on a lone<Link>are inlined, where they stayed as class attributes email clients don't read, without a word;color={null}(any style prop) no longer writescolor: ;over the default and the class, and a content prop given asnull(<Html html={null}>,text={null}, a Table's cells) renders nothing, as in 0.2.1, not the block's placeholder (Hello, world!,Heading), in the HTML, the plain text and the design, with<Tailwind>too; a<script>'s or<style>'s text in rich text children, which React 19 writes as it is, no longer becomes a live link; a Row's anchor, a Video's link and thumbnail and a Social icon's image can't add markup (0.2.1 wrote them as given), and a Video'sjavascript:link is one that throws (its thumbnail, as an Image'ssrc, is written as given); a<Font>with ajavascript:URL loads nothing (a root'sfontsprop still writes one into its<link>, which a browser doesn't run); highlight.js's time is bounded (100 KB of INI in a Markdown fence took 27 s); Markdown's quotes and task lists, anddesignToElements()(a 200×200 table took 515 s, now 93 ms), take time linear in their size; Markdown's inline syntax from data is read in time linear in its size (100 KB of emphasis nothing closes took a minute, and emphasis nested a few thousand deep overflowed the stack; now 0.2 s, its output the same for Markdown as people write it); a Tailwind plugin's function that gives nothing for a value, or an@applyTailwind can't compile, no longer breaks the handle every render shares (the first render failed at<Body>, a raw TypeError fromrenderToJson, another tenant's said to render again, and later ones dropped the class); a plugin's error that can't be tied to a class (a variant named@) no longer falls on every class of its compile without CSS, or on all of them, for every render (another tenant'sp-4); classes from data can't add markup through a prop; Markdown's tables and nesting are kept to what its size calls for (adversarial tables of 1 to 100 KB now fail in milliseconds, the whole process under 150 MB, where 5 KB was 229 MB of HTML and almost 1 GB; within the limit the output stays linear, about 340 bytes of HTML per input byte at most, as a Table block writes each cell); encodedjavascript:URLs in inline JSX's markup are left out; ajavascript:URL in a Menu item's or Social icon's link on a page or in a document, a<Link>or a component's markup is written as react-dom 19 writes it, a URL that throws, with React 18 too (on React 18 a click in JSDOM ran it, as react-dom 18 writes it as given; 0.2.1, on React 19, wrote it as one that throws), and so are the links the editor's exporters write themselves, a Button'shref, an Image'saction, an email's Menu items and Social icons, in every mode and in the design, in every shape (a prop,values,attrs.href, custom attributes) and read with their character references decoded, as a browser reads what the exporters write as given (javascript:,javascript:,java	script:; any other URL kept byte for byte) (0.2.1 wrote those as given; Paragraph'shtml,<Html>and a Table's cells stay raw HTML, and style props are written as given, as documented); link targets can't end their attribute, nor can a target, a Menu item's text or a Social icon's name that isn't a string (an array from JSON); Markdown in<Tailwind>renders in time linear in its size (a megabyte of elements nested in one with a class, or of unique classes in one paragraph or one to a paragraph, in under a second, where 4,000 classes took 23 seconds and 40,000 nested elements 4.5); a table in nested quotes is charged once against the Markdown's cells; a class whoseurl()could run code (bg-[url(javascript:…)]) is refused; a megabyte of unique classes in one Markdown attribute took 489 MB and ran a 128 MB heap out of memory: a render now reads its first 2,000 different classes, and a megabyte in one attribute, one to a paragraph, in many attributes or on nested tags peaks at 115 to 353 MB (the last is the paragraphs' own, 309 MB without Tailwind; 290 to 457 MB before), in 120 to 600 ms, and renders with a 128 MB heap; one render whose classes held one Tailwind couldn't read no longer takes an app's other classes (p-4) from the renders after it on the shared handle, nor a plugin's error for one class every class of that render; a class outside any<Tailwind>isn't compiled; acid:or base64 imageurl()is kept; a link ofnull(<Button href={null}>, which failed in 0.2.1 too) is none, and anullMenu item or Social icon is skipped (0.2.1 failed too); a Tailwind plugin that throws for one tenant's class no longer makes another tenant's render on the same handle go out without its styles; and the Markdown guide gives its output, time and memory per kilobyte by shape, the most where every few characters are a block of their own, a heading, a list item or a table row (100 KB: 0.5 to 28 MB of HTML, in up to 0.38 s, a 90 to 350 MB peak, inside a 128 MB heap, the densest shape in 192 MB; 1 MB: 5 to 276 MB, up to 3.4 s, a 270 MB to 1.9 GB peak), and recommends capping user Markdown at about 100 KB.11ec4e2, fixed with tests that fail without each fix: a Liquid tag with a quoted default ({{ first_name | default: 'friend' }}) beside inline markup in a Paragraph's, Heading's or Button's children came out as'friend'in the HTML and the design, which the email service's Liquid rejects (0.2.1 kept it), and a background image URL's{{ campaign_id }}as{{%20campaign_id%20}}: both keep the tag as written now; markup a component writes as HTML with a tag that never ends before an unclosed<style>(<img src=x onerror=alert(1) <style>) came out as a live handler: such a tag is left out with what follows it, as a browser reads it; a Row's or block's display condition (the editor's,{% if customer.vip %}…{% endif %}), which design-to-code keeps from an editor design, was left out of the HTML (0.2.1 too), so a VIP-only row went to everyone: it's written around the row or block now, as the editor's export does, and design-to-code says where a row's or block's merge tag rule isn't applied; a Table's typedheaderColor,contentColorand fonts no longer warn that they're ignored. The README now says whatmergeTagStateis (the editor's state for a Table's repeat rule, not merge tag values).6472ab3, fixed with tests that fail without each fix: a background image URL's email-service tag with quotes, a comparison or brackets ({{ hero | default: 'hero.png' }},{% if tier > 1 %}, AMPscript,<%hero%>) was percent-encoded, which failed the service's parse of the whole email (0.2.1 kept it), and a character reference that reads as a quote or a bracket got past that encoding into the row's style: the URL is now written by one function that encodes such references, keeps a tag as written but a"(write a tag's strings in single quotes there), and encodes the whole URL where a tag's quotes would take a bracket out of its CSS string; rich text's double quotes stay as written, as its single quotes do; a lone<Link>to SendGrid's<%asm_group_unsubscribe_raw_url%>stays a Paragraph with its URL as written (it was an Html block with the tag escaped, a dead unsubscribe link); a comment in rich text, one left open too, is left out (it hid the rest of the email); a design's timer, form or custom block kept as an<Html>block keeps its display condition and anchor; a heading's plain-text capitals leave every common service's tags as written ({{ FIRST_NAME }}before, 0.2.1 too); a Table's cells from JSON that aren't strings (a quantity) are their text, where the render failed.78bdc8f, fixed with tests that fail without each fix: rich text's quotes, written back as written, could end a kept attribute whose value held a<(the round's own regression): a kept attribute's<and>are references now, so a tag is read whole; a background URL's tag could end the Outlook conditional comment its VML is in: such a URL is encoded whole; the root'svalues._metaid and class names are kept to what they can hold, as a Row's and a block's are (0.2.1 wrote them as given); and a Date or an object in a Paragraph's, Heading's or Button's text fails the render, as React's does and the README says, where it was left out without a word (0.2.1 too).18516bd, fixed with tests that fail without each fix: rich text's stray<and>around a tag taken out are text now (a seeded test builds markup of random pieces and checks only rich text's own tags and attributes come out); astylevalue with a"(a font stack,url("…")) is kept, written as", where it was left out (0.2.1 kept a Row's); a Tailwind width class sizes an image whosesrcgives its natural size (w-8rendered at 256px); a Table's values type has the merge tag rule the README shows; a Heading level that isn't a tag name is an h1, and said.ee41b51, fixed with a test that fails without it:toPlainText()ofprettyHTML gave links the wrong URLs, as prettier writes a long line's end tag as</a\n >: such end tags are read as one now. An image'ssrc.maxWidthwins over a Tailwind width class again, as a flatmaxWidthdoes.b35cc79, fixed with tests that fail without each fix: a context the template doesn't provide read another request's value after the app'srenderToStringfailed inside its provider, and a stream and a failedrenderToStringcould make a tenant's value the default Elements learned for good (see above); a<Link>'s or inline<a>'s href with a quoted Liquid default ({{ email | default: 'none' }}) was written as'none', which Liquid rejects; an Image with its natural size, which an email writes twice (Outlook's copy and everyone else's), got itsstyleandclassNameon Outlook's copy only (arounded-fullavatar came out square elsewhere); and esbuild couldn't bundle the async, Tailwind or codegen entry without prettier (below).b35cc79, each with its own method (three reading the code; seven running it against the built package: real Liquid, Handlebars and Mustache engines on the output, a sanitizer fuzz checked with an HTML parser, 0.2.1 parity on 100+ templates, the editor and design-to-code round trip, the docs' claims run as written, engine, concurrency and cost, and one with no brief), found no blockers. The rounds before found what the bullets above list.workerd(no Node compatibility flag) give byte-identical output to Node. That check found that esbuild (wrangler's, a serverless bundler's) couldn't bundle the async, Tailwind or codegen entry without prettier installed, as their optionalimport()s had literal names: prettier is loaded by a name no bundler follows now, and a test bundles every entry with esbuild with prettier missing. The React matrix (18.0.0, 18.2.0, 18.3.1, 19.1.0, 19.2.9, 19.3.1; dev and prod, tests on Node 20) passed locally onb35cc79.test:visualmatches 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).renderToHtml0.97–0.99×,renderToPlainTextabout 0.96×,renderToJson1.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.javascript:URLs written as one that throws with React 18 too and in the exporters' own links, a render's<Tailwind>scopes given only their records and a key for the render, a plugin's error made its component's, base64 image URLs in CSS values, links and Menu and Social entries ofnull; the last round about 1 KB: a Row's anchor, a Video's link and thumbnail and a Social icon's image kept from adding markup, a Font'sjavascript:URL loading nothing, Tailwind classes in Heading and Button children and on a lone<Link>,nullprops, a<script>'s or<style>'s text left out of rich text; this round about 8.7 KB, most of it HTML's plain text read in time linear in its size by scanners of its own, then a content prop given asnullrendering nothing, a Video's id kept from ending its player's URL and rich text's markup read in time linear in its size, less 1.4 KB from the scanners sharing one loop and one binary search; this round about 1.9 KB: Markdown's and code's work shared across a render, plain text's tag names indexed by their parts, a rich text block's markup given to<Tailwind>; this round about 1.5 KB: a render's shared work kept to its own render, code themes read by their own names, plain text's skip names read as written; this round about 1.8 KB: a render's work shared across copies of Elements, plain text's placeholders and blocked URLs, text in a Row or Column said once per render; and the last four rounds about 4.2 KB: an email service's tags kept whole in rich text, background URLs and plain text, display conditions, rich text's sanitizer and comments, a root's ids, object children failing the render, style values' double quotes, Tailwind widths on sized images, a Heading's level checked and a Table's cells from data; so the budget goes from 203 KB to 225.5 KB).LICENSEagain (0.2.1's did; pnpm skips the repository's copy when another file matches LICENSE, asTHIRD_PARTY_LICENSES.mddoes), and CI's pack steps (Elements' and migrate's) and the publish jobs now fail without one. Each entry'simportandrequireget their own types (.d.ts,.d.cts), andtypesVersionsgivesmoduleResolution: "node"the subpaths: arethetypeswrong finds no problems on all 8 entries for node10, node16 (CJS and ESM) and bundler, where 0.2.1's main entry was "Masquerading as ESM". The code and Markdown entries' types no longer import highlight.js, which is built in (TS2307 withoutskipLibCheck; CI's check now finds an import of it or of marked in either quotes or as animport()type), andregisterLanguage()still takes highlight.js's language modules. npm and pnpm projects on React 18 and 19 import or require every entry and type-check them withskipLibCheck: falseon TypeScript 5.9 and 7.0 (bundler, node16 ESM and CommonJS, nodenext), and withmoduleResolution: "node"withskipLibCheckon (without it,@unlayer/types, a dependency, has no node10 types for its subpaths, as in 0.2.1); the README's quick start renders. The JavaScript each entry resolves to is unchanged.next buildon 16.4.0 now stops with a SecurityFatalError, as npm lists advisories against 16.0.0 to 16.4.0 (two critical, one high) published since its last run;tests/next16-integrationpins 16.4.2, the latest 16.x and outside them. The Next.js 15 and 14 apps' builds don't stop, so they're unchanged.tailwindcssandprettierstay optional peer dependencies (pnpm's isolatednode_modulesand Yarn PnP resolve them through that declaration), with the range*. The ranges this PR had before (>=4.0.0,>=3.0.0) madenpm installfail with ERESOLVE in any project that already had Tailwind CSS 3 or prettier 2 (0.2.1 declared only React). With one of those installed,loadTailwind(and<Tailwind config>in the async renderers) andprettynow say to upgrade it (npm install tailwindcss@4,npm install prettier@3), instead ofTypeError: run is not a functionand "install prettier"; without them, the messages are as before. CI installs the packed tarball in a project with tailwindcss 3.4.17 and prettier 2.8.8 and checks both messages, as a module and required; the main entry's build is unchanged.Release notes
onError: "omit"keeps omission, listed); the upgrade guide in the guides PR covers it. The version is committed inpackages/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.2project.@unlayer/migrate's Elements range goes to^0.3.0here. 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.0.3.0 (2026-10-22), 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.@unlayer/react-elements-previousalias to 0.3.0.📖 Storybook Preview: https://unlayer.github.io/elements/pr/69/