Skip to content
Open
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
40 changes: 40 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,43 @@ describe("HyperframesPlayer parent-frame media", () => {
expect(mockAudio.muted).toBe(false);
});
});

// ── Shared stylesheet (adoptedStyleSheets) ──
//
// Every player constructed in the same document should adopt the *same*
// CSSStyleSheet instance instead of getting its own <style> element. This is
// the studio thumbnail-grid win — N players, one parsed sheet.

describe("HyperframesPlayer adoptedStyleSheets", () => {
type AdoptingShadowRoot = ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
type PlayerWithShadow = HTMLElement & { shadowRoot: AdoptingShadowRoot | null };

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

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

it("shares a single CSSStyleSheet across multiple player instances", () => {
const a = document.createElement("hyperframes-player") as PlayerWithShadow;
const b = document.createElement("hyperframes-player") as PlayerWithShadow;
document.body.appendChild(a);
document.body.appendChild(b);

const sheetsA = a.shadowRoot?.adoptedStyleSheets ?? [];
const sheetsB = b.shadowRoot?.adoptedStyleSheets ?? [];

expect(sheetsA.length).toBeGreaterThan(0);
expect(sheetsB.length).toBeGreaterThan(0);
expect(sheetsA.at(-1)).toBe(sheetsB.at(-1));
});

it("does not inject a per-instance <style> when adoption succeeds", () => {
const player = document.createElement("hyperframes-player") as PlayerWithShadow;
document.body.appendChild(player);

expect(player.shadowRoot?.querySelector("style")).toBeNull();
});
});
6 changes: 2 additions & 4 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./controls.js";
import { PLAYER_STYLES } from "./styles.js";
import { applyPlayerStyles } from "./styles.js";

const DEFAULT_FPS = 30;
const RUNTIME_CDN_URL =
Expand Down Expand Up @@ -84,9 +84,7 @@ class HyperframesPlayer extends HTMLElement {
super();
this.shadow = this.attachShadow({ mode: "open" });

const style = document.createElement("style");
style.textContent = PLAYER_STYLES;
this.shadow.appendChild(style);
applyPlayerStyles(this.shadow);

this.container = document.createElement("div");
this.container.className = "hfp-container";
Expand Down
154 changes: 154 additions & 0 deletions packages/player/src/styles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetSharedPlayerStyleSheet,
applyPlayerStyles,
getSharedPlayerStyleSheet,
PLAYER_STYLES,
} from "./styles.js";

type AdoptingShadowRoot = ShadowRoot & {
adoptedStyleSheets: CSSStyleSheet[];
};

function createShadowHost(): AdoptingShadowRoot {
const host = document.createElement("div");
document.body.appendChild(host);
return host.attachShadow({ mode: "open" }) as AdoptingShadowRoot;
}

describe("getSharedPlayerStyleSheet", () => {
beforeEach(() => {
_resetSharedPlayerStyleSheet();
});

it("returns the same CSSStyleSheet instance across calls", () => {
const a = getSharedPlayerStyleSheet();
const b = getSharedPlayerStyleSheet();

expect(a).not.toBeNull();
expect(a).toBe(b);
});

it("returns null and memoizes the failure when CSSStyleSheet is unavailable", () => {
const original = globalThis.CSSStyleSheet;
(globalThis as { CSSStyleSheet?: unknown }).CSSStyleSheet = undefined;

try {
expect(getSharedPlayerStyleSheet()).toBeNull();
expect(getSharedPlayerStyleSheet()).toBeNull();
} finally {
globalThis.CSSStyleSheet = original;
}
});
});

