LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 1 views

Lexxy on an Action Text model that already has Trix in it

Every Action Text app has the same open question since 23 September, when Jorge Manrubia presented Lexxy at Rails World: is it time to move off Trix, and what does the move do to the rows already in action_text_rich_texts. The gem's own pitch answers half of that. "Works seamlessly with Action Text, generating the same canonical HTML format it expects for attachments" is the last line of its feature list, and it is true. It is also not the same claim as "nothing in your table changes".

What follows was run in a scratch Rails app with one model, Post, one has_rich_text :body, and both editors rendered against that same attribute:

<%= form.rich_text_area :body %>
<%= form.lexxy_rich_text_area :body %>

Versions, because half the answers depend on them: lexxy 0.9.33, rails 8.1.3.1, actiontext 8.1.3.1, trix 2.1.19 (read from window.Trix.VERSION in the page), Ruby 4.0.5, PostgreSQL 17.7 on port 15432, Chrome 152 on an Apple M2 Max with 12 cores, macOS arm64-darwin25.

Lexxy is a gem from Basecamp and it has no installer

gem "lexxy" resolved to 0.9.33, published to RubyGems on 2026-09-22, MIT, authored by Jorge Manrubia, with exactly one runtime dependency: railties >= 8.0.2. It is not part of Rails and nothing in rails new puts it there.

There is no rails lexxy:install. find over the unpacked gem returns nothing matching *generator* or *install*. The three things installation actually needs are documentation steps you either perform or do not: two importmap pins, an import "lexxy", and a stylesheet link. Compare bin/rails action_text:install, which writes the migrations, the two view partials, the CSS, the pins and the imports in one command.

The one that bites is the second pin. Lexxy loads Active Storage lazily, at line 12,830 of its bundle:

const { DirectUpload } = await import('@rails/activestorage');

bin/rails action_text:install pins trix and @rails/actiontext and not @rails/activestorage, because actiontext.esm.js carries its own copy of the direct upload client, SparkMD5 and all. So the pin Lexxy needs is one nobody's importmap already has. I removed it to see what happens. The page loads, the custom element upgrades, the toolbar renders, typing works and the form submits. Calling the import by hand is the only thing that says anything:

TypeError: Failed to resolve module specifier '@rails/activestorage'

That error arrives the first time a user drags in an image, which is to say in production, on a Thursday.

The two integration paths, and Rails 8.1 is on the older one

Lexxy decides at boot which way to hook into Action Text:

def supports_editor_adapter?
  !!(defined?(ActionText::Editor) && ActionText::Editor.instance_method(:editor_tag).parameters.assoc(:block))
end

On this app it returns false, and defined?(ActionText::Editor) is nil. Rails 8.1.3.1 has no editor adapter, so the engine takes the branch its own comment calls the "Rails 8.0/8.1 fallback: monkey-patch Action Text helpers". Lexxy.override_action_text_defaults then aliases rich_textarea_tag, rich_text_area_tag, rich_textarea, rich_text_area and ActionView::Helpers::Tags::ActionText#render onto the Lexxy versions. It is on by default; config.lexxy.override_action_text_defaults = false is how you get both helpers side by side, which is what made the rest of this page possible.

ActionText::Editor does exist on rails/rails main. I fetched actiontext/lib/action_text/editor.rb from the main branch today and it is a real class with as_canonical, as_editable and editor_tag, plus Editor::Registry and Editor::Configurator. Which released Rails ships it is not something I can read off that file, so I am not going to say.

The two paths produce different markup. Here is the same form.rich_text_area :body and form.lexxy_rich_text_area :body, rendered on the same Post.new:

<input type="hidden" name="post[body]" id="post_body_trix_input_post" />
<trix-editor id="post_body" input="post_body_trix_input_post" class="trix-content" ...></trix-editor>

<lexxy-editor id="post_body" input="post_body_trix_input_post" name="post[body]" class="lexxy-content" ...></lexxy-editor>

Trix is a <trix-editor> beside a hidden input that carries the value. Lexxy is one element that carries the value itself: its prototype has form, name, required, validity, checkValidity, reportValidity and formResetCallback, so it is a form-associated custom element and the hidden input is gone.

