fix(core): parent-frame proxy bypass, data-preload-eager opt-out, configurable threshold

- Player: _adoptIframeMedia now skips media with preload="metadata" or
  "none", preventing parent-frame proxies from bypassing the preloader.
  MutationObserver extended to watch preload attribute changes so proxies
  are created just-in-time when the preloader promotes a clip.

- init.ts: lazy-mode demotion loop skips elements with data-preload-eager,
  letting power users keep specific clips eagerly buffered.

- mediaPreloader: reads window.__HF_LAZY_PRELOAD_THRESHOLD as an override,
  falling back to the default 6.
This commit is contained in:
Miguel Ángel
2026-05-11 19:37:06 -07:00
parent a96d99680f
commit 35eab94e69
5 changed files with 82 additions and 4 deletions
+4
View File
@@ -1263,6 +1263,10 @@ export function initSandboxRuntimeModular(): void {
// timed clips and would never promote them back.
for (const mediaEl of mediaEls) {
if (!mediaEl.hasAttribute("data-start")) continue;
// Power-user opt-out: data-preload-eager keeps a clip eagerly buffered
// even under lazy mode, useful when a specific clip must be instantly
// available regardless of playhead proximity.
if (mediaEl.hasAttribute("data-preload-eager")) continue;
if (mediaEl.preload === "auto" || mediaEl.preload === "") {
mediaEl.preload = "metadata";
// Kick off the metadata fetch explicitly — some browsers (Chrome Lite
@@ -322,4 +322,37 @@ describe("createMediaPreloadManager", () => {
expect(onActivation).toHaveBeenCalledOnce();
});
it("respects window.__HF_LAZY_PRELOAD_THRESHOLD override", () => {
elements = Array.from({ length: 4 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
// 4 elements is below the default threshold (6) but at our custom one
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 4;
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(true);
// Clean up
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
});
it("falls back to default threshold when __HF_LAZY_PRELOAD_THRESHOLD is not set", () => {
elements = Array.from({ length: 4 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
// Ensure it's not set
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(false);
});
});
+5 -1
View File
@@ -38,7 +38,11 @@ export function createMediaPreloadManager(options?: {
function refresh(): void {
const cache = refreshRuntimeMediaCache(options);
clips = cache.mediaClips;
lazy = clips.length >= LAZY_THRESHOLD;
const configuredThreshold =
typeof (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD === "number"
? ((window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD as number)
: LAZY_THRESHOLD;
lazy = clips.length >= configuredThreshold;
if (lazy && !activationEmitted) {
activationEmitted = true;
options?.onActivation?.(clips.length);
@@ -548,8 +548,15 @@ describe("HyperframesPlayer media MutationObserver scoping", () => {
expect(observedTargets).not.toContain(fakeDoc.body);
// Subtree is still required — sub-composition media can be deeply nested
// inside the host (e.g. wrapper div around the `<audio>`).
// Attribute observation on "preload" is required so the player creates
// parent proxies just-in-time when the preloader promotes a clip.
for (const call of observeSpy.mock.calls) {
expect(call[1]).toEqual({ childList: true, subtree: true });
expect(call[1]).toEqual({
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
});
}
});
+32 -2
View File
@@ -1498,6 +1498,14 @@ class HyperframesPlayer extends HTMLElement {
* identical URL-resolution and attribute parsing.
*/
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
// Respect the preloader's demotion: if the iframe element has been set to
// metadata-only or none, creating a parent proxy with preload="auto" would
// bypass the lazy preloader and eagerly buffer the clip. Skip it — the
// MutationObserver in _observeDynamicMedia watches for preload attribute
// changes and will create the proxy just-in-time when the preloader
// promotes the clip.
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
const rawSrc =
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
if (!rawSrc) return;
@@ -1544,6 +1552,22 @@ class HyperframesPlayer extends HTMLElement {
if (typeof MutationObserver === "undefined" || !doc.body) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
// Attribute mutations: the preloader promotes a clip by changing its
// preload attribute from "metadata" to "auto". When that happens, the
// early-return guard in _adoptIframeMedia no longer blocks, so we can
// create the parent proxy just-in-time.
if (m.type === "attributes" && m.attributeName === "preload") {
const target = m.target;
if (
target instanceof HTMLMediaElement &&
target.matches("audio[data-start], video[data-start]") &&
target.preload === "auto"
) {
this._adoptIframeMedia(target);
}
continue;
}
for (const added of m.addedNodes) {
if (!(added instanceof Element)) continue;
// Handle both the node itself and any timed media nested inside
@@ -1578,13 +1602,19 @@ class HyperframesPlayer extends HTMLElement {
}
}
});
const observeOpts: MutationObserverInit = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
};
const hosts = doc.querySelectorAll("[data-composition-id]");
if (hosts.length > 0) {
for (const host of hosts) {
obs.observe(host, { childList: true, subtree: true });
obs.observe(host, observeOpts);
}
} else {
obs.observe(doc.body, { childList: true, subtree: true });
obs.observe(doc.body, observeOpts);
}
this._mediaObserver = obs;
}