describe("applyPlayerStyles", () => {
beforeEach(() => {
_resetSharedPlayerStyleSheet();
});

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

it("adopts the shared sheet on a fresh shadow root and adds no <style> element", () => {
const shadow = createShadowHost();

applyPlayerStyles(shadow);

const sheet = getSharedPlayerStyleSheet();
expect(sheet).not.toBeNull();
expect(shadow.adoptedStyleSheets).toContain(sheet);
expect(shadow.querySelector("style")).toBeNull();
});

it("shares one CSSStyleSheet across multiple shadow roots", () => {
const shadowA = createShadowHost();
const shadowB = createShadowHost();

applyPlayerStyles(shadowA);
applyPlayerStyles(shadowB);

const adoptedA = shadowA.adoptedStyleSheets.at(-1);
const adoptedB = shadowB.adoptedStyleSheets.at(-1);

expect(adoptedA).toBeDefined();
expect(adoptedA).toBe(adoptedB);
});

it("preserves any pre-existing adopted stylesheets", () => {
const shadow = createShadowHost();
const existing = new CSSStyleSheet();
existing.replaceSync(":host { --pre: 1; }");
shadow.adoptedStyleSheets = [existing];

applyPlayerStyles(shadow);

expect(shadow.adoptedStyleSheets[0]).toBe(existing);
expect(shadow.adoptedStyleSheets).toContain(getSharedPlayerStyleSheet());
expect(shadow.adoptedStyleSheets).toHaveLength(2);
});

it("is idempotent when called repeatedly on the same shadow root", () => {
const shadow = createShadowHost();

applyPlayerStyles(shadow);
applyPlayerStyles(shadow);
applyPlayerStyles(shadow);

expect(shadow.adoptedStyleSheets).toHaveLength(1);
expect(shadow.querySelectorAll("style")).toHaveLength(0);
});

it("falls back to a <style> element when adoptedStyleSheets is unsupported", () => {
const shadow = createShadowHost();
Object.defineProperty(shadow, "adoptedStyleSheets", {
configurable: true,
get() {
return undefined;
},
set() {
throw new Error("adoptedStyleSheets is not supported in this environment");
},
});

applyPlayerStyles(shadow);

const styleEl = shadow.querySelector("style");
expect(styleEl).not.toBeNull();
expect(styleEl?.textContent).toBe(PLAYER_STYLES);
});

it("falls back to a <style> element when CSSStyleSheet is unavailable", () => {
const original = globalThis.CSSStyleSheet;
(globalThis as { CSSStyleSheet?: unknown }).CSSStyleSheet = undefined;

try {
const shadow = createShadowHost();
applyPlayerStyles(shadow);

const styleEl = shadow.querySelector("style");
expect(styleEl).not.toBeNull();
expect(styleEl?.textContent).toBe(PLAYER_STYLES);
} finally {
globalThis.CSSStyleSheet = original;
}
});

it("falls back to a <style> element when replaceSync throws", () => {
const replaceSyncSpy = vi
.spyOn(CSSStyleSheet.prototype, "replaceSync")
.mockImplementation(() => {
throw new Error("simulated replaceSync failure");
});

try {
const shadow = createShadowHost();
applyPlayerStyles(shadow);

expect(shadow.querySelector("style")?.textContent).toBe(PLAYER_STYLES);
} finally {
replaceSyncSpy.mockRestore();
}
});
});
77 changes: 77 additions & 0 deletions packages/player/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,80 @@ export const PLAYER_STYLES = /* css */ `

export const PLAY_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><polygon points="4,2 16,9 4,16"/></svg>`;
export const PAUSE_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><rect x="3" y="2" width="4" height="14"/><rect x="11" y="2" width="4" height="14"/></svg>`;

/**
* Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the
* module stays SSR-safe (CSSStyleSheet is window-scoped) and so a single
* sheet can be shared across every shadow root via `adoptedStyleSheets` —
* the studio thumbnail grid renders dozens of players, and avoiding N
* duplicate `<style>` parses + style-recalc invalidations is the win here.
*
* `null` after a failed construction attempt = "fall back forever in this
* process" (the usual cause is a missing constructor in older runtimes;
* retrying every call would just throw the same way).
*/
let sharedSheet: CSSStyleSheet | null | undefined;

/**
* Returns the shared player stylesheet, or `null` if constructable
* stylesheets aren't available in this environment.
*
* The result is memoized for the life of the module — every shadow root
* adopts the same `CSSStyleSheet` instance.
*/
export function getSharedPlayerStyleSheet(): CSSStyleSheet | null {
if (sharedSheet !== undefined) return sharedSheet;

if (typeof CSSStyleSheet === "undefined") {
sharedSheet = null;
return null;
}

try {
const sheet = new CSSStyleSheet();
sheet.replaceSync(PLAYER_STYLES);
sharedSheet = sheet;
return sheet;
} catch {
sharedSheet = null;
return null;
}
}

/**
* Internal hook for tests to clear the memoized sheet. Not part of the
* public API.
*/
export function _resetSharedPlayerStyleSheet(): void {
sharedSheet = undefined;
}

/**
* Install PLAYER_STYLES into a player shadow root. Prefers the shared
* constructable stylesheet (one parse, one rule tree, N adopters) and
* falls back to a per-instance `<style>` element when the host runtime
* lacks `adoptedStyleSheets` support.
*
* Idempotent: re-applying to a root that already adopts the shared sheet
* is a no-op. Pre-existing adopted sheets are preserved (we append, never
* replace), so callers further up the chain can keep their styles.
*/
export function applyPlayerStyles(shadow: ShadowRoot): void {
const sheet = getSharedPlayerStyleSheet();
const adopted = (shadow as ShadowRoot & { adoptedStyleSheets?: CSSStyleSheet[] })
.adoptedStyleSheets;

if (sheet && Array.isArray(adopted)) {
if (!adopted.includes(sheet)) {
(shadow as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] }).adoptedStyleSheets = [
...adopted,
sheet,
];
}
return;
}

const style = document.createElement("style");
style.textContent = PLAYER_STYLES;
shadow.appendChild(style);
}
Loading