Rebase viewport-relative positioning when grafting iframe subtrees

A grafted frame's position:fixed children re-resolved against the MAIN
page viewport and escaped the frame box's overflow clip (fixed ignores
overflow ancestors) - a full-viewport backdrop inside an embedded iframe
could paint across the entire page. Grafted subtrees now demote
fixed->absolute and sticky->relative, and a static graft host is promoted
to relative so demoted absolutes anchor and clip inside the frame box.

472 tests pass (4 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 11:23:47 -07:00
co-authored by Claude Fable 5
parent d56fbbe45e
commit 22d0bf19e9
2 changed files with 69 additions and 1 deletions
+21 -1
View File
@@ -171,6 +171,21 @@ export function graftFrameIntoSnapshot(
x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
};
// Rebase viewport-relative positioning into the frame's box. Inside a real iframe,
// `position: fixed` pins to the FRAME's viewport (its content box) and `sticky` scrolls
// against the FRAME's scroll container. Grafted in as plain <div> children, those
// containing blocks no longer exist, so `fixed` would resolve against the MAIN page
// viewport — escaping the iframe's clip box (fixed ignores `overflow:hidden` ancestors)
// and painting frame chrome (e.g. a `fixed inset-0 z-[-1]` dark backdrop) across the
// whole clone. Demote to box-relative positioning: `fixed`→`absolute` (contained and
// clipped by the host div, whose bboxes are already rebased to page coords) and
// `sticky`→`relative` (stays in flow instead of sticking to the page scroll). The host
// is made a containing block below so these absolutes anchor to the frame box.
if (n.computed) {
const pos = n.computed.position;
if (pos === "fixed") n.computed = { ...n.computed, position: "absolute" };
else if (pos === "sticky") n.computed = { ...n.computed, position: "relative" };
}
const a = n.attrs ?? {};
delete a["data-cid-cap"]; // capture-ids belong to the main document only
if (a.id) a.id = prefix + a.id;
@@ -190,8 +205,13 @@ export function graftFrameIntoSnapshot(
host.children = [wrapper];
// A real frame viewport clips its document; the replacement <div> must too, at every
// captured viewport (each per-viewport snapshot is grafted independently).
// captured viewport (each per-viewport snapshot is grafted independently). It must also
// be a containing block: `visit` demoted the frame's `position: fixed` chrome (which was
// pinned to the frame viewport) to `absolute`, and those absolutes must anchor to — and
// be clipped by — this box, not some positioned ancestor further up the page. A `static`
// host wouldn't establish that containing block, so promote it to `relative`.
host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
if ((host.computed.position || "static") === "static") host.computed.position = "relative";
// An iframe is a REPLACED element: `display:inline` (the default) still honors its
// width/height. The <div> that replaces it at generation is not — inline would collapse
// the box — so translate to the behavior-equivalent inline-block.
+48
View File
@@ -163,6 +163,54 @@ describe("graftFrameIntoSnapshot", () => {
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
});
// A frame's `position: fixed` chrome pins to the FRAME viewport, not the page's. Grafted
// as plain DOM, `fixed` would resolve against the main page viewport and escape the
// iframe's clip box — the observed regression: a gallery embed's
// `fixed inset-0 z-[-1]` dark backdrop painted the whole clone dark. It must be demoted
// to `absolute` so the host box contains and clips it.
it("demotes frame `position: fixed` to `absolute` so it stays inside the iframe box", () => {
const backdrop = raw("div", { class: "backdrop" }, [], {
computed: { display: "block", position: "fixed" },
bbox: { x: 0, y: 0, width: 320, height: 160 },
});
const frame = pageSnap(raw("body", {}, [backdrop]), "https://frame.test/embed");
const host = mkHost();
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
const grafted = wrapper.children[0] as RawNode;
assert.equal(grafted.computed.position, "absolute");
});
it("demotes frame `position: sticky` to `relative` (no sticking to the page scroll)", () => {
const bar = raw("div", { class: "sticky-bar" }, [], {
computed: { display: "block", position: "sticky" },
bbox: { x: 0, y: 0, width: 320, height: 40 },
});
const frame = pageSnap(raw("body", {}, [bar]), "https://frame.test/embed");
const host = mkHost();
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
const grafted = wrapper.children[0] as RawNode;
assert.equal(grafted.computed.position, "relative");
});
it("promotes a `static` host to `relative` so demoted absolutes anchor to the frame box", () => {
const host = mkHost(); // iframe host computed.position defaults to "static"
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
assert.equal(findFrameNode(host.root, 0)!.computed.position, "relative");
});
it("leaves an already-positioned host's position untouched", () => {
const host = pageSnap(raw("body", {}, [
raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], {
computed: { display: "block", position: "absolute" },
bbox: { x: 40, y: 30, width: 320, height: 160 },
}),
]));
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
assert.equal(findFrameNode(host.root, 0)!.computed.position, "absolute");
});
});
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----