Skip to content

Commit 1ae2fbd

Browse files
Merge pull request #1310 from heygen-com/fix/runtime-ready-handshake
fix(runtime,player): replay bridge state on iframe ready to repair race
2 parents acd8e11 + 5a0966d commit 1ae2fbd

7 files changed

Lines changed: 185 additions & 0 deletions

File tree

packages/core/src/runtime/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ postMessage:
3232
- runtime -> parent events:
3333
- `source: "hf-preview"`
3434
- `type: "state"` and `type: "timeline"`
35+
- `type: "ready"` — emitted once when `installRuntimeControlBridge` registers
36+
the control-message listener. The parent uses it to replay current playback
37+
state (`set-muted`, `set-volume`, `set-playback-rate`) so any control
38+
message sent before the listener was installed isn't lost. Emitted again on
39+
every iframe reload because the new runtime instance starts with no state.
3540

3641
Determinism baseline:
3742

packages/core/src/runtime/bridge.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,14 @@ describe("installRuntimeControlBridge", () => {
168168
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 })),
169169
).not.toThrow();
170170
});
171+
172+
it("posts a ready message to window.parent on install", () => {
173+
// The bridge announces itself so the parent can replay any control
174+
// messages it posted before the iframe runtime's listener was installed.
175+
const postSpy = vi.spyOn(window.parent, "postMessage");
176+
const deps = createMockDeps();
177+
installRuntimeControlBridge(deps);
178+
expect(postSpy).toHaveBeenCalledWith({ source: "hf-preview", type: "ready" }, "*");
179+
postSpy.mockRestore();
180+
});
171181
});

packages/core/src/runtime/bridge.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
7979
}
8080
};
8181
window.addEventListener("message", handler);
82+
// Announce that the bridge listener is installed so the parent can replay
83+
// any control messages it posted before the iframe runtime was ready
84+
// (avoids losing the initial `set-muted` / `set-volume` / `set-playback-rate`
85+
// when the parent finishes loading before the iframe does — a deterministic
86+
// race on warm-cache reloads and inside the Claude desktop Electron client).
87+
postRuntimeMessage({ source: "hf-preview", type: "ready" });
8288
return handler;
8389
}
8490

packages/core/src/runtime/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,20 @@ export type RuntimeMediaAutoplayBlockedMessage = {
153153
type: "media-autoplay-blocked";
154154
};
155155

