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
+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);
}