Skip to content
Open
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
62 changes: 62 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3307,3 +3307,65 @@ describe("initSandboxRuntimeModular", () => {
});
});
});

describe("parent-driven transport tick", () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;

beforeEach(() => {
document.body.innerHTML = "";
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
globalThis.CSS.escape ??= (value: string) => value;
// The recursive schedule inside transportTick is short-circuited by the
// runtime's own re-entry guard, so a synchronous rAF runs the local
// transport exactly once and then leaves it stopped — which is the state
// this suite needs: a frame whose own rAF has stopped delivering.
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
});

afterEach(() => {
window.__hfRuntimeTeardown?.();
document.body.innerHTML = "";
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
delete window.__player;
delete window.__playerReady;
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});

function control(action: string, extra: Record<string, unknown> = {}): void {
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action, ...extra },
}),
);
}

it("posts the position a parent tick advanced to", () => {
document.body.innerHTML = `<div data-composition-id="main" data-root="true" data-duration="10"></div>`;
window.__timelines = {};
const nowMs = vi.spyOn(performance, "now");
nowMs.mockReturnValue(1_000);
initSandboxRuntimeModular();

const posted: Record<string, unknown>[] = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
posted.push(message as Record<string, unknown>);
});

control("play");
nowMs.mockReturnValue(3_000);
control("tick");

const states = posted.filter((m) => m["type"] === "state");
// 2s of wall clock at the default 30fps canonical rate = frame 60. Without
// the tick reporting the position it advanced to, the embedder only ever
// sees the frame the (now stopped) local transport last posted.
expect(states.at(-1)).toMatchObject({ frame: 60, isPlaying: true });
});
});
9 changes: 9 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3381,7 +3381,16 @@ export function initSandboxRuntimeModular(): void {
runAdapters("pause");
syncMediaForCurrentState();
postState(true);
return;
}
// The parent drives this tick precisely when our own rAF transport is
// throttled, so nothing else will report the position it just advanced
// to. Without this the composition animates while the embedder's
// playhead, "timeupdate" and end-of-playback detection stay frozen at
// the last frame the local transport managed to post. postState's own
// change/interval filter keeps the message rate the same as the local
// transport's.
postState(false);
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
Expand Down
150 changes: 150 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2501,3 +2501,153 @@ describe("HyperframesPlayer retained runtime data", () => {
expect(() => player.setRuntimeData("captions", () => undefined)).toThrow();
});
});

// The parent tick clock is the only thing driving a composition whose own rAF
// Chromium has throttled — which is exactly the cross-origin case. These cover
// the two ways it used to be torn down before it had sent a single tick.
describe("HyperframesPlayer parent tick clock lifetime", () => {
type PlayerInternal = HTMLElement & {
iframe: HTMLIFrameElement;
play: () => void;
pause: () => void;
_ready: boolean;
_duration: number;
_paused: boolean;
_parentTickRaf: number | null;
_runtimeBridgeReady: boolean;
_onMessage: (event: MessageEvent) => void;
probe: { start: () => void };
};

let player: PlayerInternal;
let frameWindow: Window;
let postSpy: ReturnType<typeof vi.spyOn>;
let frames: FrameRequestCallback[];
const originalRaf = window.requestAnimationFrame;
const originalCancelRaf = window.cancelAnimationFrame;

/** Run one animation frame's worth of scheduled callbacks. */
const advanceFrame = () => {
const due = frames;
frames = [];
for (const cb of due) cb(0);
};

const tickCount = () =>
postSpy.mock.calls.filter(
(call) => (call[0] as { action?: string } | undefined)?.action === "tick",
).length;

const stateMessage = (frame: number, isPlaying: boolean) =>
new MessageEvent("message", {
source: frameWindow,
data: { source: "hf-preview", type: "state", frame, isPlaying },
});

beforeEach(async () => {
frames = [];
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
frames.push(cb);
return frames.length;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
frames.splice(id - 1, 1);
}) as typeof window.cancelAnimationFrame;

await import("./hyperframes-player.js");
player = document.createElement("hyperframes-player") as PlayerInternal;
frameWindow = window;
postSpy = vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
Object.defineProperty(player.iframe, "contentWindow", {
configurable: true,
get: () => frameWindow,
});
document.body.appendChild(player);
player._ready = true;
player._duration = 15;
});

afterEach(() => {
player.remove();
vi.restoreAllMocks();
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancelRaf;
});

it("keeps ticking when a state message posted before play() lands after it", () => {
player.play();
// The runtime posts this on the frame before it receives "play", so it
// reports isPlaying: false and arrives while the parent is already playing.
player._onMessage(stateMessage(0, false));

advanceFrame();
advanceFrame();

expect(tickCount()).toBe(2);
});

it("keeps ticking when the iframe load event lands after the composition is ready", () => {
player.play();
// A composition still fetching images/fonts announces its timeline on
// DOMContentLoaded and only fires `load` afterwards.
player.iframe.dispatchEvent(new Event("load"));

advanceFrame();

expect(player._ready).toBe(true);
expect(tickCount()).toBe(1);
});

it("stops ticking when the composition pauses itself short of the end", () => {
player.play();
advanceFrame();
expect(tickCount()).toBe(1);
// The runtime confirms the play, so the next not-playing report is a real
// stop rather than a message that crossed our own play command.
player._onMessage(stateMessage(30, true));

// Composition code calling pause() on `window.__player`: mid-timeline, so
// the completed-playback path never runs.
player._onMessage(stateMessage(60, false));
advanceFrame();
advanceFrame();

expect(player._parentTickRaf).toBeNull();
expect(tickCount()).toBe(1);
});

it("keeps ticking after a shader-option reload starts a new document", () => {
// _reloadShaderOptions reassigns the iframe document. If that path does not
// clear readiness, the load handler skips the teardown and the fresh
// document is left with no probe and a stale bridge flag.
player.setAttribute("src", "https://composition.example/comp.html");
player._ready = true;

const probeStart = vi.spyOn(player.probe, "start");

player.setAttribute("shader-loading", "eager");
expect(player._ready).toBe(false);

player.iframe.dispatchEvent(new Event("load"));

expect(probeStart).toHaveBeenCalled();
expect(player._runtimeBridgeReady).toBe(false);
});

it("stops ticking once the composition reports the end of the timeline", () => {
player.play();
advanceFrame();
expect(tickCount()).toBe(1);
// Playback reports itself as it runs; the runtime cannot reach the end of a
// timeline without having said it was playing on the way there.
player._onMessage(stateMessage(30, true));

// frame 450 at the default 30fps protocol rate = 15s = the full duration.
player._onMessage(stateMessage(450, false));
advanceFrame();
advanceFrame();

expect(player._parentTickRaf).toBeNull();
expect(tickCount()).toBe(1);
});
});
Loading
Loading