The input="post_body_trix_input_post" on the Lexxy tag points at an element that no longer exists. It is set unconditionally in Lexxy::ActionTextTag#lexxy_render, joining the id with :trix_input. Harmless, and a good reminder of which layer you are standing on.

The HTML each one writes for the same input

Both editors were handed the same fragment through their own load path: editor.loadHTML(html) for Trix, element.value = html for Lexxy, then the value read back.

Input:

<h2>Heading</h2>
<ul><li>one</li><li>two</li></ul>
<p>Plain <strong>bold</strong> text with <a href="https://example.com">a link</a></p>
<pre><code>puts 1</code></pre>
<blockquote>quoted</blockquote>

Trix 2.1.19:

<div><strong>Heading</strong></div><ul><li>one</li><li>two</li></ul><div>Plain <strong>bold</strong> text with <a href="https://example.com">a link</a></div><pre>puts 1</pre><blockquote>quoted</blockquote>

Lexxy 0.9.33:

<h2>Heading</h2><ul><li value="1">one</li><li value="2">two</li></ul><p>Plain <strong>bold</strong> text with <a href="https://example.com">a link</a></p><pre data-language="javascript" data-highlight-language="javascript">puts 1</pre><blockquote>quoted</blockquote>

The <h2> is the interesting loss. Trix has one heading level, so a second-level heading does not degrade to <h1>, it degrades to bold text in a <div> and the fact that it was a heading is gone from the row forever. If your content came out of a Markdown import or a paste from a doc, this has already happened to you and the only trace is that your article pages have no subheadings.

The <p> claim in Lexxy's README holds: paragraphs are <p>. The value="1" and value="2" on the list items are new, they are in Action Text's allowlist because Lexxy put value there (see below), and they are noise in every list you save.

The <pre> line is the one to watch. Both editors dropped the inner <code>. Lexxy then labelled a block of Ruby data-language="javascript", because nothing in the input said otherwise and JavaScript is what its Prism setup falls back to. If you render stored Action Text through a server-side highlighter keyed on that attribute, every code block pasted without an explicit language comes out highlighted as the wrong one.

What Lexxy does to a row Trix wrote

The migration question has a clean answer. I took a fragment in Trix's own storage shape, the sort of thing sitting in action_text_rich_texts.body in an app that has been running for three years, and loaded it into both.

<h1>Release notes</h1><div>First line<br>second line</div><div>after a blank line</div><div><em>italic</em> and <del>struck</del></div><ul><li>a</li><li>b</li></ul><blockquote>quoted<br>over two lines</blockquote>

Trix read it back byte for byte identical. Lexxy read it back like this:

<h1>Release notes</h1><p>First line<br>second line</p><p>after a blank line</p><p><em>italic</em> and <s>struck</s></p><ul><li value="1">a</li><li value="2">b</li></ul><blockquote>quoted<br>over two lines</blockquote>

Nothing was lost. Every block was rewritten. <div> became <p>, <del> became <s>, the list items grew value attributes. That is a better document and it is also a diff on every row somebody opens and saves after the switch, which means: your updated_at columns all move, your audit log fills with edits nobody made, any diff-based revision history you built shows a whole-document change for a typo fix, and the CSS you wrote against .trix-content del stops matching. There is no bulk migration in the gem and no documentation page about existing content. The rewrite happens one row at a time, whenever a user happens to open that record, so you get it spread over months rather than in one deploy you could plan around.

There is a second file that has to move on the same day and nothing warns you. bin/rails action_text:install writes this:

<%# app/views/layouts/action_text/contents/_content.html.erb %>
<div class="trix-content">
  <%= yield -%>
</div>

Lexxy's four stylesheets contain zero occurrences of trix-content. Everything is scoped to :where(.lexxy-content). So until you edit that partial, every rendered rich text on the site is styled by actiontext.css and none of Lexxy's content styles apply. The docs do say the wrapper has to be lexxy-content; they say it on the CSS setup page, not in the install steps.

