From f73e5a51763036be71096402aad056bb0ae2ff03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Sun, 5 Jul 2026 08:25:50 +0200 Subject: [PATCH 01/12] Adopt a server-prerendered content element to avoid first-paint reflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` renders empty and builds its contenteditable a frame after load, so an inline field paints at ~0 height and then jumps to full content height, shifting everything below it. That reflow shows on every cold load, reload, and server re-render. Opt in with `rich_text_area(..., prerender: true)`: the helper renders the value into a `.lexxy-editor__content` element inside ``, and on connect LexicalEditorElement adopts that element instead of creating an empty one. Lexical reconciles its parsed state into the adopted element, so the field has its final height from first paint and the live editor lands at the same height — no shift. Absent the prerendered element the empty-editor path is unchanged, so this is backwards compatible and off by default. Verified against the shipped bundle: without adoption the editor builds a second `.lexxy-editor__content` (the server one is orphaned and the field double-renders); with it there is exactly one element and `setRootElement` reconciles into the server node (it gains `data-lexical-editor`). Follow-up: the general, visible-toolbar case still reflows by the toolbar's height when it mounts; reserving that space server-side is left for a later change. --- lib/lexxy/rich_text_area_tag.rb | 20 +++++++++++- src/elements/editor.js | 26 ++++++++++++++- test/browser/fixtures/prerender.html | 24 ++++++++++++++ .../tests/editor/prerender_adoption.test.js | 32 +++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 test/browser/fixtures/prerender.html create mode 100644 test/browser/tests/editor/prerender_adoption.test.js diff --git a/lib/lexxy/rich_text_area_tag.rb b/lib/lexxy/rich_text_area_tag.rb index 7f8cdebfd..d10d84c2c 100644 --- a/lib/lexxy/rich_text_area_tag.rb +++ b/lib/lexxy/rich_text_area_tag.rb @@ -7,6 +7,11 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) # remove the html_safe attribute to preserve attribute escape value = value.to_str if value.respond_to? :to_str + # Opt-in: render the value into a content element the editor adopts on + # connect, so the field has its final height at first paint instead of + # reflowing when the editor builds a frame after load. Off by default. + prerender = options.delete(:prerender) + options[:name] ||= name options[:value] ||= value options[:class] ||= "lexxy-content" @@ -14,13 +19,26 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename") - editor_tag = content_tag("lexxy-editor", "", options, &block) + inner = (block || !prerender) ? "" : prerendered_content_tag(options[:value]) + editor_tag = content_tag("lexxy-editor", inner, options, &block) editor_tag end alias_method :lexxy_rich_text_area_tag, :lexxy_rich_textarea_tag private + # A static copy of the value the editor adopts as its content element on + # connect (see LexicalEditorElement#prerenderedContentElement). It is the + # same HTML the editor parses from `value`, so it renders at the same + # height the live editor lands on. + def prerendered_content_tag(value) + content_tag "div", (value.presence || "


").html_safe, + class: "lexxy-editor__content", + contenteditable: "true", + role: "textbox", + "aria-multiline": "true" + end + # Temporary: we need to *adaptarize* action text def render_custom_attachments_in(value) if value.respond_to?(:body) diff --git a/src/elements/editor.js b/src/elements/editor.js index 7a9564df2..c8b5859c2 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -417,7 +417,7 @@ export class LexicalEditorElement extends HTMLElement { } #createEditor() { - this.editorContentElement ||= this.#createEditorContentElement() + this.editorContentElement ||= this.#prerenderedContentElement() || this.#createEditorContentElement() this.appendChild(this.editorContentElement) const editor = buildEditorFromExtensions({ @@ -467,6 +467,30 @@ export class LexicalEditorElement extends HTMLElement { return nodes } + // Adopt a content element the server prerendered inside us, if present. + // Rendering the body server-side and reusing it here gives the field its final + // height at first paint, avoiding the reflow from building the editor a frame + // after load. Lexical reconciles its parsed state into this element on mount, + // replacing the static markup with the live editor at the same height. Returns + // null when absent (the default), so the empty-editor path is unchanged. + #prerenderedContentElement() { + const element = this.querySelector(":scope > .lexxy-editor__content") + if (!element) return null + + element.id ||= `${this.id}-content` + element.setAttribute("contenteditable", "true") + element.setAttribute("role", "textbox") + element.setAttribute("aria-multiline", "true") + if (!element.hasAttribute("aria-label")) element.setAttribute("aria-label", this.#labelText) + if (this.hasAttribute("placeholder")) element.setAttribute("placeholder", this.getAttribute("placeholder")) + + this.#ariaAttributes.forEach(attribute => element.setAttribute(attribute.name, attribute.value)) + this.#transferAttributeToContentEditable(element, "autocapitalize") + this.#transferAttributeToContentEditable(element, "tabindex", { defaultValue: 0, removeSource: true }) + + return element + } + #createEditorContentElement() { const editorContentElement = createElement("div", { id: `${this.id}-content`, diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html new file mode 100644 index 000000000..efd0a433d --- /dev/null +++ b/test/browser/fixtures/prerender.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Prerender Test + + + +
+
+ + +

Alpha

Bravo

+
+
+
+ + + + diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js new file mode 100644 index 000000000..eef77c442 --- /dev/null +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -0,0 +1,32 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +// When the server prerenders the content element inside (via +// `rich_text_area(..., prerender: true)`), the editor adopts that element on +// connect instead of building an empty one, so the field keeps its first-paint +// height. Without adoption the editor would append a second content element and +// the field would double-render. +test.describe("Prerendered content element", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/prerender.html") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("adopts the server-rendered content element rather than creating a second", async ({ page }) => { + const content = page.locator("lexxy-editor .lexxy-editor__content") + + // Exactly one content element — the server's was reused, not duplicated. + await expect(content).toHaveCount(1) + // ...and it is the very node the server rendered. + await expect(content).toHaveAttribute("data-prerendered", "server") + // Lexical reconciled its state into that adopted node. + await expect(content).toHaveAttribute("data-lexical-editor", "true") + // Content is intact and the field stays editable. + await expect(content.locator("p")).toHaveText([ "Alpha", "Bravo" ]) + await expect(content).toHaveAttribute("contenteditable", "true") + }) + + test("exposes the value once, without duplicating the body", async ({ editor }) => { + expect(await editor.value()).toBe("

Alpha

Bravo

") + }) +}) From bbe806e9a33850195b913c0ca3490c038429cc34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Sun, 5 Jul 2026 08:37:38 +0200 Subject: [PATCH 02/12] Support prerender: on the Rails 8.2 editor-adapter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prerender option only lived in the TagHelper fallback, which Rails 8.2+ never loads — it registers ActionText::Editor::LexxyEditor instead, so `rich_text_area(..., prerender: true)` silently emitted the option as an HTML attribute and the editor still rendered empty. Emit the prerendered content element from the adapter Tag too, via the block Editor::Tag#render_in already supports; an explicit caller block still wins. --- lib/action_text/editor/lexxy_editor.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/action_text/editor/lexxy_editor.rb b/lib/action_text/editor/lexxy_editor.rb index 3c6f24660..87937a968 100644 --- a/lib/action_text/editor/lexxy_editor.rb +++ b/lib/action_text/editor/lexxy_editor.rb @@ -11,6 +11,23 @@ class Editor::LexxyEditor::Tag < Editor::Tag def render_in(view_context, ...) # Strip html_safe to preserve attribute escaping (see #749) options[:value] = options[:value].to_str if options[:value].respond_to?(:to_str) + + # Opt-in: render the value into a content element the editor adopts on + # connect (see LexicalEditorElement#prerenderedContentElement), so the + # field has its final height at first paint instead of reflowing when the + # editor builds a frame after load. Mirrors the same option on the + # Rails 8.0/8.1 TagHelper fallback. + if options.delete(:prerender) && @block.nil? + html = (options[:value].presence || "


").html_safe + @block = proc do + view_context.content_tag "div", html, + class: "lexxy-editor__content", + contenteditable: "true", + role: "textbox", + "aria-multiline": "true" + end + end + super end end From c3d08266f0cad81a197b1581109c8244e49a8b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Sun, 5 Jul 2026 09:24:45 +0200 Subject: [PATCH 03/12] Sanitize the prerendered content and let adoption add the interactive attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardenings of the prerendered content element, extracted into a shared Lexxy::Prerender used by both the editor-adapter Tag and the TagHelper fallback: - Sanitize the value with Action Text's display sanitizer (whose allow-list this engine already extends). The attribute path is HTML-escaped and DOMPurify cleans it client-side before it reaches the live DOM, but the prerendered element enters the DOM straight from storage — unsanitized, a stored

) + + render inline: <<~ERB, locals: { value: value } + <%= rich_textarea_tag :body, value, prerender: true %> + ERB + + # The value attribute is escaped and DOMPurify cleans it client-side, but + # the prerendered element enters the DOM straight from storage — it must go + # through Action Text's sanitizer. + assert_dom "lexxy-editor > div.lexxy-editor__content" do |content, *| + assert_dom "p", text: "Safe" + assert_dom "script", count: 0 + assert_dom "[onerror]", count: 0 + end + end + + test "prerendering a blank value renders the editor's empty paragraph" do + render inline: <<~ERB + <%= rich_textarea_tag :body, nil, prerender: true %> + ERB + + assert_dom "lexxy-editor > div.lexxy-editor__content" do + assert_dom "p br", count: 1 + end + end + + test "an explicit block wins over prerender" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true do %> + custom child + <% end %> + ERB + + assert_dom "lexxy-editor > span#custom", count: 1 + assert_dom "lexxy-editor > div.lexxy-editor__content", count: 0 + end +end From 83652d2e17caeb0f410b5bf0330c14fcff07e6fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Mon, 6 Jul 2026 08:38:27 +0200 Subject: [PATCH 05/12] Add prerender layout shift reproduction --- test/browser/fixtures/prerender.html | 135 +++++++++++++++--- .../tests/editor/prerender_adoption.test.js | 47 ++++-- 2 files changed, 151 insertions(+), 31 deletions(-) diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html index ba700283e..9ef692601 100644 --- a/test/browser/fixtures/prerender.html +++ b/test/browser/fixtures/prerender.html @@ -3,24 +3,127 @@ - Lexxy Prerender Test + Lexxy Prerender Reproduction + -
-
- - -

Alpha

Bravo

-
-
-
- - +
+

Inline editor layout reproduction

+

+ Open this page with ?delay=1500 to slow Lexxy initialization. + The page content below each editor should start in its final position when + the editor is styled to hug and grow with its contents. +

+ +
+
+

Without prerendered content

+ + + + +
+

This content shifts down after Lexxy builds the editor.

+
+
+ +
+

With prerendered content

+ + + +
+

Alpha

+

Bravo

+

Charlie

+

Delta

+
+
+ +
+

This content is already in the right spot before Lexxy connects.

+
+
+
+
+ + diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js index 633049481..d55a7d135 100644 --- a/test/browser/tests/editor/prerender_adoption.test.js +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -1,28 +1,42 @@ -import { test } from "../../test_helper.js" -import { expect } from "@playwright/test" - -// When the server prerenders the content element inside (via -// `rich_text_area(..., prerender: true)`), the editor adopts that element on -// connect instead of building an empty one, so the field keeps its first-paint -// height. Without adoption the editor would append a second content element and -// the field would double-render. +import { expect, test } from "@playwright/test" + +async function topOf(locator) { + return (await locator.boundingBox()).y +} + test.describe("Prerendered content element", () => { test.beforeEach(async ({ page }) => { - await page.goto("/prerender.html") - await page.waitForSelector("lexxy-editor[connected]") + await page.goto("/prerender.html?delay=250") + }) + + test("keeps following content stable when the editor hugs its contents", async ({ page }) => { + const withoutFollowing = page.locator("[data-following='without-prerender']") + const withFollowing = page.locator("[data-following='with-prerender']") + const withoutBefore = await topOf(withoutFollowing) + const withBefore = await topOf(withFollowing) + + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) + + const withoutAfter = await topOf(withoutFollowing) + const withAfter = await topOf(withFollowing) + + expect(withAfter - withBefore).toBeLessThan(1) + expect(withoutAfter - withoutBefore).toBeGreaterThan(60) }) test("adopts the server-rendered content element rather than creating a second", async ({ page }) => { - const content = page.locator("lexxy-editor .lexxy-editor__content") + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) - // Exactly one content element — the server's was reused, not duplicated. + const content = page.locator("[data-example='with-prerender'] lexxy-editor > .lexxy-editor__content") + + // Exactly one content element: the server's was reused, not duplicated. await expect(content).toHaveCount(1) // ...and it is the very node the server rendered. await expect(content).toHaveAttribute("data-prerendered", "server") // Lexical reconciled its state into that adopted node. await expect(content).toHaveAttribute("data-lexical-editor", "true") // Content is intact... - await expect(content.locator("p")).toHaveText([ "Alpha", "Bravo" ]) + await expect(content.locator("p")).toHaveText([ "Alpha", "Bravo", "Charlie", "Delta" ]) // ...and adoption dressed the static server markup with the interactive // attributes the server deliberately omits (it isn't editable until now). await expect(content).toHaveAttribute("contenteditable", "true") @@ -30,7 +44,10 @@ test.describe("Prerendered content element", () => { await expect(content).toHaveAttribute("aria-multiline", "true") }) - test("exposes the value once, without duplicating the body", async ({ editor }) => { - expect(await editor.value()).toBe("

Alpha

Bravo

") + test("exposes the value once, without duplicating the body", async ({ page }) => { + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) + + const value = await page.locator("[data-example='with-prerender'] lexxy-editor").evaluate(editor => editor.value) + expect(value).toBe("

Alpha

Bravo

Charlie

Delta

") }) }) From 48c0bfdbaa4dfc7e4d8194f993826673f43778c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Mon, 6 Jul 2026 08:57:33 +0200 Subject: [PATCH 06/12] Keep toolbar visible in prerender layout reproduction --- test/browser/fixtures/prerender.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html index 9ef692601..07f3c6cd8 100644 --- a/test/browser/fixtures/prerender.html +++ b/test/browser/fixtures/prerender.html @@ -33,7 +33,7 @@ background: #ffffff; border: 1px solid #d8d5ce; border-radius: 8px; - padding: 24px; + padding: 56px 24px 24px; } .example h2 { @@ -55,6 +55,15 @@ padding: 0; } + .inline-editor lexxy-toolbar { + background: #ffffff; + border-block-end: 1px solid #d8d5ce; + inset-block-end: 100%; + inset-inline: 0; + position: absolute; + z-index: 1; + } + .following-content { border-block-start: 1px solid #d8d5ce; margin-block-start: 16px; @@ -83,7 +92,6 @@

Without prerendered content

@@ -103,7 +111,6 @@

With prerendered content

`data-prerendered` lets the spec prove the same node. -->

Alpha

From b95a74f5f15bf73c028b1f662a2be8ef9931ed8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Mon, 6 Jul 2026 09:10:05 +0200 Subject: [PATCH 07/12] Hide reproduction toolbar until keyboard focus --- test/browser/fixtures/prerender.html | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html index 07f3c6cd8..020a0597e 100644 --- a/test/browser/fixtures/prerender.html +++ b/test/browser/fixtures/prerender.html @@ -33,12 +33,12 @@ background: #ffffff; border: 1px solid #d8d5ce; border-radius: 8px; - padding: 56px 24px 24px; + padding: 24px; } .example h2 { font-size: 1rem; - margin-block: 0 16px; + margin-block: 0 56px; } .inline-editor { @@ -58,12 +58,22 @@ .inline-editor lexxy-toolbar { background: #ffffff; border-block-end: 1px solid #d8d5ce; - inset-block-end: 100%; + inset-block-end: calc(100% + 8px); inset-inline: 0; + opacity: 0; + pointer-events: none; position: absolute; + visibility: hidden; z-index: 1; } + .inline-editor:has(.lexxy-editor__content:focus-visible) lexxy-toolbar, + .inline-editor:has(lexxy-toolbar:focus-within) lexxy-toolbar { + opacity: 1; + pointer-events: auto; + visibility: visible; + } + .following-content { border-block-start: 1px solid #d8d5ce; margin-block-start: 16px; From a00a6ec9e53a83f58d2a96f550bc29af2e1169c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Thu, 9 Jul 2026 09:10:43 +0200 Subject: [PATCH 08/12] Address Copilot review: fix sanitize formatting and test import --- lib/lexxy/prerender.rb | 4 ++-- test/browser/tests/editor/prerender_adoption.test.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/lexxy/prerender.rb b/lib/lexxy/prerender.rb index aa6327907..5c75606d6 100644 --- a/lib/lexxy/prerender.rb +++ b/lib/lexxy/prerender.rb @@ -16,8 +16,8 @@ module Prerender # isn't there yet — keystrokes would be discarded on adoption. The editor # adds them when it adopts the element. def self.content_tag_for(view, value) - html = view.sanitize (value.presence || "


"), - tags: view.sanitizer_allowed_tags, attributes: view.sanitizer_allowed_attributes + html = view.sanitize(value.presence || "


", + tags: view.sanitizer_allowed_tags, attributes: view.sanitizer_allowed_attributes) view.content_tag "div", html, class: "lexxy-editor__content" end diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js index d55a7d135..b3ef7b479 100644 --- a/test/browser/tests/editor/prerender_adoption.test.js +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -1,4 +1,5 @@ -import { expect, test } from "@playwright/test" +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" async function topOf(locator) { return (await locator.boundingBox()).y From 55c5b4c558899a478e431e54ce3a10d4f3b74f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Thu, 9 Jul 2026 09:18:06 +0200 Subject: [PATCH 09/12] Drive editor loading from the test to remove timer race --- test/browser/fixtures/prerender.html | 11 +++++++++-- test/browser/tests/editor/prerender_adoption.test.js | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html index 020a0597e..141af2862 100644 --- a/test/browser/fixtures/prerender.html +++ b/test/browser/fixtures/prerender.html @@ -138,9 +138,16 @@

With prerendered content

diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js index b3ef7b479..fcedba95f 100644 --- a/test/browser/tests/editor/prerender_adoption.test.js +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -7,7 +7,7 @@ async function topOf(locator) { test.describe("Prerendered content element", () => { test.beforeEach(async ({ page }) => { - await page.goto("/prerender.html?delay=250") + await page.goto("/prerender.html?manual") }) test("keeps following content stable when the editor hugs its contents", async ({ page }) => { @@ -16,6 +16,7 @@ test.describe("Prerendered content element", () => { const withoutBefore = await topOf(withoutFollowing) const withBefore = await topOf(withFollowing) + await page.evaluate(() => window.loadLexxy()) await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) const withoutAfter = await topOf(withoutFollowing) @@ -26,6 +27,7 @@ test.describe("Prerendered content element", () => { }) test("adopts the server-rendered content element rather than creating a second", async ({ page }) => { + await page.evaluate(() => window.loadLexxy()) await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) const content = page.locator("[data-example='with-prerender'] lexxy-editor > .lexxy-editor__content") @@ -46,6 +48,7 @@ test.describe("Prerendered content element", () => { }) test("exposes the value once, without duplicating the body", async ({ page }) => { + await page.evaluate(() => window.loadLexxy()) await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) const value = await page.locator("[data-example='with-prerender'] lexxy-editor").evaluate(editor => editor.value) From 2773949c723130020eaf70faf32e3c5d9839697e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Thu, 9 Jul 2026 09:32:37 +0200 Subject: [PATCH 10/12] Wait for Lexical mount and drop unused assert_dom block params --- test/browser/tests/editor/prerender_adoption.test.js | 5 ++++- test/helpers/prerender_test.rb | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js index fcedba95f..4edcb4e91 100644 --- a/test/browser/tests/editor/prerender_adoption.test.js +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -17,7 +17,10 @@ test.describe("Prerendered content element", () => { const withBefore = await topOf(withFollowing) await page.evaluate(() => window.loadLexxy()) - await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) + // `connected` is set before Lexical mounts (mount happens in a following + // animation frame), so wait for the mounted roots or the height change we + // assert on could be measured before it happens. + await expect(page.locator("lexxy-editor .lexxy-editor__content[data-lexical-editor='true']")).toHaveCount(2) const withoutAfter = await topOf(withoutFollowing) const withAfter = await topOf(withFollowing) diff --git a/test/helpers/prerender_test.rb b/test/helpers/prerender_test.rb index 15db38ea1..e913120e2 100644 --- a/test/helpers/prerender_test.rb +++ b/test/helpers/prerender_test.rb @@ -50,7 +50,7 @@ class PrerenderTest < ActionView::TestCase # The value attribute is escaped and DOMPurify cleans it client-side, but # the prerendered element enters the DOM straight from storage — it must go # through Action Text's sanitizer. - assert_dom "lexxy-editor > div.lexxy-editor__content" do |content, *| + assert_dom "lexxy-editor > div.lexxy-editor__content" do assert_dom "p", text: "Safe" assert_dom "script", count: 0 assert_dom "[onerror]", count: 0 From c3e5162afe1deff9b99c8da6dac9d04775d058a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Mon, 3 Aug 2026 08:17:41 +0200 Subject: [PATCH 11/12] Assert absolute displacement, and pin the toolbar boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review points from Copilot on the rebased branch. The stability assertion compared a signed delta against 1, so a large upward shift would have passed as a comfortably negative number. Compare absolute displacement. The second is substantive and correct. Prerendering reserves the content element's height, but #createDefaultToolbar prepends the default toolbar in normal flow during connectedCallback, so an editor using it still gains that height on mount — measured at 41px, exactly the toolbar's height, once the fixture's out-of-flow positioning is removed. The fixture floats its toolbar, so nothing exercised this. Scope it explicitly rather than leave it implied: document what the option does and does not reserve, and add a test that puts the toolbar back in flow and pins the residual shift to the toolbar's height. The editors this option is for float the toolbar out of flow — an inline field standing in for published copy has nowhere to put one — but a reader should not have to infer that from the fixture's CSS. --- lib/lexxy/prerender.rb | 9 ++++++ .../tests/editor/prerender_adoption.test.js | 29 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/lexxy/prerender.rb b/lib/lexxy/prerender.rb index 5c75606d6..6d56c64f1 100644 --- a/lib/lexxy/prerender.rb +++ b/lib/lexxy/prerender.rb @@ -2,6 +2,15 @@ module Lexxy # Builds the static content element a prerendering editor emits inside # (see LexicalEditorElement#prerenderedContentElement). Shared # by the Rails 8.2 editor-adapter Tag and the 8.0/8.1 TagHelper fallback. + # + # Scope: this reserves the height of the editor's *content* — the part that + # varies per record, and so cannot be reserved by a CSS rule the way a fixed + # row count can. It does not reserve the default toolbar, which + # connectedCallback prepends in normal flow: an editor using it still gains + # that element's height on mount. The editors this option exists for float the + # toolbar out of flow, since an inline field standing in for published copy has + # nowhere to put one, but the boundary is real and is pinned by + # test/browser/tests/editor/prerender_adoption.test.js. module Prerender # The value is the same editable HTML the editor parses from the `value` # attribute, so the element renders at the height the live editor lands on. diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js index 4edcb4e91..dacc97521 100644 --- a/test/browser/tests/editor/prerender_adoption.test.js +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -25,10 +25,37 @@ test.describe("Prerendered content element", () => { const withoutAfter = await topOf(withoutFollowing) const withAfter = await topOf(withFollowing) - expect(withAfter - withBefore).toBeLessThan(1) + // Absolute displacement: a signed comparison would let a large *upward* shift + // through as a comfortably negative number. + expect(Math.abs(withAfter - withBefore)).toBeLessThan(1) expect(withoutAfter - withoutBefore).toBeGreaterThan(60) }) + // Prerendering reserves the height of the editor's *content*, which is the part + // whose height depends on the record. The default toolbar is a separate element + // that connectedCallback prepends in normal flow, so an editor using it still + // gains that much height on mount. Editors this option is for float the toolbar + // out of flow — the fixture does, and an inline field that must match published + // copy could hardly do otherwise — but the boundary should be visible rather + // than implied, so pin it. + test("reserves the content height, not an in-flow default toolbar's", async ({ page }) => { + await page.addStyleTag({ content: `.inline-editor lexxy-toolbar { + position: static !important; opacity: 1 !important; visibility: visible !important; inset: auto !important; }` }) + + const following = page.locator("[data-following='with-prerender']") + const before = await topOf(following) + + await page.evaluate(() => window.loadLexxy()) + await expect(page.locator("lexxy-editor .lexxy-editor__content[data-lexical-editor='true']")).toHaveCount(2) + + const after = await topOf(following) + const toolbar = await page.locator("[data-example='with-prerender'] lexxy-toolbar").boundingBox() + + // The residual shift is the toolbar's height and nothing more — the body, which + // is the part that varies per record, is still fully reserved. + expect(after - before).toBeCloseTo(toolbar.height, 0) + }) + test("adopts the server-rendered content element rather than creating a second", async ({ page }) => { await page.evaluate(() => window.loadLexxy()) await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) From 99e221e8d611fab536b904d482d9355544047dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Dubois?= Date: Mon, 3 Aug 2026 08:43:48 +0200 Subject: [PATCH 12/12] Reserve the toolbar's height too, so prerendering covers the whole editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerendering reserved the content element's height but not the toolbar's, so an editor using the default in-flow toolbar still moved the page by exactly that element's height on mount — 41px, measured with the fixture's out-of-flow positioning removed. The option is meant to mean "this editor will not shift", so make it true rather than document the gap. Ship the toolbar itself, empty, ahead of the content element, and fill it in on connect instead of building a second one. It has to be a real rather than a neutral spacer: whether a toolbar takes space at all is the host's CSS to decide, and an inline editor floats it out of flow. A spacer sized from --lexxy-toolbar-height would reserve height nothing fills there and shift the page *up* — which is exactly what the first attempt did, caught by the existing stability test. Two details worth keeping: - The reservation is a border-box one. --lexxy-toolbar-height already counts the toolbar's padding and border, and the toolbar is content-box, so applying it as a plain min-block-size added both a second time and left 5px to give back. - Only reserve a toolbar the editor is actually going to prepend: rich text off, or a `toolbar` naming an element by id, means none arrives, and reserving then is the same defect upwards. The client drops a prerendered toolbar it turns out not to want, in the same task as connect, so nothing paints in between. Tests: the fixture keeps its floated toolbar, so the existing stability test now covers "reserves nothing when the toolbar takes no space"; a new one puts the toolbar back in normal flow and requires the same stability; a third pins that the prerendered toolbar is filled rather than duplicated. Ruby-side, the emitted toolbar and each of the three opt-out conditions. --- app/assets/stylesheets/lexxy-editor.css | 17 ++++++ lib/action_text/editor/lexxy_editor.rb | 4 +- lib/lexxy/prerender.rb | 53 +++++++++++++++---- lib/lexxy/rich_text_area_tag.rb | 8 +-- src/elements/editor.js | 23 +++++++- test/browser/fixtures/prerender.html | 1 + .../tests/editor/prerender_adoption.test.js | 35 +++++++----- test/helpers/prerender_test.rb | 28 ++++++++++ 8 files changed, 135 insertions(+), 34 deletions(-) diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 6456ffd27..be5c2944f 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -416,6 +416,23 @@ min-block-size: calc(var(--lexxy-toolbar-height) + var(--lexxy-editor-rows) + 2 * var(--lexxy-editor-padding)); } +/* The reservation above cannot help an editor sized by its content: with + `--lexxy-editor-rows: auto` the calc() is invalid and the declaration is + dropped, and a row count would be the wrong height for a field that has to + match the copy it stands in for anyway. Such an editor prerenders its content + instead (`rich_text_area … prerender: true`), which reserves the body, and + ships an empty toolbar alongside it, which this gives the height the filled + one will have. Only while undefined: from then on the toolbar's own contents + size it. A real rather than a spacer, so a host that takes the + toolbar out of flow gets a reservation of nothing, automatically. */ +:where(lexxy-toolbar:not(:defined)) { + /* --lexxy-toolbar-height already counts the toolbar's padding and border, so + the reservation has to be a border-box one or they are added a second time + and the field gives 5px back on connect. */ + box-sizing: border-box; + min-block-size: var(--lexxy-toolbar-height); +} + /* Placeholder */ :where(.lexxy-editor--empty) { .lexxy-editor__content:not(:has(h1, h2, h3, h4, h5, h6, table, ul, ol, figure, .attachment-gallery))::before { diff --git a/lib/action_text/editor/lexxy_editor.rb b/lib/action_text/editor/lexxy_editor.rb index d9530810d..772a4a388 100644 --- a/lib/action_text/editor/lexxy_editor.rb +++ b/lib/action_text/editor/lexxy_editor.rb @@ -18,8 +18,8 @@ def render_in(view_context, ...) # editor builds a frame after load. Mirrors the same option on the # Rails 8.0/8.1 TagHelper fallback. if options.delete(:prerender) && @block.nil? - value = options[:value] - @block = proc { Lexxy::Prerender.content_tag_for(view_context, value) } + prerendered = options.dup + @block = proc { Lexxy::Prerender.inner_html_for(view_context, prerendered) } end super diff --git a/lib/lexxy/prerender.rb b/lib/lexxy/prerender.rb index 6d56c64f1..cf49038a1 100644 --- a/lib/lexxy/prerender.rb +++ b/lib/lexxy/prerender.rb @@ -1,17 +1,20 @@ module Lexxy - # Builds the static content element a prerendering editor emits inside - # (see LexicalEditorElement#prerenderedContentElement). Shared - # by the Rails 8.2 editor-adapter Tag and the 8.0/8.1 TagHelper fallback. + # Builds the static markup a prerendering editor emits inside + # (see LexicalEditorElement#prerenderedContentElement). Shared by the Rails 8.2 + # editor-adapter Tag and the 8.0/8.1 TagHelper fallback. # - # Scope: this reserves the height of the editor's *content* — the part that - # varies per record, and so cannot be reserved by a CSS rule the way a fixed - # row count can. It does not reserve the default toolbar, which - # connectedCallback prepends in normal flow: an editor using it still gains - # that element's height on mount. The editors this option exists for float the - # toolbar out of flow, since an inline field standing in for published copy has - # nowhere to put one, but the boundary is real and is pinned by - # test/browser/tests/editor/prerender_adoption.test.js. + # Between them, the two elements below reserve the whole height the editor + # lands on: the toolbar's, which is fixed and could equally come from CSS, and + # the content's, which varies per record and is the part no stylesheet can know. module Prerender + def self.inner_html_for(view, options) + parts = [] + parts << toolbar_placeholder_tag(view) if reserves_toolbar?(options) + parts << content_tag_for(view, options[:value]) + + view.safe_join(parts) + end + # The value is the same editable HTML the editor parses from the `value` # attribute, so the element renders at the height the live editor lands on. # Two deliberate differences from the live element: @@ -30,5 +33,33 @@ def self.content_tag_for(view, value) view.content_tag "div", html, class: "lexxy-editor__content" end + + # The toolbar itself, empty: the editor fills it in on connect rather than + # building another one. It has to be a real and not a neutral + # spacer, because whether a toolbar occupies space at all is the host's CSS + # to decide — an inline editor floats it out of flow, and a spacer sized from + # --lexxy-toolbar-height would then reserve height nothing ever fills and + # shift the page *up* on connect. Styled by the host's own toolbar rules, it + # reserves exactly what the real one will take, including nothing. + def self.toolbar_placeholder_tag(view) + view.content_tag "lexxy-toolbar", "", data: { prerendered: "server" }, aria: { hidden: true } + end + + # Only reserve a toolbar the editor is actually going to prepend, or the + # field would give the space back on connect — the same shift, upwards. + # Mirrors the client's own conditions: rich text off means no toolbar, and a + # `toolbar` that names an element by id puts it outside this editor. + def self.reserves_toolbar?(options) + # fetch, not `||`: an explicit `toolbar: false` is exactly the case this + # has to catch, and `||` would collapse it to nil and reserve anyway. + toolbar = options.fetch(:toolbar) { options["toolbar"] } + rich_text = options.fetch(:rich_text) { options.fetch("rich-text") { options["rich_text"] } } + + return false if [ false, "false" ].include?(rich_text) + return false if [ false, "false" ].include?(toolbar) + return false if toolbar.is_a?(String) + + true + end end end diff --git a/lib/lexxy/rich_text_area_tag.rb b/lib/lexxy/rich_text_area_tag.rb index 1cc835bb4..7d2dffcac 100644 --- a/lib/lexxy/rich_text_area_tag.rb +++ b/lib/lexxy/rich_text_area_tag.rb @@ -19,7 +19,7 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename") - inner = (block || !prerender) ? "" : prerendered_content_tag(options[:value]) + inner = (block || !prerender) ? "" : Lexxy::Prerender.inner_html_for(self, options) editor_tag = content_tag("lexxy-editor", inner, options, &block) editor_tag end @@ -27,12 +27,6 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) alias_method :lexxy_rich_text_area_tag, :lexxy_rich_textarea_tag private - # A static, sanitized copy of the value the editor adopts as its content - # element on connect (see Lexxy::Prerender). - def prerendered_content_tag(value) - Lexxy::Prerender.content_tag_for(self, value) - end - # Temporary: we need to *adaptarize* action text def render_custom_attachments_in(value) if value.respond_to?(:body) diff --git a/src/elements/editor.js b/src/elements/editor.js index c8b5859c2..a6203ba28 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -400,6 +400,7 @@ export class LexicalEditorElement extends HTMLElement { this.#registerFileAcceptFilter() this.#attachDebugHooks() this.#attachToolbar() + this.#discardUnusedPrerenderedToolbar() this.#resetBeforeTurboCaches() this.#setInternalFormValue(this.value, { suppressEvent: true }) @@ -764,7 +765,12 @@ export class LexicalEditorElement extends HTMLElement { if (typeof toolbarConfig === "string") { return document.getElementById(toolbarConfig) } else { - return this.querySelector("lexxy-toolbar") ?? this.#createDefaultToolbar() + const existing = this.querySelector("lexxy-toolbar") + // A prerendered toolbar is ours and arrives empty — fill it rather than + // treat it as one the caller supplied and wants left alone. + if (existing?.dataset.prerendered) return this.#fillDefaultToolbar(existing) + + return existing ?? this.#createDefaultToolbar() } } @@ -772,12 +778,25 @@ export class LexicalEditorElement extends HTMLElement { return this.supportsRichText && !!this.config.get("toolbar") } + // A prerendered toolbar this editor turns out not to want — the toolbar is + // configured off, or points at an element elsewhere. Leaving it would reserve + // space nothing fills. Same task as connect, so nothing paints in between. + #discardUnusedPrerenderedToolbar() { + const prerendered = this.querySelector(":scope > lexxy-toolbar[data-prerendered]") + if (prerendered && prerendered !== this.toolbar) prerendered.remove() + } + #createDefaultToolbar() { const toolbar = createElement("lexxy-toolbar") + this.prepend(toolbar) + return this.#fillDefaultToolbar(toolbar) + } + + #fillDefaultToolbar(toolbar) { toolbar.innerHTML = LexicalToolbar.defaultTemplate toolbar.setAttribute("data-attachments", this.supportsAttachments) // Drives toolbar CSS styles + toolbar.removeAttribute("aria-hidden") toolbar.configure(this.config.get("toolbar")) - this.prepend(toolbar) return toolbar } diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html index 141af2862..149acbd17 100644 --- a/test/browser/fixtures/prerender.html +++ b/test/browser/fixtures/prerender.html @@ -122,6 +122,7 @@

With prerendered content

+

Alpha

Bravo

diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js index dacc97521..37461d8ee 100644 --- a/test/browser/tests/editor/prerender_adoption.test.js +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -31,14 +31,11 @@ test.describe("Prerendered content element", () => { expect(withoutAfter - withoutBefore).toBeGreaterThan(60) }) - // Prerendering reserves the height of the editor's *content*, which is the part - // whose height depends on the record. The default toolbar is a separate element - // that connectedCallback prepends in normal flow, so an editor using it still - // gains that much height on mount. Editors this option is for float the toolbar - // out of flow — the fixture does, and an inline field that must match published - // copy could hardly do otherwise — but the boundary should be visible rather - // than implied, so pin it. - test("reserves the content height, not an in-flow default toolbar's", async ({ page }) => { + // The content element reserves the body; the toolbar placeholder reserves the + // element connectedCallback prepends above it. The fixture floats its toolbar, + // which would hide a failure here, so put it back in normal flow — the stock + // arrangement — and require the same stability. + test("reserves the default toolbar's height too, when it takes space in flow", async ({ page }) => { await page.addStyleTag({ content: `.inline-editor lexxy-toolbar { position: static !important; opacity: 1 !important; visibility: visible !important; inset: auto !important; }` }) @@ -48,12 +45,26 @@ test.describe("Prerendered content element", () => { await page.evaluate(() => window.loadLexxy()) await expect(page.locator("lexxy-editor .lexxy-editor__content[data-lexical-editor='true']")).toHaveCount(2) - const after = await topOf(following) + // The real toolbar is in flow and has height, so this asserts the reservation + // matched it rather than that there was nothing to reserve. const toolbar = await page.locator("[data-example='with-prerender'] lexxy-toolbar").boundingBox() + expect(toolbar.height).toBeGreaterThan(0) + expect(Math.abs(await topOf(following) - before)).toBeLessThan(1) + }) + + // The prerendered toolbar is ours, not the caller's: the editor fills it in + // rather than leaving it empty or building a second one beside it. + test("fills the prerendered toolbar instead of adding another", async ({ page }) => { + const toolbars = page.locator("[data-example='with-prerender'] lexxy-toolbar") + await expect(toolbars).toHaveCount(1) + await expect(toolbars.first()).toBeEmpty() + + await page.evaluate(() => window.loadLexxy()) + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) - // The residual shift is the toolbar's height and nothing more — the body, which - // is the part that varies per record, is still fully reserved. - expect(after - before).toBeCloseTo(toolbar.height, 0) + await expect(toolbars).toHaveCount(1) + await expect(toolbars.first()).not.toBeEmpty() + await expect(toolbars.first()).not.toHaveAttribute("aria-hidden") }) test("adopts the server-rendered content element rather than creating a second", async ({ page }) => { diff --git a/test/helpers/prerender_test.rb b/test/helpers/prerender_test.rb index e913120e2..bdeea9389 100644 --- a/test/helpers/prerender_test.rb +++ b/test/helpers/prerender_test.rb @@ -30,6 +30,34 @@ class PrerenderTest < ActionView::TestCase assert_dom "lexxy-editor[prerender]", count: 0 end + test "prerender: true also ships the toolbar the editor will fill" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true %> + ERB + + # Empty, and before the content: the editor prepends its toolbar there, so + # this has to occupy the same place to reserve the same space. A real + # rather than a spacer, so the host's own toolbar CSS decides + # whether it takes any room at all. + assert_dom "lexxy-editor > lexxy-toolbar[data-prerendered='server']:empty", count: 1 + assert_dom "lexxy-editor > *:first-child", count: 1 do |first, *| + assert_equal "lexxy-toolbar", first.name + end + end + + test "no toolbar is prerendered when the editor will not prepend one" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true, toolbar: false %> + <%= rich_textarea_tag :other, "

Hello

", prerender: true, toolbar: "shared-toolbar" %> + <%= rich_textarea_tag :third, "

Hello

", prerender: true, rich_text: false %> + ERB + + # Reserving space for a toolbar that never arrives is the same defect + # upwards: the field would give the height back on connect. + assert_dom "lexxy-toolbar", count: 0 + assert_dom "lexxy-editor > div.lexxy-editor__content", count: 3 + end + test "prerendered content is static until the editor adopts it" do render inline: <<~ERB <%= rich_textarea_tag :body, "

Hello

", prerender: true %>