fix(player/runtime): rebind timelines and bound paused seeks (#3489)

* fix(player): rebind replaced direct timelines

* fix(runtime): rebind timelines after runtime data

* fix(player): defer initial iframe navigation

* fix(player): preserve runtime readiness through load

* fix(runtime): publish rebound timeline before apply

* fix(player): defer preconnect option reloads

* fix(runtime): stop re-seeking paused timelines

* fix(player): restrict runtime-src to trusted origins and reset readiness on reload
This commit is contained in:
Vance Ingalls
2026-08-30 15:46:41 -07:00
committed by GitHub
parent 859ac622c2
commit 61ba800a5d
5 changed files with 431 additions and 9 deletions
@@ -525,6 +525,7 @@ describe("HyperframesPlayer shader transition options", () => {
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
player.setAttribute("shader-capture-scale", "0.5");
player.setAttribute("shader-loading", "player");
document.body.appendChild(player);
player.setAttribute("src", "/api/projects/demo/preview?x=1#stage");
const url = new URL(player.iframeElement.src);
@@ -539,6 +540,7 @@ describe("HyperframesPlayer shader transition options", () => {
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
player.setAttribute("shader-capture-scale", "0.5");
player.setAttribute("shader-loading", "player");
document.body.appendChild(player);
player.setAttribute(
"srcdoc",
'<!doctype html><html><head><script src="composition.js"></script></head><body></body></html>',
@@ -983,6 +985,7 @@ describe("HyperframesPlayer seek() sync path", () => {
stopMedia: () => void;
iframe: HTMLIFrameElement;
_currentTime: number;
duration: number;
_parentMedia: Array<{
el: { pause: ReturnType<typeof vi.fn>; src: string };
start: number;
@@ -1105,6 +1108,35 @@ describe("HyperframesPlayer seek() sync path", () => {
expect(post).not.toHaveBeenCalled();
});
it("rebinds when a same-origin composition replaces its registered timeline", () => {
const first: TimelineStub = {
duration: vi.fn(() => 5),
time: vi.fn(() => 0),
seek: vi.fn(),
play: vi.fn(),
pause: vi.fn(),
};
const second: TimelineStub = {
duration: vi.fn(() => 8),
time: vi.fn(() => 0),
seek: vi.fn(),
play: vi.fn(),
pause: vi.fn(),
};
const timelines = { main: first };
const post = vi.fn();
stubContentWindow({ __timelines: timelines, postMessage: post });
player.seek(1);
timelines.main = second;
player.seek(6);
expect(first.seek).toHaveBeenCalledTimes(1);
expect(second.seek).toHaveBeenCalledWith(6, false);
expect(player.duration).toBe(8);
expect(post).not.toHaveBeenCalled();
});
it("plays and pauses same-origin __timelines when no runtime bridge exists", () => {
const timeline: TimelineStub = {
duration: vi.fn(() => 5),
@@ -1462,6 +1494,51 @@ describe("HyperframesPlayer srcdoc attribute", () => {
| undefined;
expect(ctor).toBeDefined();
expect(ctor!.observedAttributes).toContain("srcdoc");
expect(ctor!.observedAttributes).toContain("runtime-src");
});
it("uses a configured runtime source for loopback srcdoc", () => {
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("srcdoc", "<!doctype html><html><head></head><body></body></html>");
player.setAttribute("runtime-src", "http://127.0.0.1:8900/hyperframe.runtime.iife.js");
expect(player.iframe.hasAttribute("srcdoc")).toBe(false);
document.body.appendChild(player);
expect(player.iframe.getAttribute("srcdoc")).toContain(
'<script src="http://127.0.0.1:8900/hyperframe.runtime.iife.js"></script>',
);
player.remove();
});
it("falls back to the pinned runtime for a foreign-origin runtime source", () => {
// A srcdoc frame inherits the embedder's origin under the default `allow-same-origin`,
// so an attacker-controlled host would be script execution in the embedding page.
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("runtime-src", "https://evil.example.com/hyperframe.runtime.iife.js");
player.setAttribute("srcdoc", "<!doctype html><html><head></head><body></body></html>");
document.body.appendChild(player);
const srcdoc = player.iframe.getAttribute("srcdoc") ?? "";
expect(srcdoc).not.toContain("evil.example.com");
expect(srcdoc).toContain("hyperframe.runtime.iife.js");
player.remove();
});
it("falls back to the pinned runtime for an unsafe runtime source", () => {
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("runtime-src", 'javascript:alert("no")');
player.setAttribute("srcdoc", "<!doctype html><html><head></head><body></body></html>");
document.body.appendChild(player);
const srcdoc = player.iframe.getAttribute("srcdoc") ?? "";
expect(srcdoc).not.toContain("javascript:");
expect(srcdoc).toContain("hyperframe.runtime.iife.js");
player.remove();
});
it("forwards an initial srcdoc attribute to the iframe on connect", () => {
@@ -1482,6 +1559,36 @@ describe("HyperframesPlayer srcdoc attribute", () => {
player.remove();
});
it("does not navigate initial srcdoc before the runtime listener is connected", () => {
// React assigns custom-element attributes before inserting the element. If the observed
// attribute callback navigates the child iframe immediately, a fast srcdoc runtime can post
// its one-shot `ready` message before connectedCallback subscribes to `window.message`.
// Retained runtime data then waits forever and a caption style appears stuck on its bootstrap
// frame. The connect path owns the first navigation; attributeChangedCallback owns only
// subsequent swaps.
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("srcdoc", "<!doctype html><html><body>deferred</body></html>");
expect(player.iframe.hasAttribute("srcdoc")).toBe(false);
document.body.appendChild(player);
expect(player.iframe.getAttribute("srcdoc")).toContain("<body>deferred</body>");
player.remove();
});
it("does not navigate initial src before the runtime listener is connected", () => {
const player = document.createElement("hyperframes-player") as PlayerInternal;
player.setAttribute("src", "/api/projects/deferred/preview");
expect(player.iframe.hasAttribute("src")).toBe(false);
document.body.appendChild(player);
expect(player.iframe.getAttribute("src")).toBe("/api/projects/deferred/preview");
player.remove();
});
it("forwards a srcdoc attribute set after connect to the iframe", () => {
// The composition-switching flow: same player element, new HTML.
// Without `attributeChangedCallback` wiring this would no-op.
@@ -1899,6 +2006,8 @@ describe("HyperframesPlayer runtime ready handshake", () => {
paused: boolean;
iframe: HTMLIFrameElement;
_onMessage: (event: MessageEvent) => void;
_onIframeLoad: () => void;
_runtimeBridgeReady: boolean;
}
let player: PlayerInternal;
@@ -2040,6 +2149,26 @@ describe("HyperframesPlayer runtime ready handshake", () => {
expect(findControlCalls("set-muted")).toHaveLength(2);
});
it("does not erase a DOMContentLoaded runtime handshake when iframe load follows it", () => {
player._onMessage(readyMessage());
expect(player._runtimeBridgeReady).toBe(true);
player._onIframeLoad();
expect(player._runtimeBridgeReady).toBe(true);
});
it("drops the runtime handshake when a shader-option change navigates the frame", () => {
// The navigating sandbox path already clears readiness. This path navigates too, so a
// delivery issued afterwards must not be posted into the document being replaced.
player._onMessage(readyMessage());
expect(player._runtimeBridgeReady).toBe(true);
player.setAttribute("shader-capture-scale", "0.5");
expect(player._runtimeBridgeReady).toBe(false);
});
it("ignores ready events from a different window", () => {
postSpy.mockClear();
const otherSource = {} as Window;
+30 -2
View File
@@ -8,6 +8,7 @@ import { handleRuntimeMessage } from "./runtime-message-handler.js";
import {
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
RUNTIME_SRC_ATTR,
type ShaderLoadingMode,
getShaderCaptureScaleFromElement,
getShaderModeFromElement,
@@ -78,6 +79,7 @@ class HyperframesPlayer extends HTMLElement {
"playback-rate",
"audio-src",
SANDBOX_ORIGIN_ATTR,
RUNTIME_SRC_ATTR,
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
];
@@ -218,6 +220,11 @@ class HyperframesPlayer extends HTMLElement {
attributeChangedCallback(name: string, oldVal: string | null, val: string | null) {
switch (name) {
case "src":
// Custom-element attributes are normally assigned before insertion (React does this for
// every render). Navigating the inner iframe here would let its one-shot runtime `ready`
// message fire before connectedCallback installs the parent message listener. Initial
// attributes are applied below by connectedCallback; only live changes navigate here.
if (!this.isConnected) break;
if (val) {
this._ready = false;
this._runtimeBridgeReady = false;
@@ -228,6 +235,7 @@ class HyperframesPlayer extends HTMLElement {
}
break;
case "srcdoc":
if (!this.isConnected) break;
this._ready = false;
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(
@@ -291,6 +299,8 @@ class HyperframesPlayer extends HTMLElement {
break;
case SHADER_CAPTURE_SCALE_ATTR:
case SHADER_LOADING_ATTR:
case RUNTIME_SRC_ATTR:
if (!this.isConnected) break;
this._reloadShaderOptions();
break;
}
@@ -773,6 +783,13 @@ class HyperframesPlayer extends HTMLElement {
}
private _reloadShaderOptions(): void {
// This navigates the frame, so readiness has to fall with it. Leaving
// `_runtimeBridgeReady` true lets a delivery post into a document that is being
// replaced, where it can only end in a delivery timeout rather than the immediate,
// explanatory rejection the caller gets from every other navigating path.
this._ready = false;
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries("Shader options changed before runtime data was applied");
if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset();
if (this.hasAttribute("srcdoc")) {
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || "");
@@ -798,10 +815,18 @@ class HyperframesPlayer extends HTMLElement {
}
private _withDirectTimeline(fn: (tl: DirectTimelineAdapter) => void): boolean {
const tl = this._directTimelineAdapter || this.probe.resolveDirectTimelineAdapter();
const resolved = this.probe.resolveDirectTimelineAdapter();
const tl = resolved || this._directTimelineAdapter;
if (!tl) return false;
try {
fn(tl);
if (resolved && resolved !== this._directTimelineAdapter) {
const duration = resolved.duration();
if (Number.isFinite(duration) && duration > 0) {
this._duration = duration;
this.controlsApi?.updateTime(this._currentTime, duration);
}
}
this._directTimelineAdapter = tl;
return true;
} catch {
@@ -973,7 +998,10 @@ class HyperframesPlayer extends HTMLElement {
private _onIframeLoad() {
this._ready = false;
this._runtimeBridgeReady = false;
// The runtime installs its bridge at DOMContentLoaded, posts `ready`, and only then does the
// iframe's load event fire. Do not erase that authoritative handshake here: doing so strands
// retained data set after load until a second `ready` that never comes. Source setters and
// sandbox-policy reloads already clear bridge readiness before starting a navigation.
this._directTimelineAdapter = null;
this._directTimelineClock.stop();
this._stopParentTickClock();
+21 -1
View File
@@ -9,6 +9,7 @@ import { RUNTIME_CDN_URL } from "./runtime-url.js";
export const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale";
export const SHADER_LOADING_ATTR = "shader-loading";
export const RUNTIME_SRC_ATTR = "runtime-src";
const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
const SHADER_LOADING_PARAM = "__hf_shader_loading";
@@ -153,6 +154,25 @@ export function prepareSrcdocForElement(el: Element, srcdoc: string): string {
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
getShaderModeFromElement(el),
),
RUNTIME_CDN_URL,
runtimeSrcFromElement(el),
);
}
function runtimeSrcFromElement(el: Element): string {
const configured = el.getAttribute(RUNTIME_SRC_ATTR)?.trim();
if (!configured) return RUNTIME_CDN_URL;
try {
const url = new URL(configured, document.baseURI);
// A srcdoc frame runs with `allow-same-origin` by default, so it inherits the embedder's
// origin. An unrestricted host here would therefore be arbitrary script execution in the
// embedding page, reachable through a prop bag since React spreads unknown props onto
// custom elements. Loopback and same-origin cover the local rig this exists for; a foreign
// origin has to be a deliberate, named opt-in rather than a scheme check falling through.
const okScheme = url.protocol === "http:" || url.protocol === "https:";
const loopback =
url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
return okScheme && (loopback || url.origin === location.origin) ? url.href : RUNTIME_CDN_URL;
} catch {
return RUNTIME_CDN_URL;
}
}