mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(slideshow): retry init at DOMContentLoaded when the children were not parsed yet (#3750)
connectedCallback defers init to a setTimeout(0) so the parser can append the element's children first. With the bundle loaded from <head>, as `hyperframes present` emits it, headless Chromium fires that timer while the parser is still inside <hyperframes-slideshow>: init finds no player or a half-streamed island, returns, and the deck never binds. While document.readyState is "loading", init now retries once at DOMContentLoaded. After parsing there is nothing to wait for, so a missing or malformed island still fails quietly as before. The subtree check is split out as locateSlideshowParts so the classification is testable without the element lifecycle.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { handleRuntimeMessage } from "../runtime-message-handler.js";
|
||||
import { dropInvalidSlides } from "./hyperframes-slideshow.js";
|
||||
import {
|
||||
childrenMayStillArrive,
|
||||
dropInvalidSlides,
|
||||
locateSlideshowParts,
|
||||
} from "./hyperframes-slideshow.js";
|
||||
import { slideshowChannelName } from "./slideshowPresenter.js";
|
||||
|
||||
// Dynamic import defers custom-element registration until happy-dom is active.
|
||||
@@ -1561,6 +1565,167 @@ describe("<hyperframes-slideshow> deferred init (Bug 1)", () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// The macrotask is not a guarantee: with the bundle loaded from <head>, real
|
||||
// headless Chromium fires it while the parser is still inside the element,
|
||||
// so init sees no player and no island. While the document is loading, init
|
||||
// retries once at DOMContentLoaded, when every parser-inserted child exists.
|
||||
describe("retry at DOMContentLoaded when init ran before the children were parsed", () => {
|
||||
const island = `
|
||||
<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [ { "sceneId": "intro", "startTime": 0, "endTime": 1 } ] }
|
||||
</script>
|
||||
`;
|
||||
|
||||
function fakePlayer(): HTMLElement {
|
||||
const player = document.createElement("hyperframes-player");
|
||||
Object.defineProperty(player, "ready", { get: () => true });
|
||||
Object.defineProperty(player, "seek", { value: () => {} });
|
||||
Object.defineProperty(player, "play", { value: () => {} });
|
||||
Object.defineProperty(player, "pause", { value: () => {} });
|
||||
Object.defineProperty(player, "currentTime", { get: () => 0 });
|
||||
Object.defineProperty(player, "scenes", { get: () => [] });
|
||||
return player;
|
||||
}
|
||||
|
||||
function setReadyState(state: DocumentReadyState): void {
|
||||
Object.defineProperty(document, "readyState", { value: state, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
setReadyState("loading");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setReadyState("complete");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("binds once DOMContentLoaded fires with the children present", async () => {
|
||||
const el = document.createElement("hyperframes-slideshow");
|
||||
document.body.appendChild(el);
|
||||
|
||||
// The deferred init runs against an empty subtree and finds nothing.
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(el.querySelector("[data-hf-chrome]")).toBeNull();
|
||||
|
||||
// The parser catches up, then the document finishes loading.
|
||||
el.innerHTML = island;
|
||||
el.appendChild(fakePlayer());
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(el.querySelector("[data-hf-chrome]")).toBeTruthy();
|
||||
expect(el.querySelector("[data-hf-counter]")?.textContent).toContain("1");
|
||||
|
||||
el.remove();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
it("retries when the island was only partly streamed in", async () => {
|
||||
const el = document.createElement("hyperframes-slideshow");
|
||||
el.appendChild(fakePlayer());
|
||||
// What the parser has appended so far: an open island with truncated JSON.
|
||||
el.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<script type="application/hyperframes-slideshow+json">{ "slides": [ { "sceneId": "in</script>`,
|
||||
);
|
||||
document.body.appendChild(el);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(el.querySelector("[data-hf-chrome]")).toBeNull();
|
||||
|
||||
el.querySelector("script")?.remove();
|
||||
el.insertAdjacentHTML("beforeend", island);
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(el.querySelector("[data-hf-chrome]")).toBeTruthy();
|
||||
|
||||
el.remove();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
it("does not retry once the document has finished parsing", async () => {
|
||||
setReadyState("complete");
|
||||
const addListener = vi.spyOn(document, "addEventListener");
|
||||
const el = document.createElement("hyperframes-slideshow");
|
||||
document.body.appendChild(el);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const armed = addListener.mock.calls.some(([type]) => type === "DOMContentLoaded");
|
||||
expect(armed).toBe(false);
|
||||
addListener.mockRestore();
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("drops the retry when the element is disconnected first", async () => {
|
||||
const removeListener = vi.spyOn(document, "removeEventListener");
|
||||
const el = document.createElement("hyperframes-slideshow");
|
||||
document.body.appendChild(el);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
el.innerHTML = island;
|
||||
el.appendChild(fakePlayer());
|
||||
el.remove();
|
||||
const dropped = removeListener.mock.calls.some(([type]) => type === "DOMContentLoaded");
|
||||
expect(dropped).toBe(true);
|
||||
removeListener.mockRestore();
|
||||
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(el.querySelector("[data-hf-chrome]")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("slideshow parts (pure)", () => {
|
||||
const island = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [ { "sceneId": "intro", "startTime": 0, "endTime": 1 } ] }
|
||||
</script>`;
|
||||
|
||||
function subtree(html: string): Element {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = html;
|
||||
return root;
|
||||
}
|
||||
|
||||
it("childrenMayStillArrive is true only while the document is loading", () => {
|
||||
expect(childrenMayStillArrive("loading")).toBe(true);
|
||||
expect(childrenMayStillArrive("interactive")).toBe(false);
|
||||
expect(childrenMayStillArrive("complete")).toBe(false);
|
||||
});
|
||||
|
||||
it("locateSlideshowParts reports what is missing, in the order init needs it", () => {
|
||||
expect(locateSlideshowParts(subtree(""))).toEqual({ kind: "incomplete", reason: "no-player" });
|
||||
expect(locateSlideshowParts(subtree(island))).toEqual({
|
||||
kind: "incomplete",
|
||||
reason: "no-player",
|
||||
});
|
||||
expect(locateSlideshowParts(subtree("<hyperframes-player></hyperframes-player>"))).toEqual({
|
||||
kind: "incomplete",
|
||||
reason: "no-island",
|
||||
});
|
||||
expect(
|
||||
locateSlideshowParts(
|
||||
subtree(
|
||||
`<hyperframes-player></hyperframes-player>` +
|
||||
`<script type="application/hyperframes-slideshow+json">{ "slides": [ {</script>`,
|
||||
),
|
||||
),
|
||||
).toEqual({ kind: "incomplete", reason: "malformed-island" });
|
||||
});
|
||||
|
||||
it("locateSlideshowParts is ready when both the player and the island are there", () => {
|
||||
const root = subtree(`<hyperframes-player></hyperframes-player>${island}`);
|
||||
const parts = locateSlideshowParts(root);
|
||||
expect(parts.kind).toBe("ready");
|
||||
if (parts.kind !== "ready") return;
|
||||
expect(parts.player).toBe(root.querySelector("hyperframes-player"));
|
||||
expect(parts.manifest.slides.map((s) => s.sceneId)).toEqual(["intro"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -100,6 +100,30 @@ function isPlayerElement(el: HTMLElement): el is PlayerElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** What init needs from the element's subtree, or why it cannot start yet. */
|
||||
export type SlideshowParts =
|
||||
| { kind: "ready"; player: HTMLElement; manifest: SlideshowManifest }
|
||||
| { kind: "incomplete"; reason: "no-player" | "no-island" | "malformed-island" };
|
||||
|
||||
/** Classify the subtree. Reads the DOM, decides nothing about timing. */
|
||||
export function locateSlideshowParts(root: Element): SlideshowParts {
|
||||
const player = root.querySelector("hyperframes-player");
|
||||
if (!(player instanceof HTMLElement)) return { kind: "incomplete", reason: "no-player" };
|
||||
let manifest: ReturnType<typeof parseSlideshowManifest>;
|
||||
try {
|
||||
manifest = parseSlideshowManifest(root.innerHTML);
|
||||
} catch {
|
||||
return { kind: "incomplete", reason: "malformed-island" };
|
||||
}
|
||||
if (!manifest) return { kind: "incomplete", reason: "no-island" };
|
||||
return { kind: "ready", player, manifest };
|
||||
}
|
||||
|
||||
/** Parser-inserted children are all present once the document leaves `loading`. */
|
||||
export function childrenMayStillArrive(readyState: DocumentReadyState): boolean {
|
||||
return readyState === "loading";
|
||||
}
|
||||
|
||||
const PRESENTER_NOTES_STORAGE_PREFIX = "hf-slideshow:presenter-notes:v1:";
|
||||
|
||||
// Injected once per document to avoid duplicating @keyframes across multiple elements.
|
||||
@@ -196,6 +220,7 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
private presenterPositionTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
private disconnected = false;
|
||||
private initTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private initRetry: (() => void) | null = null;
|
||||
private initInFlight = false;
|
||||
private initGeneration = 0;
|
||||
private keyForwardFrame: HTMLIFrameElement | null = null;
|
||||
@@ -284,6 +309,10 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
clearTimeout(this.initTimer);
|
||||
this.initTimer = null;
|
||||
}
|
||||
if (this.initRetry !== null) {
|
||||
document.removeEventListener("DOMContentLoaded", this.initRetry);
|
||||
this.initRetry = null;
|
||||
}
|
||||
window.removeEventListener("keydown", this.onKey);
|
||||
this.detachIframeKeys?.();
|
||||
this.removeEventListener("touchstart", this.onTouchStart);
|
||||
@@ -410,18 +439,14 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
const gen = this.initGeneration;
|
||||
|
||||
try {
|
||||
const playerEl = this.querySelector("hyperframes-player");
|
||||
if (!playerEl || !(playerEl instanceof HTMLElement)) return;
|
||||
|
||||
const html = this.innerHTML;
|
||||
let manifest: ReturnType<typeof parseSlideshowManifest>;
|
||||
try {
|
||||
manifest = parseSlideshowManifest(html);
|
||||
} catch {
|
||||
// Malformed island (e.g. bad JSON) — fail gracefully, no chrome.
|
||||
const parts = locateSlideshowParts(this);
|
||||
if (parts.kind === "incomplete") {
|
||||
// Missing or malformed: either an authoring error (no chrome, fail
|
||||
// gracefully) or the parser has not finished appending our children.
|
||||
this.retryInitWhenParsed(gen);
|
||||
return;
|
||||
}
|
||||
if (!manifest) return;
|
||||
const { player: playerEl, manifest } = parts;
|
||||
|
||||
this.renderInitialChrome(manifest);
|
||||
|
||||
@@ -495,6 +520,25 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The macrotask in connectedCallback usually lets the parser append this
|
||||
* element's children before init runs, but with the bundle loaded from
|
||||
* <head> the timer can still fire while the parser is inside the element
|
||||
* (seen in headless Chromium), so init finds no player, or an island that is
|
||||
* only half streamed in, and would give up for good. While the document is
|
||||
* still loading, retry once at DOMContentLoaded: every parser-inserted child
|
||||
* exists by then. Once parsing is over there is nothing to wait for.
|
||||
*/
|
||||
private retryInitWhenParsed(gen: number): void {
|
||||
if (this.initRetry !== null || !childrenMayStillArrive(document.readyState)) return;
|
||||
const retry = () => {
|
||||
this.initRetry = null;
|
||||
if (gen === this.initGeneration && this.isConnected && !this.disconnected) void this.init();
|
||||
};
|
||||
this.initRetry = retry;
|
||||
document.addEventListener("DOMContentLoaded", retry, { once: true });
|
||||
}
|
||||
|
||||
private renderInitialChrome(manifest: SlideshowManifest): void {
|
||||
if (this.controller || manifest.slides.length === 0) return;
|
||||
const counter = { index: 1, total: manifest.slides.length };
|
||||
|
||||
Reference in New Issue
Block a user