Lexxy widens the sanitizer for the whole application

Lexxy's engine has an initializer called lexxy.sanitization that rewrites ActionText::ContentHelper.allowed_tags and allowed_attributes globally, on the action_text_content load hook. Measured before and after in one process:

allowed_tags       rails=46  with lexxy=55  added=["audio", "embed", "source", "table", "tbody", "td", "th", "tr", "video"]
allowed_attributes rails=22  with lexxy=28  added=["controls", "data-language", "poster", "start", "style", "value"]
css var() allowed  = true

It also appends "var" to Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS.

style is the one worth stopping on. Action Text's default sanitizer strips inline styles from rich text, and after this gem is in the Gemfile it does not, for every rich text record in the app, including the ones written in Trix years ago and including any content that arrived from somewhere other than your own editor. Whether that matters depends on whether anything but a trusted author can put HTML into a has_rich_text column in your app. If the answer is "a customer support agent pastes from a customer's email", it matters.

The gem prepends a second thing app-wide, ActiveStorage::BlobWithPreviewUrl, which overrides ActiveStorage::Blob#as_json to add previewable and url keys for previewable blobs. ActiveStorage::Blob.ancestors.include?(ActiveStorage::BlobWithPreviewUrl) is true after boot. I could not make it fire: this scratch app has no image_processing, so my test blob was not previewable? and as_json came back with the stock ten keys. If you serialise blobs anywhere in a JSON API, check that yourself rather than taking my word for it.

Attachments: the canonical tag survives, and picks up a passenger

Here is what Action Text stores for an image, which is the same for both editors because it is Action Text's format, not the editor's:

One row in action_text_rich_texts goes out to two editors and comes back two different ways. Trix converts the canonical attachment tag into a figure on the way out and Action Text converts it back on the way in, so a Trix row returns byte for byte identical. Lexxy converts in neither direction, but it staples a content attribute onto the canonical tag and rewrites every block, so the row that is saved is not the row that was loaded.

<action-text-attachment sgid="..." content-type="image/png" filename="pixel.png" filesize="70" previewable="true"></action-text-attachment>

Trix cannot read that, so rich_text_area_tag converts it on the way into the form. The hidden input's value contains:

<figure data-trix-attachment="{&quot;sgid&quot;:&quot;...&quot;,&quot;contentType&quot;:&quot;image/png&quot;,&quot;filename&quot;:&quot;pixel.png&quot;,&quot;filesize&quot;:70,&quot;previewable&quot;:true}"></figure>

and Action Text converts it back on the way in. Lexxy skips the round trip. Its value attribute carries the canonical tag untouched, plus one addition, made by Lexxy::TagHelper#render_custom_attachments_in:

node["content"] = render_action_text_attachment(attachment).to_json

So the tag gains a content attribute holding a JSON-encoded, server-rendered copy of app/views/active_storage/blobs/_blob.html.erb, <figure>, <img>, signed representation URL, figcaption and all. That is how Lexxy shows a preview without knowing anything about your partial, and it is a good trick.

The cost is on the wire and it is measurable. Same post, same attachments, rendered through ApplicationController.renderer:

attachments Trix tag Lexxy tag
0 398 bytes 339 bytes
1 864 bytes 1,523 bytes
10 5,031 bytes 12,170 bytes

With zero attachments Lexxy is smaller, because there is no hidden input. With ten it is 2.4 times larger. The full production responses for that ten-attachment record were 5,049 and 12,220 bytes.

Then there is where that content attribute ends up. ActionText::Attachment::ATTRIBUTES is:

["sgid", "content-type", "url", "href", "filename", "filesize", "width", "height", "previewable", "presentation", "caption", "content"]

content is on the list, so when the form comes back it is written into the row. I checked what Lexxy actually serialises: I loaded the server-rendered value into the editor and read .value back, and got

<p>before</p><p><action-text-attachment sgid="..." content="..." content-type="image/png"></action-text-attachment></p>

filename, filesize and previewable were dropped, which is fine because Action Text recomputes them from the sgid. content was kept, which is not fine, because a rendered snapshot of your attachment partial is now frozen into the database. Change _blob.html.erb and old rows keep rendering the old markup.

