Files
hyperframes/packages/player/src/styles.test.ts
T
Vance Ingalls ed62894d01 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.
2026-04-22 15:40:33 -07:00

155 lines
4.4 KiB
TypeScript

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