Skip to content

Commit ed62894

Browse files
authored
perf(player): share PLAYER_STYLES via adoptedStyleSheets (heygen-com#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.
1 parent f9863ab commit ed62894

4 files changed

Lines changed: 294 additions & 3 deletions

File tree

packages/player/src/hyperframes-player.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,43 @@ describe("HyperframesPlayer parent-frame media", () => {
322322
expect(mockAudio.muted).toBe(false);
323323
});
324324
});
325+
326+
// ── Shared stylesheet (adoptedStyleSheets) ──
327+
//
328+
// Every player constructed in the same document should adopt the *same*
329+
// CSSStyleSheet instance instead of getting its own <style> element. This is
330+
// the studio thumbnail-grid win — N players, one parsed sheet.
331+
332+
describe("HyperframesPlayer adoptedStyleSheets", () => {
333+
type AdoptingShadowRoot = ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
334+
type PlayerWithShadow = HTMLElement & { shadowRoot: AdoptingShadowRoot | null };
335+
336+
beforeEach(async () => {
337+
await import("./hyperframes-player.js");
338+
});
339+
340+
afterEach(() => {
341+
document.body.innerHTML = "";
342+
});
343+
344+
it("shares a single CSSStyleSheet across multiple player instances", () => {
345+
const a = document.createElement("hyperframes-player") as PlayerWithShadow;
346+
const b = document.createElement("hyperframes-player") as PlayerWithShadow;
347+
document.body.appendChild(a);
348+
document.body.appendChild(b);
349+
350+
const sheetsA = a.shadowRoot?.adoptedStyleSheets ?? [];
351+
const sheetsB = b.shadowRoot?.adoptedStyleSheets ?? [];
352+
353+
expect(sheetsA.length).toBeGreaterThan(0);
354+
expect(sheetsB.length).toBeGreaterThan(0);
355+
expect(sheetsA.at(-1)).toBe(sheetsB.at(-1));
356+
});
357+
358+
it("does not inject a per-instance <style> when adoption succeeds", () => {
359+
const player = document.createElement("hyperframes-player") as PlayerWithShadow;
360+
document.body.appendChild(player);
361+
362+
expect(player.shadowRoot?.querySelector("style")).toBeNull();
363+
});
364+
});

packages/player/src/hyperframes-player.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./control
22
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
33
import { PLAYER_STYLES } from "./styles.js";
44