And that <p> wrapper has a consequence I did not expect. _blob.html.erb renders a <figure>, and a <figure> cannot live inside a <p>, so the HTML parser splits it. Storing the fragment above and calling post.body.to_s gives an empty <action-text-attachment></action-text-attachment> inside the paragraph, the real <figure> hoisted out as a sibling, and a stray empty <p></p> after it. With the attachment as a top-level sibling instead, it renders correctly: <action-text-attachment ...><figure>...</figure></action-text-attachment>. I reached that nesting by driving the editor's value property rather than by uploading a file through the UI, so treat it as a thing to check on your own content before you switch, not as a filed bug.

The JavaScript, weighed

Sizes as the gems ship them, raw and under gzip -9:

file raw gzip -9
trix.js 526,142 102,669
actiontext.esm.js 31,650 6,873
Trix total 557,792 109,542
lexxy.js 999,642 280,624
lexxy.min.js 632,522 192,978
activestorage.esm.js 28,343 6,422

The documented importmap pin is pin "lexxy", to: "lexxy.js", the unminified build, which is what I measured on the page. What is inside it, from the 140 entries in lexxy.js.map, by sourcesContent bytes: Lexxy's own source 477,989, prismjs 145,879, lexical 136,658, dompurify 63,925, @lexical/table 61,805, marked 39,971, then eleven more @lexical/* packages. You are shipping a syntax highlighter, a Markdown parser and an HTML sanitizer to the browser whether or not you use code blocks, Markdown or paste.

Bytes are the cheap part. What costs is parse and evaluate. I fetched each bundle, wrapped it in a unique blob URL so the module cache could not serve it twice, and timed await import() seven times. Chrome 152, M2 Max, medians:

module median range
actiontext.esm.js 0.8 ms 0.6 to 1.0
trix.js 6.5 ms 6.1 to 7.4
lexxy.min.js 28.3 ms 27.2 to 34.4
lexxy.js 32.4 ms 28.4 to 36.2

25 milliseconds of extra main-thread work on a laptop that is at the top of what anyone develops on. And switching the pin to lexxy.min.js, which is the obvious first optimisation, buys 4 ms. The minified build is smaller to send and almost exactly as expensive to run, because the work is compiling 632 KB of JavaScript either way. If the bundle matters to you, the lever is not minification.

One more thing about deployment. RAILS_ENV=production bin/rails assets:precompile put eleven lexxy-named files into public/assets, totalling 4,855,746 bytes, against five files and 632,349 bytes for trix plus actiontext. The largest single file is lexxy-6caf9340.js.map at 2,313,663 bytes, and the app serves it with a 200 because the bundle's last line points at it. The gem also ships pre-built .br and .gz copies of both bundles, which Propshaft digests as if they were independent assets (lexxy.js-101ae8a3.br, lexxy.min.js-aadf1fb9.gz), so they sit in your slug doing nothing at all.

The server barely notices

Server-side rendering cost is a non-issue, which is worth saying because it is the thing people worry about first. ApacheBench 2.3, 300 requests, concurrency 1, RAILS_ENV=production, one Puma worker, against two routes rendering the same ten-attachment post:

/posts/7/trix    155.03 req/s   6.450 ms mean   p50 6   p95  9   p99 20
/posts/7/lexxy   147.97 req/s   6.758 ms mean   p50 6   p95 12   p99 20

4.5 percent fewer requests per second. Both editors load the same blobs; Lexxy additionally renders the attachment partial ten times, and on an M2 Max that is 0.3 ms. The cost of Lexxy is in bytes and in the browser, not in your Puma.

