fix(mail): the message frame keeps its height on a full page load

On a hard load or deep link the reading frame collapsed to its 80px minimum
with the whole message scrolling inside it. The frame's srcdoc is parsed and
reports its height synchronously, but the app's module script is deferred, so
the parent controller connected a beat later and that first report landed with
no listener — and the frame dedupes its reports, so the true height was never
re-sent. The parent now asks for a measurement once it is listening (a
'measure' message), and the frame answers unconditionally. Reported and traced
from a live instance; pinned by a reload spec proven to fail without the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Fable 5 committed 2026-08-12 15:29:00 +02:00
1 parent 52daa63ea3
commit d1dac23b64
3 files changed
+83 -7

No files matched your search

@@ -26,10 +26,18 @@ export default class extends Controller {
// The frame may already have loaded before this controller connected —
// srcdoc content is parsed synchronously and Stimulus connects on the
// next microtask, so `load` can be gone by the time we could listen.
// Posting the theme on both paths is idempotent.
this.frameTarget.addEventListener("load", () => this.postTheme());
this.postTheme();
// next microtask, so `load` can be gone by the time we could listen,
// and with it the frame's first height report. Now that we ARE
// listening, ask for a fresh measurement (the frame dedupes its own
// reports, so it will not re-send the lost one unprompted). Both the
// theme and the request are idempotent, and run on connect and on load
// in case either happened first.
this.sync = () => {
this.postTheme();
this.post({ plmail: "measure" });
};
this.frameTarget.addEventListener("load", this.sync);
this.sync();
}
disconnect() {
+13 -3
View File
@@ -111,13 +111,13 @@ Variables:
// it cannot read this one, because reading it would require running.
var send = function (message) { parent.postMessage(message, "*"); };
var lastHeight = 0;
var reportHeight = function () {
var lastHeight = -1;
var reportHeight = function (force) {
var height = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
);
if (height !== lastHeight) { lastHeight = height; send({ plmail: "height", height: height }); }
if (force || height !== lastHeight) { lastHeight = height; send({ plmail: "height", height: height }); }
};
window.addEventListener("load", reportHeight);
@@ -142,6 +142,16 @@ Variables:
// The parent is the only window that can reach us.
if (event.source !== parent || !event.data || typeof event.data !== "object") { return; }
// A message from the parent is proof its listener is live NOW. This
// frame's first height report is parsed and sent synchronously, before
// the parent's Stimulus controller connects — on a full page load the
// app's module script is deferred — so that first report lands with no
// listener and is dropped, and the dedupe above then never re-sends the
// unchanged height. The frame stays at its 80px minimum with the whole
// message scrolling inside it. The parent asks for a measurement once
// it is listening; answer it unconditionally.
if (event.data.plmail === "measure") { reportHeight(true); }
if (event.data.plmail === "show-images") {
document.querySelectorAll("img[data-plmail-src]").forEach(function (img) {
// The stored value is already a proxy URL on our own origin —
+58
View File
@@ -0,0 +1,58 @@
import { test, expect } from "./support/test";
import type { Page } from "@playwright/test";
import { mailRow, seed } from "./support/config";
/**
* The sandboxed message frame must be as tall as its content on a FULL page
* load, not only after a soft (Turbo) navigation.
*
* The frame's srcdoc is parsed synchronously and posts its measured height at
* once; on a full load the app's module script is deferred, so the parent's
* controller connects a beat later and that first report lands with no
* listener. The frame dedupes its own reports, so the height was never re-sent
* and the frame stayed at its 80px minimum with the whole message scrolling
* inside it — unreadable on any F5 or deep link. The fix has the parent ask
* for a measurement once it is listening, and the frame answer unconditionally.
*
* The check is a reload, which is exactly the reported repro: open works,
* reload collapses. `loading="lazy"` means only the visible expanded frame is
* in the race, which is why the long-body message (opened expanded) is the one
* that shows it.
*/
const SUBJECT = "E2E Long Body";
const MIN_HEIGHT = 80;
test.beforeEach(() => {
seed("seed-mail", "seed-rendering");
});
test.describe("sandboxed message frame height", () => {
const frame = (page: Page) => page.locator("[data-mail--message-frame-target='frame']").first();
async function frameHeight(page: Page): Promise<number> {
await expect(frame(page)).toBeVisible();
// The parent sizes the frame by writing an inline pixel height; poll it
// until it exceeds the minimum, so a slow measurement is a wait, not a
// failure. Returns the settled height.
await expect
.poll(async () => (await frame(page).boundingBox())?.height ?? 0, { timeout: 5000 })
.toBeGreaterThan(MIN_HEIGHT + 1);
return (await frame(page).boundingBox())?.height ?? 0;
}
test("stays as tall as its content across a full reload", async ({ page }) => {
await page.goto("/mail/inbox");
await mailRow(page, SUBJECT).click();
const openedHeight = await frameHeight(page);
expect(openedHeight).toBeGreaterThan(400); // 60 paragraphs are not 80px
// The bug: a full reload of the thread URL. Before the fix the frame
// collapsed to exactly the 80px minimum here.
await page.reload();
const reloadedHeight = await frameHeight(page);
expect(reloadedHeight).toBeGreaterThan(400);
});
});