5+
let sharedSheet: CSSStyleSheet | null = null;
6+
7+
function getSharedSheet(): CSSStyleSheet | null {
8+
if (sharedSheet) return sharedSheet;
9+
if (typeof CSSStyleSheet === "undefined") return null;
10+
try {
11+
const sheet = new CSSStyleSheet();
12+
sheet.replaceSync(PLAYER_STYLES);
13+
sharedSheet = sheet;
14+
return sheet;
15+
} catch {
16+
return null;
17+
}
18+
}
19+
520
const DEFAULT_FPS = 30;
621
const RUNTIME_CDN_URL =
722
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
@@ -85,9 +100,14 @@ class HyperframesPlayer extends HTMLElement {
85100
super();
86101
this.shadow = this.attachShadow({ mode: "open" });
87102

88-
const style = document.createElement("style");
89-
style.textContent = PLAYER_STYLES;
90-
this.shadow.appendChild(style);
103+
const sheet = getSharedSheet();
104+
if (sheet) {
105+
this.shadow.adoptedStyleSheets = [sheet];
106+
} else {
107+
const style = document.createElement("style");
108+
style.textContent = PLAYER_STYLES;
109+
this.shadow.appendChild(style);
110+
}
91111

92112
this.container = document.createElement("div");
93113
this.container.className = "hfp-container";

packages/player/src/styles.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
_resetSharedPlayerStyleSheet,
4+
applyPlayerStyles,
5+
getSharedPlayerStyleSheet,
6+
PLAYER_STYLES,
7+
} from "./styles.js";
8+
9+
type AdoptingShadowRoot = ShadowRoot & {
10+
adoptedStyleSheets: CSSStyleSheet[];
11+
};
12+
13+
function createShadowHost(): AdoptingShadowRoot {
14+
const host = document.createElement("div");
15+
document.body.appendChild(host);
16+
return host.attachShadow({ mode: "open" }) as AdoptingShadowRoot;
17+
}
18+
19+
describe("getSharedPlayerStyleSheet", () => {
20+
beforeEach(() => {
21+
_resetSharedPlayerStyleSheet();
22+
});
23+
24+
it("returns the same CSSStyleSheet instance across calls", () => {
25+
const a = getSharedPlayerStyleSheet();
26+
const b = getSharedPlayerStyleSheet();
27+
28+
expect(a).not.toBeNull();
29+
expect(a).toBe(b);
30+
});
31+
32+
it("returns null and memoizes the failure when CSSStyleSheet is unavailable", () => {
33+
const original = globalThis.CSSStyleSheet;
34+
(globalThis as { CSSStyleSheet?: unknown }).CSSStyleSheet = undefined;
35+
36+
try {
37+
expect(getSharedPlayerStyleSheet()).toBeNull();
38+
expect(getSharedPlayerStyleSheet()).toBeNull();
39+
} finally {
40+
globalThis.CSSStyleSheet = original;
41+
}
42+
});
43+
});
44+
45+
describe("applyPlayerStyles", () => {
46+
beforeEach(() => {
47+
_resetSharedPlayerStyleSheet();
48+
});
49+
50+
afterEach(() => {
51+
document.body.innerHTML = "";
52+
});
53+
54+
it("adopts the shared sheet on a fresh shadow root and adds no <style> element", () => {
55+
const shadow = createShadowHost();
56+
57+
applyPlayerStyles(shadow);
58+
59+
const sheet = getSharedPlayerStyleSheet();
60+
expect(sheet).not.toBeNull();
61+
expect(shadow.adoptedStyleSheets).toContain(sheet);
62+
expect(shadow.querySelector("style")).toBeNull();
63+
});
64+
65+
it("shares one CSSStyleSheet across multiple shadow roots", () => {
66+
const shadowA = createShadowHost();
67+
const shadowB = createShadowHost();
68+
69+
applyPlayerStyles(shadowA);
70+
applyPlayerStyles(shadowB);
71+
72+
const adoptedA = shadowA.adoptedStyleSheets.at(-1);
73+
const adoptedB = shadowB.adoptedStyleSheets.at(-1);
74+
75+
expect(adoptedA).toBeDefined();
76+
expect(adoptedA).toBe(adoptedB);
77+
});
78+
79+
it("preserves any pre-existing adopted stylesheets", () => {
80+
const shadow = createShadowHost();
81+
const existing = new CSSStyleSheet();
82+
existing.replaceSync(":host { --pre: 1; }");
83+
shadow.adoptedStyleSheets = [existing];
84+
85+
applyPlayerStyles(shadow);
86+
87+
expect(shadow.adoptedStyleSheets[0]).toBe(existing);
88+
expect(shadow.adoptedStyleSheets).toContain(getSharedPlayerStyleSheet());
89+
expect(shadow.adoptedStyleSheets).toHaveLength(2);
90+
});
91+
92+
it("is idempotent when called repeatedly on the same shadow root", () => {
93+
const shadow = createShadowHost();
94+
95+
applyPlayerStyles(shadow);
96+
applyPlayerStyles(shadow);
97+
applyPlayerStyles(shadow);
98+
99+
expect(shadow.adoptedStyleSheets).toHaveLength(1);
100+
expect(shadow.querySelectorAll("style")).toHaveLength(0);
101+
});
102+
103+
it("falls back to a <style> element when adoptedStyleSheets is unsupported", () => {
104+
const shadow = createShadowHost();
105+
Object.defineProperty(shadow, "adoptedStyleSheets", {
106+
configurable: true,
107+
get() {
108+
return undefined;
109+
},
110+
set() {
111+
throw new Error("adoptedStyleSheets is not supported in this environment");
112+
},
113+
});
114+
115+
applyPlayerStyles(shadow);
116+
117+
const styleEl = shadow.querySelector("style");
118+
expect(styleEl).not.toBeNull();
119+
expect(styleEl?.textContent).toBe(PLAYER_STYLES);
120+
});
121+
122+
it("falls back to a <style> element when CSSStyleSheet is unavailable", () => {
123+
const original = globalThis.CSSStyleSheet;
124+
(globalThis as { CSSStyleSheet?: unknown }).CSSStyleSheet = undefined;
125+
126+
try {
127+
const shadow = createShadowHost();
128+
applyPlayerStyles(shadow);
129+
130+
const styleEl = shadow.querySelector("style");
131+
expect(styleEl).not.toBeNull();
132+
expect(styleEl?.textContent).toBe(PLAYER_STYLES);
133+
} finally {
134+
globalThis.CSSStyleSheet = original;
135+
}
136+
});
137+
138+
it("falls back to a <style> element when replaceSync throws", () => {
139+
const replaceSyncSpy = vi
140+
.spyOn(CSSStyleSheet.prototype, "replaceSync")
141+
.mockImplementation(() => {
142+
throw new Error("simulated replaceSync failure");
143+
});
144+
145+
try {
146+
const shadow = createShadowHost();
147+
applyPlayerStyles(shadow);
148+
149+
expect(shadow.querySelector("style")?.textContent).toBe(PLAYER_STYLES);
150+
} finally {
151+
replaceSyncSpy.mockRestore();
152+
}
153+
});
154+
});

