Skip to content
Merged
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
78 changes: 78 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,81 @@ describe("HyperframesPlayer adoptedStyleSheets", () => {
expect(player.shadowRoot?.querySelector("style")).toBeNull();
});
});

// ── Media MutationObserver scoping ──
//
// The observer that catches late-attached `<audio data-start>` from
// sub-composition activation used to watch `iframe.contentDocument.body`
// wholesale. That fired on every body-level mutation — analytics scripts,
// runtime telemetry markers, dev-only overlays — even though only
// composition-tree changes can introduce new timed media. The fix is to
// scope per top-level composition host (see `selectMediaObserverTargets`);
// these tests verify the player honors that scoping.

describe("HyperframesPlayer media MutationObserver scoping", () => {
type PlayerInternal = HTMLElement & {
_observeDynamicMedia?: (doc: Document) => void;
};

beforeEach(async () => {
await import("./hyperframes-player.js");
});

afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});

it("attaches the observer to each top-level composition host (not the body)", () => {
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");

const player = document.createElement("hyperframes-player") as PlayerInternal;
document.body.appendChild(player);
// The constructor doesn't install an observer — only `_observeDynamicMedia`
// does — so the spy starts clean for the call we care about.
observeSpy.mockClear();

// Simulates the iframe document the runtime hands the player after mount.
// Bypassing the iframe lifecycle keeps the test deterministic; the
// selection logic itself is exercised in `mediaObserverScope.test.ts`.
const fakeDoc = document.implementation.createHTMLDocument("test");
fakeDoc.body.innerHTML = `
<div data-composition-id="root-a"></div>
<div data-composition-id="root-b"></div>
<script>// runtime telemetry — body-level, must NOT be observed</script>
`;

player._observeDynamicMedia?.(fakeDoc);

expect(observeSpy).toHaveBeenCalledTimes(2);
const observedTargets = observeSpy.mock.calls.map((call) => call[0]);
expect(observedTargets.map((t) => (t as Element).getAttribute("data-composition-id"))).toEqual([
"root-a",
"root-b",
]);
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>`).
for (const call of observeSpy.mock.calls) {
expect(call[1]).toEqual({ childList: true, subtree: true });
}
});

it("falls back to observing the document body when no composition hosts exist", () => {
// Preserves the legacy behavior for documents that haven't bootstrapped
// a composition tree yet (e.g. a blank iframe between src changes).
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");

const player = document.createElement("hyperframes-player") as PlayerInternal;
document.body.appendChild(player);
observeSpy.mockClear();

const fakeDoc = document.implementation.createHTMLDocument("test");
fakeDoc.body.innerHTML = `<div class="not-a-composition"></div>`;

player._observeDynamicMedia?.(fakeDoc);

expect(observeSpy).toHaveBeenCalledTimes(1);
expect(observeSpy.mock.calls[0]?.[0]).toBe(fakeDoc.body);
});
});
9 changes: 8 additions & 1 deletion packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,14 @@ class HyperframesPlayer extends HTMLElement {
}
}
});
obs.observe(doc.body, { childList: true, subtree: true });
const hosts = doc.querySelectorAll("[data-composition-id]");
if (hosts.length > 0) {
for (const host of hosts) {
obs.observe(host, { childList: true, subtree: true });
}
} else {
obs.observe(doc.body, { childList: true, subtree: true });
}
this._mediaObserver = obs;
}

Expand Down
192 changes: 192 additions & 0 deletions packages/player/src/mediaObserverScope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { selectMediaObserverTargets } from "./mediaObserverScope.js";

afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});

function makeDoc(html: string): Document {
// happy-dom doesn't ship a usable XMLHttpRequest path for parser-driven
// doc creation, so we build a fresh document by hand and inject markup
// through the body — same DOM shape the iframe document will have when
// the runtime finishes mounting compositions.
const doc = document.implementation.createHTMLDocument("test");
doc.body.innerHTML = html;
return doc;
}

describe("selectMediaObserverTargets", () => {
it("returns the single root composition host", () => {
const doc = makeDoc(`
<div data-composition-id="root"></div>
`);

const targets = selectMediaObserverTargets(doc);

expect(targets).toHaveLength(1);
expect(targets[0]?.getAttribute("data-composition-id")).toBe("root");
});

it("returns only top-level hosts when sub-composition hosts are nested", () => {
// Mirrors the runtime structure: root host with a sub-composition host
// mounted inside it. The nested host is already covered by the root
// host's subtree observation.
const doc = makeDoc(`
<div data-composition-id="root">
<div data-composition-id="sub-1"></div>
<div>
<div data-composition-id="sub-2"></div>
</div>
</div>
`);

const targets = selectMediaObserverTargets(doc);

expect(targets).toHaveLength(1);
expect(targets[0]?.getAttribute("data-composition-id")).toBe("root");
});

it("returns multiple hosts when they are siblings (no shared ancestor host)", () => {
const doc = makeDoc(`
<div data-composition-id="comp-a"></div>
<div data-composition-id="comp-b"></div>
`);

const targets = selectMediaObserverTargets(doc);

expect(targets).toHaveLength(2);
expect(targets.map((t) => t.getAttribute("data-composition-id"))).toEqual(["comp-a", "comp-b"]);
});

it("ignores attribute presence on intermediate non-host elements", () => {
// Only `data-composition-id` is meaningful; an unrelated `data-composition`
// attribute on a wrapper must not promote a nested host to top-level.
const doc = makeDoc(`
<div data-composition-id="root">
<div data-composition="not-a-host">
<div data-composition-id="sub"></div>
</div>
</div>
`);

const targets = selectMediaObserverTargets(doc);

expect(targets).toHaveLength(1);
expect(targets[0]?.getAttribute("data-composition-id")).toBe("root");
});

it("falls back to the document body when no composition hosts exist", () => {
// Documents that haven't been bootstrapped (or never will be) keep the
// legacy behavior so adoption logic still runs against late additions.
const doc = makeDoc(`<div class="not-a-composition"></div>`);

const targets = selectMediaObserverTargets(doc);

expect(targets).toEqual([doc.body]);
});

it("returns an empty array when neither hosts nor body are available", () => {
// Synthetic edge case — guards the caller against attaching an observer
// to `undefined` if the document is missing both signals. happy-dom
// auto-fills `<body>`, so we hand-roll a minimal Document shape rather
// than fight the runtime.
const doc = {
body: null,
querySelectorAll: () => [],
} as unknown as Document;

const targets = selectMediaObserverTargets(doc);

expect(targets).toEqual([]);
});

describe("body-fallback collision warning", () => {
it("warns when scoped observation skips body-level timed media", () => {
// Composition host present → scoped path. The body-level <audio data-start>
// is outside every host subtree, so the observer would never see it.
// This is precisely the silent-miss the warning is designed to surface.
const doc = makeDoc(`
<audio data-start="0" src="theme.mp3"></audio>
<div data-composition-id="root">
<video data-start="1" src="hero.mp4"></video>
</div>
`);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

selectMediaObserverTargets(doc);

expect(warn).toHaveBeenCalledTimes(1);
const [message, orphans] = warn.mock.calls[0] ?? [];
expect(typeof message).toBe("string");
expect(message).toContain("body-level timed media");
expect(Array.isArray(orphans)).toBe(true);
expect((orphans as Element[]).map((el) => el.tagName)).toEqual(["AUDIO"]);
});

it("does not warn when every body-level timed media element lives inside a host", () => {
// Same body-level audio as above, but now nested under a composition
// host — the scoped observer will pick it up via the host subtree, so
// there's no silent-miss to flag.
const doc = makeDoc(`
<div data-composition-id="root">
<audio data-start="0" src="theme.mp3"></audio>
<video data-start="1" src="hero.mp4"></video>
</div>
`);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

selectMediaObserverTargets(doc);

expect(warn).not.toHaveBeenCalled();
});

it("does not warn on the body-fallback path even with orphan timed media", () => {
// No composition hosts → fallback observer attaches to `doc.body`, which
// already covers any body-level media. Emitting the warning here would
// be noise on every legacy / pre-bootstrap document.
const doc = makeDoc(`
<audio data-start="0" src="theme.mp3"></audio>
`);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

selectMediaObserverTargets(doc);

expect(warn).not.toHaveBeenCalled();
});

it("ignores body-level audio/video that are not timed (no data-start)", () => {
// Untimed media isn't part of the time-sync pipeline, so it doesn't
// matter whether the observer sees it. Only `[data-start]` orphans
// qualify as a silent miss worth surfacing.
const doc = makeDoc(`
<audio src="ambient.mp3"></audio>
<div data-composition-id="root"></div>
`);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

selectMediaObserverTargets(doc);

expect(warn).not.toHaveBeenCalled();
});

it("emits a single warn for multiple orphaned timed media elements", () => {
// The whole point of the forensic guard is to give a single, batched
// signal. Spamming one warn per orphan would drown out the diagnostic
// value on documents with many late-bound clips.
const doc = makeDoc(`
<audio data-start="0" src="a.mp3"></audio>
<video data-start="1" src="b.mp4"></video>
<audio data-start="2" src="c.mp3"></audio>
<div data-composition-id="root"></div>
`);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

selectMediaObserverTargets(doc);

expect(warn).toHaveBeenCalledTimes(1);
const [, orphans] = warn.mock.calls[0] ?? [];
expect((orphans as Element[]).map((el) => el.tagName)).toEqual(["AUDIO", "VIDEO", "AUDIO"]);
});
});
});
99 changes: 99 additions & 0 deletions packages/player/src/mediaObserverScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Internal helper for scoping the player's media MutationObserver to the
* composition tree inside the iframe.
*
* Not part of the package's public API — kept in its own module so the
* decision logic can be exercised by unit tests without exposing it through
* the player entry point.
*/

/**
* Pick the elements inside `doc` that the media MutationObserver should
* attach to.
*
* Compositions mount inside `[data-composition-id]` host elements — the
* runtime root and any sub-composition hosts that `compositionLoader` writes
* into them. Watching only those hosts (with `subtree: true`) catches every
* late-arriving timed media element from sub-composition activation, while
* filtering out churn from analytics tags, runtime telemetry markers, and
* other out-of-host nodes that the runtime appends straight to `<body>`
* during bootstrap.
*
* Nested hosts are filtered out — they're already covered by their nearest
* host ancestor's subtree observation, so observing them too would deliver
* each callback twice and double-count adoption work.
*
* Falls back to `[doc.body]` when no composition hosts are present, which
* preserves the previous behavior for documents that aren't yet (or never
* will be) composition-structured. Returns an empty array when neither a
* host nor a body is available — the caller should treat that as "nothing
* to observe".
*
* When the scoped path is taken but the body still carries timed media
* outside every host, a `console.warn` fires once per call as a forensic
* signal: the new scope skips that media, so any `<audio data-start>` /
* `<video data-start>` injected at body level will silently never get a
* parent-frame proxy. Today every runtime path appends inside a host so
* this branch shouldn't trip; if it does, the warn surfaces the drift
* immediately rather than presenting as a missing-audio bug downstream.
*/
export function selectMediaObserverTargets(doc: Document): Element[] {
const all = Array.from(doc.querySelectorAll<Element>("[data-composition-id]"));
if (all.length === 0) {
return doc.body ? [doc.body] : [];
}

const topLevel: Element[] = [];
for (const el of all) {
if (!hasCompositionAncestor(el)) {
topLevel.push(el);
}
}

warnOnUnscopedTimedMedia(doc);
return topLevel;
}

/**
* Forensic guard: with composition hosts present the observer attaches only
* to those subtrees, so any timed media sitting at body level (or under a
* non-host wrapper) is invisible to the adoption pipeline. Walk the body for
* `[data-start]` audio/video that has no `[data-composition-id]` ancestor
* and emit a single `console.warn` listing the orphans. The walk is cheap
* (one `querySelectorAll` over a typed selector + a `closest` per match)
* and only runs on the scoped path, so the no-host fallback retains its
* legacy behavior with zero extra work.
*/
function warnOnUnscopedTimedMedia(doc: Document): void {
const body = doc.body;
if (!body) return;
if (typeof console === "undefined" || typeof console.warn !== "function") return;

const candidates = body.querySelectorAll<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
if (candidates.length === 0) return;

const orphans: HTMLMediaElement[] = [];
for (const el of candidates) {
if (!el.closest("[data-composition-id]")) orphans.push(el);
}
if (orphans.length === 0) return;

console.warn(
"[hyperframes-player] selectMediaObserverTargets: composition hosts are present, " +
`but ${orphans.length} body-level timed media element(s) sit outside every ` +
"[data-composition-id] subtree and will not be observed. Move them inside a " +
"composition host or the parent-frame proxy will never adopt them.",
orphans,
);
}

function hasCompositionAncestor(el: Element): boolean {
let cursor = el.parentElement;
while (cursor) {
if (cursor.hasAttribute("data-composition-id")) return true;
cursor = cursor.parentElement;
}
return false;
}
Loading