From e5ec4cf53243a19a8a34f029c3139abaf07315c3 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 1 Jul 2026 14:36:48 -0700 Subject: [PATCH] fix(player): rescale on cross-origin timeline ready, guard warn spam (#1840) onRuntimeTimelineReady (the cross-origin ready signal for signed CDN composition URLs) never called _rescale(), leaving the iframe unscaled and untransformed if the runtime's stage-size postMessage was ever skipped. Also adds a one-shot diagnostic warning when a rescale keeps no-oping after ready, latched so a legitimately hidden/zero-size player doesn't spam the console. --- .../player/src/hyperframes-player.test.ts | 31 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 28 ++++++++++++++++- packages/player/src/iframe-dom.ts | 7 +++-- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 94929851b..76b9358fa 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1989,6 +1989,37 @@ describe("HyperframesPlayer runtime ready handshake", () => { expect(player.paused).toBe(false); expect(findControlCalls("play")).toHaveLength(1); }); + + it("rescales the iframe on cross-origin timeline readiness even without a stage-size message", () => { + // Regression: the runtime's postTimeline() only sends `stage-size` when it + // can resolve the root's data-width/data-height at that instant — a race + // that can lose on first paint. onRuntimeTimelineReady must not depend on + // stage-size having arrived, or the iframe is left unscaled/untranslated + // (rendered pinned to the top-left instead of centered and fit). + Object.defineProperty(player, "offsetWidth", { value: 400, configurable: true }); + Object.defineProperty(player, "offsetHeight", { value: 300, configurable: true }); + + expect(player.iframe.style.transform).toBe(""); + + player._onMessage(timelineMessage(120)); + + expect(player.iframe.style.transform).not.toBe(""); + expect(player.iframe.style.transform).toContain("translate(-50%, -50%)"); + }); + + it("warns at most once per instance when rescale keeps no-oping after ready", () => { + // A player that stays zero-size after ready (hidden tab, collapsed + // carousel card) keeps getting rescale attempts from every subsequent + // width/height attribute change and ResizeObserver tick. The diagnostic + // warning must not spam the console once per instance. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + player._onMessage(timelineMessage(120)); // first no-op after ready + player.setAttribute("width", "800"); // still zero-size — would no-op again + player.setAttribute("height", "450"); // ditto + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); }); describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => { diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 20f6fd6f5..d000c4b84 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -86,6 +86,7 @@ class HyperframesPlayer extends HTMLElement { private _volume = 1; private _compositionWidth = 1920; private _compositionHeight = 1080; + private _rescaleWarned = false; private _directTimelineAdapter: DirectTimelineAdapter | null = null; private _directTimelineClock: DirectTimelineClock; private _parentTickRaf: number | null = null; @@ -667,6 +668,10 @@ class HyperframesPlayer extends HTMLElement { this._ready = true; this.controlsApi?.updateTime(this._currentTime, duration); this.dispatchEvent(new CustomEvent("ready", { detail: { duration } })); + // stage-size may not have arrived yet (race in the runtime's postTimeline + // resolving the root's data-width/data-height on first paint) — rescale + // here too so cross-origin compositions never stay unscaled/untransformed. + this._rescale(); const doc = this._getSameOriginIframeDocument(); if (doc) this._media.setupFromIframe(doc); @@ -698,7 +703,28 @@ class HyperframesPlayer extends HTMLElement { } private _rescale() { - scaleIframeToFit(this, this.iframe, this._compositionWidth, this._compositionHeight); + const applied = scaleIframeToFit( + this, + this.iframe, + this._compositionWidth, + this._compositionHeight, + ); + // A no-op before "ready" is expected (element not painted yet). A no-op + // once ready means the composition is stuck unscaled/untransformed — + // pinned to the iframe's default top-left position — with no evidence of + // why in the field. Surface it once (not on every ResizeObserver tick — + // a legitimately hidden/zero-sized player, e.g. a collapsed tab or + // off-screen carousel card, would otherwise spam the console forever). + if (!applied && this._ready && !this._rescaleWarned) { + this._rescaleWarned = true; + console.warn("[hyperframes-player] rescale no-op after ready — zero-size player element", { + src: this.getAttribute("src"), + offsetWidth: this.offsetWidth, + offsetHeight: this.offsetHeight, + compositionWidth: this._compositionWidth, + compositionHeight: this._compositionHeight, + }); + } } private _onIframeLoad() { diff --git a/packages/player/src/iframe-dom.ts b/packages/player/src/iframe-dom.ts index 5340c3972..537d86186 100644 --- a/packages/player/src/iframe-dom.ts +++ b/packages/player/src/iframe-dom.ts @@ -57,18 +57,21 @@ export function createCompositionIframe(): { /** * Scale the iframe so the composition fits inside the player element while * preserving aspect ratio. No-ops when the player has no painted size yet. + * Returns whether the transform was actually applied, so callers can tell a + * real no-op (still 0×0) apart from a successful rescale. */ export function scaleIframeToFit( playerElement: HTMLElement, iframe: HTMLIFrameElement, compositionWidth: number, compositionHeight: number, -): void { +): boolean { const w = playerElement.offsetWidth; const h = playerElement.offsetHeight; - if (w === 0 || h === 0) return; + if (w === 0 || h === 0) return false; const scale = Math.min(w / compositionWidth, h / compositionHeight); iframe.style.width = `${compositionWidth}px`; iframe.style.height = `${compositionHeight}px`; iframe.style.transform = `translate(-50%, -50%) scale(${scale})`; + return true; }