packages/player/src/styles.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,80 @@ export const PLAYER_STYLES = /* css */ `
199199

200200
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>`;
201201
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>`;
202+
203+
/**
204+
* Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the
205+
* module stays SSR-safe (CSSStyleSheet is window-scoped) and so a single
206+
* sheet can be shared across every shadow root via `adoptedStyleSheets` —
207+
* the studio thumbnail grid renders dozens of players, and avoiding N
208+
* duplicate `<style>` parses + style-recalc invalidations is the win here.
209+
*
210+
* `null` after a failed construction attempt = "fall back forever in this
211+
* process" (the usual cause is a missing constructor in older runtimes;
212+
* retrying every call would just throw the same way).
213+
*/
214+
let sharedSheet: CSSStyleSheet | null | undefined;
215+
216+
/**
217+
* Returns the shared player stylesheet, or `null` if constructable
218+
* stylesheets aren't available in this environment.
219+
*
220+
* The result is memoized for the life of the module — every shadow root
221+
* adopts the same `CSSStyleSheet` instance.
222+
*/
223+
export function getSharedPlayerStyleSheet(): CSSStyleSheet | null {
224+
if (sharedSheet !== undefined) return sharedSheet;
225+
226+
if (typeof CSSStyleSheet === "undefined") {
227+
sharedSheet = null;
228+
return null;
229+
}
230+
231+
try {
232+
const sheet = new CSSStyleSheet();
233+
sheet.replaceSync(PLAYER_STYLES);
234+
sharedSheet = sheet;
235+
return sheet;
236+
} catch {
237+
sharedSheet = null;
238+
return null;
239+
}
240+
}
241+
242+
/**
243+
* Internal hook for tests to clear the memoized sheet. Not part of the
244+
* public API.
245+
*/
246+
export function _resetSharedPlayerStyleSheet(): void {
247+
sharedSheet = undefined;
248+
}
249+
250+
/**
251+
* Install PLAYER_STYLES into a player shadow root. Prefers the shared
252+
* constructable stylesheet (one parse, one rule tree, N adopters) and
253+
* falls back to a per-instance `<style>` element when the host runtime
254+
* lacks `adoptedStyleSheets` support.
255+
*
256+
* Idempotent: re-applying to a root that already adopts the shared sheet
257+
* is a no-op. Pre-existing adopted sheets are preserved (we append, never
258+
* replace), so callers further up the chain can keep their styles.
259+
*/
260+
export function applyPlayerStyles(shadow: ShadowRoot): void {
261+
const sheet = getSharedPlayerStyleSheet();
262+
const adopted = (shadow as ShadowRoot & { adoptedStyleSheets?: CSSStyleSheet[] })
263+
.adoptedStyleSheets;
264+
265+
if (sheet && Array.isArray(adopted)) {
266+
if (!adopted.includes(sheet)) {
267+
(shadow as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] }).adoptedStyleSheets = [
268+
...adopted,
269+
sheet,
270+
];
271+
}
272+
return;
273+
}
274+
275+
const style = document.createElement("style");
276+
style.textContent = PLAYER_STYLES;
277+
shadow.appendChild(style);
278+
}

0 commit comments

Comments
 (0)