mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## 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.
155 lines
4.4 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|