fix(studio): SSR-safe player load, captions import cleanup (#248)

* fix(studio): load @hyperframes/player lazily to support SSR

Player.tsx had a bare `import "@hyperframes/player"` at module scope. The
player package registers a class that extends HTMLElement as a side effect,
and HTMLElement doesn't exist in a Node server runtime. Any consumer that
imported from @hyperframes/studio during server-side rendering (e.g. the
Next.js App Router evaluating a client component for SSR) threw
`HTMLElement is not defined`.

Move the import inside the mount effect via dynamic `import(...)` so it
only runs in the browser, and wire up a cancellation flag and deferred
cleanup so a fast unmount doesn't leak listeners or DOM nodes.

* fix(studio): remove .js extensions from captions-internal imports

The captions module imported sibling files as `./types.js` and
`./parser.js`. That's legal ESM TypeScript, but Turbopack (and other
bundlers) refuse to resolve those specifiers against .ts files when the
package is consumed from node_modules — the rest of @hyperframes/studio
uses extensionless imports for that reason.

Align captions with the rest of the codebase so the package builds
without bundler-specific configuration in consumers.

* chore: release @hyperframes/player@0.2.7 and @hyperframes/studio@0.2.9

Ships the root-timeline resolution fix (#247), the SSR-safe player load,
and the captions import cleanup.
This commit is contained in:
Miguel Ángel
2026-04-14 00:15:16 +02:00
committed by GitHub
parent f40447f2e8
commit bf0d698858
7 changed files with 67 additions and 52 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/player",
"version": "0.2.6",
"version": "0.2.7",
"description": "Embeddable web component for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
"version": "0.2.5",
"version": "0.2.9",
"description": "",
"repository": {
"type": "git",
@@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { generateCaptionHtml } from "./generator.js";
import { buildCaptionModel, TranscriptWord } from "./parser.js";
import { generateCaptionHtml } from "./generator";
import { buildCaptionModel, TranscriptWord } from "./parser";
// ---------------------------------------------------------------------------
// Fixtures
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
CaptionContainerStyle,
CaptionShadow,
CaptionGlow,
} from "./types.js";
} from "./types";
// ---------------------------------------------------------------------------
// Public API
+2 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser.js";
import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types.js";
import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser";
import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types";
// ---------------------------------------------------------------------------
// Fixtures
+1 -1
View File
@@ -11,7 +11,7 @@ import {
DEFAULT_STYLE,
DEFAULT_CONTAINER,
DEFAULT_ANIMATION_SET,
} from "./types.js";
} from "./types";
export interface TranscriptWord {
id?: string;
@@ -1,7 +1,9 @@
import { forwardRef, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import type { HyperframesPlayer } from "@hyperframes/player";
import "@hyperframes/player"; // registers <hyperframes-player> custom element
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
// at module load, which throws under SSR. Defer the import to the mount effect
// so it only runs in the browser.
interface PlayerProps {
projectId?: string;
@@ -27,55 +29,68 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const container = containerRef.current;
if (!container) return;
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayer;
const src = directUrl || `/api/projects/${projectId}/preview`;
player.setAttribute("src", src);
player.setAttribute("width", String(portrait ? 1080 : 1920));
player.setAttribute("height", String(portrait ? 1920 : 1080));
player.style.width = "100%";
player.style.height = "100%";
player.style.display = "block";
container.appendChild(player);
let canceled = false;
let cleanup: (() => void) | undefined;
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
const iframe = player.iframeElement;
if (typeof ref === "function") {
ref(iframe);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
}
// Dynamic import registers the custom element in the browser only.
import("@hyperframes/player").then(() => {
if (canceled) return;
// Prevent the web component's built-in click-to-toggle behavior.
// The studio manages playback exclusively via useTimelinePlayer.
const preventToggle = (e: Event) => e.stopImmediatePropagation();
player.addEventListener("click", preventToggle, { capture: true });
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayer;
const src = directUrl || `/api/projects/${projectId}/preview`;
player.setAttribute("src", src);
player.setAttribute("width", String(portrait ? 1080 : 1920));
player.setAttribute("height", String(portrait ? 1920 : 1080));
player.style.width = "100%";
player.style.height = "100%";
player.style.display = "block";
container.appendChild(player);
// Forward the iframe's native load event to the studio's onIframeLoad.
const handleLoad = () => {
loadCountRef.current++;
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
void container.offsetWidth;
container.classList.add("preview-revealing");
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
const iframe = player.iframeElement;
if (typeof ref === "function") {
ref(iframe);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
}
onLoad();
};
iframe.addEventListener("load", handleLoad);
// Prevent the web component's built-in click-to-toggle behavior.
// The studio manages playback exclusively via useTimelinePlayer.
const preventToggle = (e: Event) => e.stopImmediatePropagation();
player.addEventListener("click", preventToggle, { capture: true });
// Forward the iframe's native load event to the studio's onIframeLoad.
const handleLoad = () => {
loadCountRef.current++;
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
void container.offsetWidth;
container.classList.add("preview-revealing");
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
}
onLoad();
};
iframe.addEventListener("load", handleLoad);
cleanup = () => {
iframe.removeEventListener("load", handleLoad);
player.removeEventListener("click", preventToggle, { capture: true });
container.removeChild(player);
// Clear the forwarded ref
if (typeof ref === "function") {
ref(null);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = null;
}
};
});
return () => {
iframe.removeEventListener("load", handleLoad);
player.removeEventListener("click", preventToggle, { capture: true });
container.removeChild(player);
// Clear the forwarded ref
if (typeof ref === "function") {
ref(null);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = null;
}
canceled = true;
cleanup?.();
};
});