Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-iframes-settle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Fix intermittent clipping in surface iframes when async content settles after the initial resize pass.
63 changes: 55 additions & 8 deletions server/surfacePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,32 +200,79 @@ document.addEventListener('keydown', function (e) {
//
// A plain h !== __lastH guard can't stop this: in a 2-cycle every value differs
// from the one immediately before it. So we remember the previous height too and
// drop a return to it *if it recurs faster than a human could* (< 250ms) — that's
// the runaway. A genuine change (a <details> toggle, a textarea drag) recurs on a
// human timescale and still passes through.
// defer a return to it *if it recurs faster than a human could* (< 250ms) — that's
// the runaway. The deferred pass keeps one trailing re-measure and reports the
// taller height in the pair, so an ordinary font/image reflow can't leave the
// frame permanently clipped.
var __lastH = 0;
var __prevH = 0;
var __lastT = 0;
var __seenH = 0;
var __trailTimer = 0;
var __trailH = 0;
var __FLIP_MS = 250;
var __TRAIL_MS = 350;
function __now() {
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
}
function __report() {
var h = document.body
function __measureHeight() {
return document.body
? document.body.scrollHeight
: document.documentElement.scrollHeight;
if (h <= 0 || h === __lastH) return; // no content yet, or unchanged
var t = __now();
if (h === __prevH && t - __lastT < 250) return; // rapid A<->B flip: stop the loop
}
function __postHeight(h, t) {
__prevH = __lastH;
__lastH = h;
__lastT = t;
parent.postMessage({ __sideshow: true, type: 'resize', height: h }, '*');
}
function __clearTrailing() {
if (__trailTimer && typeof clearTimeout !== 'undefined') clearTimeout(__trailTimer);
__trailTimer = 0;
__trailH = 0;
}
function __flushTrailing() {
__trailTimer = 0;
var measured = __measureHeight();
if (measured > 0) __seenH = measured;
var target = Math.max(__trailH || 0, measured || 0);
__trailH = 0;
if (target <= 0 || target === __lastH) return;
__postHeight(target, __now());
}
function __scheduleTrailing(h, reset) {
__trailH = Math.max(__trailH || 0, h || 0, __lastH || 0);
if (__trailTimer) {
if (!reset) return;
if (typeof clearTimeout !== 'undefined') clearTimeout(__trailTimer);
}
__trailTimer = setTimeout(__flushTrailing, __TRAIL_MS);
}
function __report() {
var h = __measureHeight();
if (h <= 0) return; // no content yet
var changed = h !== __seenH;
__seenH = h;
if (h === __lastH) return; // unchanged
var t = __now();
if (h === __prevH && (__trailTimer || t - __lastT < __FLIP_MS)) {
__scheduleTrailing(h, true); // rapid A<->B flip: defer one settled report
return;
}
__clearTrailing();
__postHeight(h, t);
}
if (document.readyState === 'complete') __report();
else window.addEventListener('load', function () { requestAnimationFrame(__report); });
setTimeout(__report, 60);
setTimeout(__report, 350);
setTimeout(__report, 1500);
setTimeout(__report, 3000);
setTimeout(__report, 6000);
setTimeout(__report, 10000);
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
document.fonts.ready.then(function () { __report(); });
}
if (window.ResizeObserver) {
window.__ssRO = new ResizeObserver(__report);
window.__ssRO.observe(document.documentElement);
Expand Down
114 changes: 101 additions & 13 deletions test/surfacePage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,42 @@ function loadResizeBridge() {

const posted: number[] = [];
const clock = { scrollHeight: 0, now: 0 };
type Timer = { id: number; due: number; fn: () => void; cancelled?: boolean };
const timers: Timer[] = [];
let nextTimer = 1;
const noop = () => 0;
const runUntil = (ms: number) => {
while (true) {
let next: Timer | undefined;
for (const timer of timers) {
if (timer.cancelled || timer.due > ms) continue;
if (!next || timer.due < next.due || (timer.due === next.due && timer.id < next.id)) {
next = timer;
}
}
if (!next) break;
next.cancelled = true;
clock.now = next.due;
next.fn();
}
clock.now = ms;
};
const ctx: Record<string, unknown> = {
parent: {
postMessage: (msg: { type?: string; height?: number }) => {
if (msg && msg.type === "resize") posted.push(msg.height!);
},
},
performance: { now: () => clock.now },
setTimeout: noop, // ignore the deferred __report() warm-up calls
setTimeout: (fn: () => void, delay = 0) => {
const timer = { id: nextTimer++, due: clock.now + delay, fn };
timers.push(timer);
return timer.id;
},
clearTimeout: (id: number) => {
const timer = timers.find((candidate) => candidate.id === id);
if (timer) timer.cancelled = true;
},
requestAnimationFrame: noop,
document: {
readyState: "loading", // take the load-listener branch, not an eval-time __report()
Expand All @@ -338,10 +365,15 @@ function loadResizeBridge() {
return {
posted,
at(height: number, ms: number) {
runUntil(ms);
clock.scrollHeight = height;
clock.now = ms;
report();
},
setHeight(height: number, ms: number) {
runUntil(ms);
clock.scrollHeight = height;
},
runUntil,
};
}

Expand All @@ -350,30 +382,86 @@ function loadResizeBridge() {
// iframe to the reported height" feed back into the content's height, so reports
// alternate A, B, A, B... forever. A plain `h !== lastH` guard can't stop it
// (each value differs from the one before), and on a heavy surface the per-frame
// relayout pegs a CPU core. The bridge must break the rapid 2-cycle while still
// honoring a genuine change that happens to return to a prior height.
test("resize bridge breaks a rapid 2-cycle but honors slow genuine changes", () => {
// relayout pegs a CPU core. The bridge must break the rapid 2-cycle, but a
// one-off A→B→A font/image reflow must still finish at the final A.
test("resize bridge breaks a rapid 2-cycle and rests on the taller height", () => {
const b = loadResizeBridge();

b.at(100, 0); // first measurement
b.at(200, 16); // genuine growth
b.at(100, 1600);
b.at(200, 1616);
assert.deepEqual(b.posted, [100, 200]);

// The runaway: rapid flips back and forth, one per frame.
b.at(100, 32);
b.at(200, 48);
b.at(100, 64);
// Keep flipping for long enough that a simple "within 250ms" guard would start
// posting again. The active trailing debounce should keep suppressing until the
// pair goes quiet, then leave the already-posted taller height in place.
for (let i = 0; i < 40; i++) {
b.at(i % 2 === 0 ? 100 : 200, 1632 + i * 16);
}
b.runUntil(3000);

assert.deepEqual(
b.posted,
[100, 200],
"a rapid A<->B oscillation must stop after the first cycle",
);

// A real change that lands on a previous height, seconds later, still resizes.
b.at(150, 5000);
assert.deepEqual(
b.posted,
[100, 200, 150],
"a later third height is a genuine resize, not stale oscillation state",
);
});

test("resize bridge defers a suppressed A→B→A reflow instead of losing the final height", () => {
const b = loadResizeBridge();

b.at(320, 1600);
b.at(180, 1616);
b.at(320, 1632);
assert.deepEqual(b.posted, [320, 180], "the rapid return is suppressed immediately");

b.runUntil(2100);
assert.deepEqual(
b.posted,
[320, 180, 320],
"the trailing re-measure reports the final taller height",
);
});

test("resize bridge allows a slow genuine return to an oscillation endpoint", () => {
const b = loadResizeBridge();

b.at(100, 1600);
b.at(200, 1616);
b.at(100, 1632);
b.runUntil(2100);
assert.deepEqual(b.posted, [100, 200], "the rapid return is suppressed");

b.at(100, 5000);
assert.deepEqual(
b.posted,
[100, 200, 100],
"a slow, genuine return to a prior height must still resize the frame",
"after the debounce window, the same lower endpoint can be a genuine resize",
);
});

test("resize bridge late timers catch height growth after the 1500ms warm-up", () => {
const b = loadResizeBridge();

b.at(100, 0);
b.runUntil(1500);
b.setHeight(260, 2200); // no ResizeObserver fire: simulate a missed late settle
assert.deepEqual(b.posted, [100]);

b.runUntil(3000);
assert.deepEqual(b.posted, [100, 260], "the 3000ms safety timer reports growth");

b.setHeight(420, 4200); // after the first late safety net has already fired
b.runUntil(6000);
assert.deepEqual(b.posted, [100, 260, 420], "the 6000ms safety timer reports growth");

b.setHeight(640, 7500); // after both earlier late safety nets have fired
b.runUntil(10000);
assert.deepEqual(b.posted, [100, 260, 420, 640], "the 10000ms safety timer reports growth");
});
Loading