(ApacheBench reports 292 failed requests on the Trix route. They are all 200s. Trix's hidden input id is trix_input_N with a process-wide counter, so the response length changes on every request and ab counts a length mismatch as a failure.)

What actually broke: the system tests

ActionText::SystemTestHelper#fill_in_rich_textarea in actiontext 8.1.3.1 already handles both editors in its script body:

if ("value" in this) { this.value = arguments[0] } else { this.editor.loadHTML(arguments[0]) }

The problem is the Capybara selector in front of it. Capybara.add_selector :rich_textarea matches an element that has role="textbox" and contenteditable, and then filters on the locator matching its id, its placeholder, its aria-label, or its input attribute resolved from a hidden input's name.

A <trix-editor> satisfies all of that itself: it is the contenteditable, it carries role=textbox, its id is post_body, and its input attribute names the hidden input whose name is post[body]. Lexxy splits it. The contenteditable with role="textbox" is a child div, and src/elements/editor.js ids it by appending a suffix:

id: `${this.id}-content`,
role: "textbox",
"aria-label": this.#labelText,

which came out as id="post_body-content" with aria-label="", and there is no hidden input at all. So both of the locators you already have in your suite miss:

fill_in_rich_text_area "post_body",   with: "..."   # id is post_body-content
fill_in_rich_text_area "post[body]",  with: "..."   # no hidden input to resolve the name
fill_in_rich_text_area "post_body-content", with: "..."   # this one works

Two characters in the middle of your suite, once per rich text field. That is the entire breakage, and it is the sort that fails loudly, which is the good kind.

The dead end: you cannot fake a paste

I wanted to check the README's "Markdown support: shortcuts, auto-formatting on paste" and the direct upload path without a human at the keyboard, so I dispatched a synthetic paste at the contenteditable:

const dt = new DataTransfer();
dt.setData("text/plain", "## Release notes\n\n- one\n- two\n");
box.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));

element.value stayed <p><br></p>. So did a synthetic InputEvent with inputType: "insertFromPaste" and the same DataTransfer. Lexical does not take input from events the page manufactures. Trix accepted editor.insertHTML() for the same job because that is a documented API on its editor object; Lexxy exposes value, which goes through its HTML importer and therefore tells you nothing about the paste pipeline.

The consequence is not really about me. It is that any part of your suite that tests rich text by synthesising events rather than driving a real browser will silently do nothing against Lexxy, and an assertion on the empty document is an assertion that passes when you have removed the feature. Lexxy's own gemspec lists capybara ~> 3.0 and cuprite ~> 0.17 as development dependencies, which is the setup this needs: a real CDP browser sending real key and paste events.

I therefore have nothing to say about Markdown shortcuts, auto-formatting on paste, mentions, prompts, or the in-editor PDF and video previews. They may all be excellent. I did not run them.

The verdict, and what would change it

For a new Action Text field, take Lexxy. The HTML it produces is the HTML you would have written, it is a form-associated element so it behaves like an input, and 32 ms of parse is a fair price for an editor that emits <p> and <h2>.

For a column that already holds Trix content, I would not switch this quarter. Not because anything breaks loudly, but because of what does not: every record silently rewritten the first time it is opened, a content snapshot of your attachment partial frozen into rows that used to be regenerated, style allowed through the sanitizer for content you did not author, and a whole-table diff arriving over the next six months rather than in a deploy.

Three things would change that, and all three are somebody else's to ship. The ActionText::Editor adapter that is on rails/rails main landing in a release, which removes the monkey-patch on ActionView::Helpers::FormBuilder and gives the conversion a defined seam. A documented bulk migration, or even a documented statement that the rewrite is expected and safe. And a version number that does not start with 0.9: 0.9.33 shipped on 2026-09-22, four days before this page, and the six versions before it landed within five weeks.

What this page does not cover

Only the importmap install on Rails 8.1, in one scratch app, on one machine. Not the JavaScript bundler or esm.sh routes. Not the ActionText::Editor adapter path, which I read on rails/rails main and did not run. Not prompts, mentions, remote attachments, hotkeys or the extension API. Not accessibility, which the docs have a page on and I did not audit. Not a real file upload through the editor UI, and therefore nothing about how Lexxy serialises an attachment it created itself rather than one it ingested. Not Safari or Firefox: every browser number here is Chrome 152. And not has_many_attached or any Active Storage behaviour beyond what a single has_rich_text column touches.

#rails #action-text

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.