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:
Vance Ingalls
2026-04-22 15:40:33 -07:00
committed by GitHub
parent f9863ab565
commit ed62894d01
4 changed files with 294 additions and 3 deletions
@@ -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();
});
});
+23 -3
View File
@@ -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";
+154
View File
@@ -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
View File
@@ -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);
}