mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -5,6 +5,11 @@ import { initSandboxRuntimeModular } from "./init";
|
||||
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
import {
|
||||
registerRuntimeDataHandler,
|
||||
resetRuntimeDataForTests,
|
||||
setRuntimeData,
|
||||
} from "./runtimeData";
|
||||
|
||||
it("schedules WebAudio element gain from author volume without bridge volume", () => {
|
||||
const source = readFileSync("src/runtime/init.ts", "utf8");
|
||||
@@ -107,6 +112,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
resetRuntimeDataForTests();
|
||||
document.body.innerHTML = "";
|
||||
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
|
||||
globalThis.CSS.escape ??= (value: string) => value;
|
||||
@@ -174,6 +180,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
|
||||
afterEach(() => {
|
||||
window.__hfRuntimeTeardown?.();
|
||||
resetRuntimeDataForTests();
|
||||
document.body.innerHTML = "";
|
||||
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
|
||||
delete window.__player;
|
||||
@@ -2725,6 +2732,117 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(clipControl?.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("rebinds the injected player before reporting runtime-data applied", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const first = createMockTimeline(10);
|
||||
const replacement = createMockTimeline(10);
|
||||
window.__timelines = { main: first };
|
||||
const applied: Array<Record<string, unknown>> = [];
|
||||
const deliveryOrder: string[] = [];
|
||||
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
|
||||
if (typeof message !== "object" || message === null) return;
|
||||
const payload = message as Record<string, unknown>;
|
||||
if (payload.type === "timeline" || payload.type === "runtime-data-applied") {
|
||||
deliveryOrder.push(String(payload.type));
|
||||
}
|
||||
if (payload.type === "runtime-data-applied") applied.push(payload);
|
||||
});
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
deliveryOrder.length = 0;
|
||||
window.__player?.seek(0.25);
|
||||
registerRuntimeDataHandler("captions", async () => {
|
||||
await Promise.resolve();
|
||||
window.__timelines = { main: replacement };
|
||||
});
|
||||
|
||||
setRuntimeData("captions", { style: "replacement" }, 7);
|
||||
await vi.waitFor(() => expect(applied).toHaveLength(1));
|
||||
|
||||
// Runtime seeks are canonicalized to the configured frame rate.
|
||||
expect(replacement.time()).toBeCloseTo(7 / 30, 5);
|
||||
expect(first.time()).toBeCloseTo(7 / 30, 5);
|
||||
|
||||
window.__player?.seek(1.25);
|
||||
|
||||
expect(first.time()).toBeCloseTo(7 / 30, 5);
|
||||
expect(replacement.time()).toBeCloseTo(37 / 30, 5);
|
||||
expect(applied[0]).toMatchObject({ channel: "captions", requestId: 7 });
|
||||
expect(deliveryOrder.slice(0, 2)).toEqual(["timeline", "runtime-data-applied"]);
|
||||
});
|
||||
|
||||
it("does not seek a removed timeline after runtime data is cleared", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const first = createMockTimeline(10);
|
||||
window.__timelines = { main: first };
|
||||
const applied: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
|
||||
if (typeof message !== "object" || message === null) return;
|
||||
const payload = message as Record<string, unknown>;
|
||||
if (payload.type === "runtime-data-applied") applied.push(payload);
|
||||
});
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
window.__player?.seek(0.25);
|
||||
registerRuntimeDataHandler("captions", () => {
|
||||
window.__timelines = {};
|
||||
});
|
||||
|
||||
setRuntimeData("captions", undefined, 8);
|
||||
await vi.waitFor(() => expect(applied).toHaveLength(1));
|
||||
const timeAtClear = first.time();
|
||||
|
||||
window.__player?.seek(1.25);
|
||||
|
||||
expect(first.time()).toBe(timeAtClear);
|
||||
});
|
||||
|
||||
it("does not report applied when a runtime-data handler rejects", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
|
||||
const applied: Array<Record<string, unknown>> = [];
|
||||
const errors: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
|
||||
if (typeof message !== "object" || message === null) return;
|
||||
const payload = message as Record<string, unknown>;
|
||||
if (payload.type === "runtime-data-applied") applied.push(payload);
|
||||
if (payload.type === "runtime-data-error") errors.push(payload);
|
||||
});
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
registerRuntimeDataHandler("captions", async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error("attach failed");
|
||||
});
|
||||
|
||||
setRuntimeData("captions", { style: "broken" }, 9);
|
||||
await vi.waitFor(() => expect(errors).toHaveLength(1));
|
||||
|
||||
expect(applied).toHaveLength(0);
|
||||
expect(errors[0]).toMatchObject({ channel: "captions", requestId: 9 });
|
||||
});
|
||||
|
||||
it("onSetMuted preserves authored muted attribute on video elements", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "root");
|
||||
@@ -2912,13 +3030,76 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(seekTimes.length).toBeGreaterThan(beforePlaying);
|
||||
player?.pause();
|
||||
|
||||
// (3) Paused + marker cleared (drop/cancel) → the per-frame re-seek resumes.
|
||||
// (3) Paused + marker cleared (drop/cancel) → one reconciliation seek runs.
|
||||
document.getElementById("dragged")?.removeAttribute("data-hf-studio-manual-edit-gesture");
|
||||
const beforeResume = seekTimes.length;
|
||||
raf.step(16);
|
||||
expect(seekTimes.length).toBeGreaterThan(beforeResume);
|
||||
});
|
||||
|
||||
it("does not re-seek an unchanged paused timeline on every animation frame", () => {
|
||||
const raf = createManualRaf();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
||||
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
|
||||
|
||||
const seekTimes: number[] = [];
|
||||
const tl = createMockTimeline(5);
|
||||
const origTotalTime = tl.totalTime;
|
||||
tl.totalTime = ((time: number, ...rest: unknown[]) => {
|
||||
seekTimes.push(time);
|
||||
(origTotalTime as Function).call(tl, time, ...rest);
|
||||
}) as RuntimeTimelineLike["totalTime"];
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
|
||||
`;
|
||||
window.__timelines = { root: tl };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
// The first transport frame reconciles the initial timeline at the paused playhead.
|
||||
raf.step(16);
|
||||
const afterInitialFrame = seekTimes.length;
|
||||
expect(afterInitialFrame).toBeGreaterThan(0);
|
||||
|
||||
// No time or timeline change means there is no new frame to render.
|
||||
raf.step(16);
|
||||
raf.step(16);
|
||||
raf.step(16);
|
||||
expect(seekTimes.length).toBe(afterInitialFrame);
|
||||
|
||||
// An explicit paused seek still renders immediately, then settles again after the transport
|
||||
// records the new playhead on its next frame.
|
||||
window.__player?.seek(2);
|
||||
expect(seekTimes.some((time) => time === 2)).toBe(true);
|
||||
raf.step(16);
|
||||
const afterPausedSeek = seekTimes.length;
|
||||
raf.step(16);
|
||||
expect(seekTimes.length).toBe(afterPausedSeek);
|
||||
|
||||
// A runtime-data rebuild can replace the timeline without moving the paused playhead. The
|
||||
// identity check must render that new object once instead of treating it as the old frame.
|
||||
const replacementSeekTimes: number[] = [];
|
||||
const replacement = createMockTimeline(5);
|
||||
const replacementTotalTime = replacement.totalTime;
|
||||
replacement.totalTime = ((time: number, ...rest: unknown[]) => {
|
||||
replacementSeekTimes.push(time);
|
||||
(replacementTotalTime as Function).call(replacement, time, ...rest);
|
||||
}) as RuntimeTimelineLike["totalTime"];
|
||||
window.__timelines = { root: replacement };
|
||||
window.__hfForceTimelineRebind?.();
|
||||
raf.step(16);
|
||||
expect(replacementSeekTimes.length).toBeGreaterThan(0);
|
||||
const afterReplacementFrame = replacementSeekTimes.length;
|
||||
raf.step(16);
|
||||
expect(replacementSeekTimes.length).toBe(afterReplacementFrame);
|
||||
|
||||
// Playback still traverses the timeline every frame.
|
||||
window.__player?.play();
|
||||
raf.step(16);
|
||||
expect(replacementSeekTimes.length).toBeGreaterThan(afterReplacementFrame);
|
||||
});
|
||||
|
||||
it("redraws animated grading from the transport clock only during playback", () => {
|
||||
const raf = createManualRaf();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
||||
|
||||
@@ -135,6 +135,11 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
|
||||
|
||||
export function initSandboxRuntimeModular(): void {
|
||||
const state = createRuntimeState();
|
||||
// Runtime-data handlers may replace the timeline object they mutate. Keep the
|
||||
// reconciliation callback late-bound because the reporter is installed before
|
||||
// the timeline resolver/binder is declared below. Delivery cannot complete
|
||||
// until after init has installed the final callback.
|
||||
let reconcileTimelineAfterRuntimeData: () => void = () => undefined;
|
||||
// Own the analytics bridge before any best-effort runtime installation so
|
||||
// early failures are observable instead of disappearing before player setup.
|
||||
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
|
||||
@@ -148,6 +153,18 @@ export function initSandboxRuntimeModular(): void {
|
||||
});
|
||||
});
|
||||
setRuntimeDataAppliedReporter((channel, requestId) => {
|
||||
try {
|
||||
reconcileTimelineAfterRuntimeData();
|
||||
} catch (error) {
|
||||
postRuntimeMessage({
|
||||
source: "hf-preview",
|
||||
type: "runtime-data-error",
|
||||
channel,
|
||||
requestId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
postRuntimeMessage({
|
||||
source: "hf-preview",
|
||||
type: "runtime-data-applied",
|
||||
@@ -1542,11 +1559,37 @@ export function initSandboxRuntimeModular(): void {
|
||||
return true;
|
||||
};
|
||||
|
||||
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => {
|
||||
childrenBound = false;
|
||||
bindRootTimelineIfAvailable();
|
||||
const reconcileTimeline = () => {
|
||||
if (state.tornDown) return;
|
||||
const resolution = resolveRootTimelineFromDocument();
|
||||
if (!resolution.timeline) {
|
||||
// A successful clear must not leave the player seeking a killed timeline.
|
||||
state.capturedTimeline = null;
|
||||
childrenBound = false;
|
||||
clock.setDuration(0);
|
||||
syncTimedElementVisibility(state.currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid needlessly invalidating the child-binding cache when a handler
|
||||
// updates data in place. A replacement object is the signal that a rebind
|
||||
// is required.
|
||||
if (state.capturedTimeline !== resolution.timeline) {
|
||||
childrenBound = false;
|
||||
bindRootTimelineIfAvailable();
|
||||
}
|
||||
syncTimedElementVisibility(state.currentTime);
|
||||
};
|
||||
reconcileTimelineAfterRuntimeData = () => {
|
||||
reconcileTimeline();
|
||||
// The parent treats runtime-data-applied as permission to re-seek immediately. Publish the
|
||||
// replacement duration first; otherwise that seek is clamped by the bootstrap timeline (often
|
||||
// one second) and a style switch appears frozen on the first caption segment until some later
|
||||
// polling tick happens to post the rebuilt timeline.
|
||||
postTimeline();
|
||||
};
|
||||
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind =
|
||||
reconcileTimeline;
|
||||
|
||||
const emitRootStageLayoutDiagnostics = () => {
|
||||
const rootNode = resolveRootCompositionElement();
|
||||
@@ -2766,6 +2809,13 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
let transportTickCount = 0;
|
||||
let inTransportTick = false;
|
||||
// A paused transport has no new frame to render. Re-seeking the same GSAP timeline at the
|
||||
// same time on every rAF is not merely redundant: one picker can embed several paused
|
||||
// players, multiplying full timeline traversal and style invalidation across every iframe.
|
||||
// Keep enough identity to render once when time or the asynchronously-bound timeline changes.
|
||||
let lastTransportSeekTime = Number.NaN;
|
||||
let lastTransportSeekTimeline: RuntimeTimelineLike | null = null;
|
||||
let pausedSeekDeferredByManualGesture = false;
|
||||
|
||||
const seekRuntimeTimeline = (
|
||||
timeline: RuntimeTimelineLike,
|
||||
@@ -3086,10 +3136,23 @@ export function initSandboxRuntimeModular(): void {
|
||||
// skipping the re-seek is a no-op for every other element; it resumes
|
||||
// the frame the gesture marker clears (drop/cancel). Playback is never
|
||||
// affected — the seek runs whenever the clock is playing.
|
||||
if (clock.isPlaying() || !hasActiveStudioManualEditGesture()) {
|
||||
const isPlaying = clock.isPlaying();
|
||||
const manualEditOwnsPausedFrame = !isPlaying && hasActiveStudioManualEditGesture();
|
||||
if (manualEditOwnsPausedFrame) {
|
||||
// Force one reconciliation after drop/cancel even though the playhead did not move.
|
||||
pausedSeekDeferredByManualGesture = true;
|
||||
} else if (
|
||||
isPlaying ||
|
||||
pausedSeekDeferredByManualGesture ||
|
||||
t !== lastTransportSeekTime ||
|
||||
state.capturedTimeline !== lastTransportSeekTimeline
|
||||
) {
|
||||
seekTimelineAndAdapters(t);
|
||||
lastTransportSeekTime = t;
|
||||
lastTransportSeekTimeline = state.capturedTimeline;
|
||||
if (!isPlaying) pausedSeekDeferredByManualGesture = false;
|
||||
}
|
||||
if (clock.isPlaying()) {
|
||||
if (isPlaying) {
|
||||
colorGrading.redrawAnimated();
|
||||
}
|
||||
|
||||
@@ -3485,6 +3548,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
state.injectedCompScripts = [];
|
||||
state.capturedTimeline = null;
|
||||
reconcileTimelineAfterRuntimeData = () => undefined;
|
||||
if (window.__hfRuntimeTeardown === teardown) {
|
||||
window.__hfRuntimeTeardown = null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user