156+
/**
157+
* Posted by the runtime when `installRuntimeControlBridge` finishes registering
158+
* its message listener — signals that subsequent control messages
159+
* (`set-muted`, `set-volume`, `set-playback-rate`, etc.) will now be received
160+
* and processed. The parent (web component / host app) listens for this and
161+
* replays current playback state to repair any race where bridge messages
162+
* were posted before the listener was installed. Emitted again on every iframe
163+
* reload because the new runtime instance starts with no state.
164+
*/
165+
export type RuntimeReadyMessage = {
166+
source: "hf-preview";
167+
type: "ready";
168+
};
169+
156170
/**
157171
* Analytics events emitted by the runtime.
158172
*
@@ -199,6 +213,7 @@ export type RuntimeOutboundMessage =
199213
| RuntimePickerCancelledMessage
200214
| RuntimeStageSizeMessage
201215
| RuntimeMediaAutoplayBlockedMessage
216+
| RuntimeReadyMessage
202217
| RuntimeAnalyticsMessage
203218
| RuntimePerformanceMessage;
204219

packages/player/src/hyperframes-player.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,130 @@ describe("HyperframesPlayer audio lock", () => {
15911591
});
15921592
});
15931593

1594+
describe("HyperframesPlayer runtime ready handshake", () => {
1595+
// When the iframe runtime announces `{type: "ready"}` the player replays
1596+
// current bridge state (muted, volume, playback rate) so any control message
1597+
// that arrived before the iframe runtime registered its listener isn't lost.
1598+
// This fixes a deterministic race on warm-cache reloads of claude.ai and
1599+
// inside the Claude desktop Electron client where the iframe finishes
1600+
// loading after the player has already set audio-locked.
1601+
interface PlayerInternal extends HTMLElement {
1602+
muted: boolean;
1603+
volume: number;
1604+
audioLocked: boolean;
1605+
playbackRate: number;
1606+
iframe: HTMLIFrameElement;
1607+
_onMessage: (event: MessageEvent) => void;
1608+
}
1609+
1610+
let player: PlayerInternal;
1611+
let frameWindow: Window;
1612+
let postSpy: ReturnType<typeof vi.spyOn>;
1613+
1614+
function readyMessage() {
1615+
return new MessageEvent("message", {
1616+
source: frameWindow,
1617+
data: { source: "hf-preview", type: "ready" },
1618+
});
1619+
}
1620+
1621+
function findControlCalls(action: string) {
1622+
return postSpy.mock.calls.filter((call) => {
1623+
const data = call[0] as { type?: string; action?: string };
1624+
return data?.type === "control" && data?.action === action;
1625+
});
1626+
}
1627+
1628+
beforeEach(async () => {
1629+
await import("./hyperframes-player.js");
1630+
player = document.createElement("hyperframes-player") as PlayerInternal;
1631+
frameWindow = window;
1632+
postSpy = vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
1633+
Object.defineProperty(player.iframe, "contentWindow", {
1634+
configurable: true,
1635+
get: () => frameWindow,
1636+
});
1637+
document.body.appendChild(player);
1638+
});
1639+
1640+
afterEach(() => {
1641+
player.remove();
1642+
vi.restoreAllMocks();
1643+
});
1644+
1645+
it("replays current muted state when runtime emits ready", () => {
1646+
player.muted = true;
1647+
postSpy.mockClear();
1648+
1649+
player._onMessage(readyMessage());
1650+
1651+
const muteCalls = findControlCalls("set-muted");
1652+
expect(muteCalls).toHaveLength(1);
1653+
expect(muteCalls[0]?.[0]).toMatchObject({
1654+
source: "hf-parent",
1655+
type: "control",
1656+
action: "set-muted",
1657+
muted: true,
1658+
});
1659+
});
1660+
1661+
it("replays volume and playback-rate alongside muted", () => {
1662+
player.volume = 0.5;
1663+
player.playbackRate = 1.25;
1664+
postSpy.mockClear();
1665+
1666+
player._onMessage(readyMessage());
1667+
1668+
expect(findControlCalls("set-muted")).toHaveLength(1);
1669+
expect(findControlCalls("set-volume")[0]?.[0]).toMatchObject({
1670+
action: "set-volume",
1671+
volume: 0.5,
1672+
});
1673+
expect(findControlCalls("set-playback-rate")[0]?.[0]).toMatchObject({
1674+
action: "set-playback-rate",
1675+
playbackRate: 1.25,
1676+
});
1677+
});
1678+
1679+
it("replays the muted state forced by audio-locked", () => {
1680+
// The audio-locked attribute is the original motivating case for this
1681+
// handshake — its `muted = true` side effect must survive an iframe race.
1682+
player.setAttribute("audio-locked", "");
1683+
expect(player.muted).toBe(true);
1684+
postSpy.mockClear();
1685+
1686+
player._onMessage(readyMessage());
1687+
1688+
const muteCalls = findControlCalls("set-muted");
1689+
expect(muteCalls).toHaveLength(1);
1690+
expect(muteCalls[0]?.[0]).toMatchObject({ action: "set-muted", muted: true });
1691+
});
1692+
1693+
it("replays again on a second ready (idempotent — iframe reloads emit again)", () => {
1694+
player.muted = true;
1695+
postSpy.mockClear();
1696+
1697+
player._onMessage(readyMessage());
1698+
player._onMessage(readyMessage());
1699+
1700+
expect(findControlCalls("set-muted")).toHaveLength(2);
1701+
});
1702+
1703+
it("ignores ready events from a different window", () => {
1704+
postSpy.mockClear();
1705+
const otherSource = {} as Window;
1706+
1707+
player._onMessage(
1708+
new MessageEvent("message", {
1709+
source: otherSource,
1710+
data: { source: "hf-preview", type: "ready" },
1711+
}),
1712+
);
1713+
1714+
expect(findControlCalls("set-muted")).toHaveLength(0);
1715+
});
1716+
});
1717+
15941718
describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => {
15951719
// Some host renderers (observed on the Claude desktop Electron client) strip
15961720
// unknown custom-element attributes before they reach the DOM, so the

packages/player/src/hyperframes-player.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,21 @@ class HyperframesPlayer extends HTMLElement {
428428
}
429429
}
430430

431+
/**
432+
* Replay current bridge state to the iframe runtime. Triggered when the
433+
* runtime announces `{type: "ready"}` — repairs the race where the parent
434+
* posts control messages before the iframe's bridge listener is installed
435+
* (warm-cache reloads, the Claude desktop Electron client, anywhere the
436+
* iframe finishes loading after we've already called `set-muted` etc).
437+
* Re-sending current state is idempotent — even at default values it just
438+
* confirms what the runtime would have done anyway.
439+
*/
440+
private _replayBridgeState(): void {
441+
this._sendControl("set-muted", { muted: this.muted });
442+
this._sendControl("set-volume", { volume: this._volume });
443+
this._sendControl("set-playback-rate", { playbackRate: this.playbackRate });
444+
}
445+
431446
private _reloadShaderOptions(): void {
432447
if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset();
433448
if (this.hasAttribute("srcdoc")) {
@@ -529,6 +544,7 @@ class HyperframesPlayer extends HTMLElement {
529544
},
530545
sendControl: (action, extra) => this._sendControl(action, extra),
531546
getIframeDoc: () => this.iframe.contentDocument,
547+
onRuntimeReady: () => this._replayBridgeState(),
532548
updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d),
533549
updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p),
534550
dispatchEvent: (ev) => this.dispatchEvent(ev),

packages/player/src/runtime-message-handler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
2323
setCompositionSize: (width: number, height: number) => void;
2424
sendControl: (action: string, extra?: Record<string, unknown>) => void;
2525
getIframeDoc: () => Document | null;
26+
/** Invoked when the iframe runtime posts `{type: "ready"}` — the player
27+
* uses it to replay current bridge state (mute, volume, playback rate) so
28+
* control messages sent before the iframe's listener registered aren't lost. */
29+
onRuntimeReady: () => void;
2630
}
2731

2832
export function handleRuntimeMessage(
@@ -48,6 +52,11 @@ export function handleRuntimeMessage(
4852
return;
4953
}
5054

55+
if (data["type"] === "ready") {
56+
callbacks.onRuntimeReady();
57+
return;
58+
}
59+
5160
if (data["type"] === "state") {
5261
callbacks.setPlaybackState(
5362
applyRuntimeStateMessage(

0 commit comments

Comments
 (0)