diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index 3d40714176..2c664d3776 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -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(" deferred init (Bug 1)", () => { await vi.advanceTimersByTimeAsync(3000); vi.useRealTimers(); }); + + // The macrotask is not a guarantee: with the bundle loaded from , 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 = ` + + `; + + 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", + ``, + ); + 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 = ``; + + 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(""))).toEqual({ + kind: "incomplete", + reason: "no-island", + }); + expect( + locateSlideshowParts( + subtree( + `` + + ``, + ), + ), + ).toEqual({ kind: "incomplete", reason: "malformed-island" }); + }); + + it("locateSlideshowParts is ready when both the player and the island are there", () => { + const root = subtree(`${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"]); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/player/src/slideshow/hyperframes-slideshow.ts b/packages/player/src/slideshow/hyperframes-slideshow.ts index 8058e69482..6bd034665a 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.ts @@ -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; + 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[] = []; private disconnected = false; private initTimer: ReturnType | 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; - 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 + * 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 };