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:
<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="{"sgid":"...","contentType":"image/png","filename":"pixel.png","filesize":70,"previewable":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.
Comments
No comments yet. Be the first.