From dc9fb673203dda0a2c0951f3b698cf8cd0ea8d61 Mon Sep 17 00:00:00 2001
From: blw <59554283+BuddhiLW@users.noreply.github.com>
Date: Mon, 7 Sep 2026 17:03:16 -0300
Subject: [PATCH] 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
, as
`hyperframes present` emits it, headless Chromium fires that timer while
the parser is still inside : 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.
---
.../slideshow/hyperframes-slideshow.test.ts | 167 +++++++++++++++++-
.../src/slideshow/hyperframes-slideshow.ts | 64 +++++--
2 files changed, 220 insertions(+), 11 deletions(-)
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 };