mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
perf(player): share PLAYER_STYLES via adoptedStyleSheets (#394)
## Summary Replace per-instance `<style>` injection in `<hyperframes-player>` with a lazily constructed `CSSStyleSheet` adopted via `shadowRoot.adoptedStyleSheets`. One parsed stylesheet, many adopters — the studio thumbnail grid renders dozens of players concurrently and was paying for N parses of the same CSS. ## Why Step `P1-1` of the player perf proposal. The previous implementation appended a `<style>` element to every shadow root, which means: - N shadow roots → N copies of the same CSS string parsed into N independent style sheets. - Each `<style>` lives in the DOM and contributes to layout/style invalidation work when its shadow root churns. - The studio's project grid mounts ~30 players on initial load — that's 30 redundant parses of the same ~1 KB stylesheet on the critical path. `adoptedStyleSheets` flips this: parse once at module load, hand the same `CSSStyleSheet` reference to every shadow root. ## What changed - New `getSharedPlayerStyleSheet()` in `packages/player/src/styles.ts` — module-scoped and memoized; the sheet is built once per process and returned to every adopter. - New `applyPlayerStyles(shadow)` is the single integration point. It **appends** (never replaces) the shared sheet so any pre-adopted sheets — host themes, scoped overrides, future caller-side injections — survive intact, and is idempotent so repeated calls don't multiply adoptions. - SSR-safe via a `typeof CSSStyleSheet` guard. Failures (e.g. `replaceSync` throw, no constructor) are cached as `null` so we don't retry constructor failures forever. - Defensive fallback path creates a per-instance `<style>` element when `adoptedStyleSheets` is unavailable (older runtimes, hostile environments). Behavior on those paths is unchanged from before. - `PLAYER_STYLES`, `PLAY_ICON`, and `PAUSE_ICON` exports preserved — no public API change. ## Test plan - [x] Unit tests in `styles.test.ts` cover sharing across instances, fallback when `CSSStyleSheet` is undefined or `replaceSync` throws, fallback when `adoptedStyleSheets` is unsupported on the shadow root, idempotency, and preservation of pre-existing adopted sheets. - [x] Integration test in `hyperframes-player.test.ts` confirms two real `<hyperframes-player>` elements adopt the same `CSSStyleSheet` instance and inject zero `<style>` elements. - [x] Build size delta is negligible (utility code replaces `container.appendChild` calls). ## Stack Step `P1-1` of the player perf proposal. Followed by `P1-2` (scoping the media `MutationObserver`) and `P1-4` (coalescing parent media-time mirror writes) — all three target the studio multi-player render path.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,21 @@ import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./control
|
||||
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
|
||||
import { PLAYER_STYLES } from "./styles.js";
|
||||
|
||||
let sharedSheet: CSSStyleSheet | null = null;
|
||||
|
||||
function getSharedSheet(): CSSStyleSheet | null {
|
||||
if (sharedSheet) return sharedSheet;
|
||||
if (typeof CSSStyleSheet === "undefined") return null;
|
||||
try {
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(PLAYER_STYLES);
|
||||
sharedSheet = sheet;
|
||||
return sheet;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_FPS = 30;
|
||||
const RUNTIME_CDN_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
@@ -85,9 +100,14 @@ class HyperframesPlayer extends HTMLElement {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = PLAYER_STYLES;
|
||||
this.shadow.appendChild(style);
|
||||
const sheet = getSharedSheet();
|
||||
if (sheet) {
|
||||
this.shadow.adoptedStyleSheets = [sheet];
|
||||
} else {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = PLAYER_STYLES;
|
||||
this.shadow.appendChild(style);
|
||||
}
|
||||
|
||||
this.container = document.createElement("div");
|
||||
this.container.className = "hfp-container";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user