perf(player): share PLAYER_STYLES via adoptedStyleSheets#394
Conversation
Replaces per-instance `<style>` injection in HyperframesPlayer with a lazily constructed CSSStyleSheet adopted into every shadow root via `shadowRoot.adoptedStyleSheets`. The studio thumbnail grid renders dozens of players concurrently — sharing one parsed stylesheet across N players (one rule tree, one parse, N adopters) cuts per-instance style work that the old <style>-per-shadow-root path imposed. Key behavior: - `getSharedPlayerStyleSheet()` is module-scoped and memoized; the sheet is built once per process and returned to every adopter. SSR- safe via a `typeof CSSStyleSheet` guard, with failures cached as `null` to avoid retrying constructor failures forever. - `applyPlayerStyles(shadow)` is the single integration point for the player. 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. - A defensive fallback path creates a per-instance `<style>` element when adoptedStyleSheets is unavailable (older runtimes, hostile environments, or replaceSync failure). Behavior on those paths is unchanged from the previous implementation. Tests 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. Includes an integration test in the player spec confirming two real `<hyperframes-player>` elements adopt the same CSSStyleSheet instance and inject no `<style>` element. No public API change. PLAYER_STYLES, PLAY_ICON, and PAUSE_ICON exports are preserved. Build size delta is negligible (utility code replaces container.appendChild calls).
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
jrusso1020
left a comment
There was a problem hiding this comment.
Good perf win. Constructable stylesheet shared across every shadow root, memoized, with a proper <style> fallback when CSSStyleSheet is missing, replaceSync throws, or adoptedStyleSheets returns non-array. Test matrix covers each fallback path individually — exactly the right way to exercise this.
Idempotency test (applyPlayerStyles called three times → one adopted sheet, zero <style> elements) is what pinned my confidence here. The preservation of pre-existing adoptedStyleSheets is a nice touch for hosts that stack their own sheets on top.
Two non-blocking observations:
-
The test "falls back to a <style> element when adoptedStyleSheets is unsupported" uses
Object.definePropertyto make the setter throw, but the production code (if (sheet && Array.isArray(adopted))) only reads the getter. In the tested scenario the getter returnsundefined, soArray.isArray(undefined) === false, which takes the fallback path. That's fine — but an environment where the getter returns an array and the setter throws would skip the guard. Probably not real-world, just noting. -
sharedSheetmemoization key is process-scoped. In a multi-document test environment (some test runners reuse modules across multiple documents), a stale CSSStyleSheet from a prior document could be handed to a new one._resetSharedPlayerStyleSheetis the escape hatch — good that it's exported.
Approved.
— Rames Jusso

Summary
Replace per-instance
<style>injection in<hyperframes-player>with a lazily constructedCSSStyleSheetadopted viashadowRoot.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-1of the player perf proposal. The previous implementation appended a<style>element to every shadow root, which means:<style>lives in the DOM and contributes to layout/style invalidation work when its shadow root churns.adoptedStyleSheetsflips this: parse once at module load, hand the sameCSSStyleSheetreference to every shadow root.What changed
getSharedPlayerStyleSheet()inpackages/player/src/styles.ts— module-scoped and memoized; the sheet is built once per process and returned to every adopter.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.typeof CSSStyleSheetguard. Failures (e.g.replaceSyncthrow, no constructor) are cached asnullso we don't retry constructor failures forever.<style>element whenadoptedStyleSheetsis unavailable (older runtimes, hostile environments). Behavior on those paths is unchanged from before.PLAYER_STYLES,PLAY_ICON, andPAUSE_ICONexports preserved — no public API change.Test plan
styles.test.tscover sharing across instances, fallback whenCSSStyleSheetis undefined orreplaceSyncthrows, fallback whenadoptedStyleSheetsis unsupported on the shadow root, idempotency, and preservation of pre-existing adopted sheets.hyperframes-player.test.tsconfirms two real<hyperframes-player>elements adopt the sameCSSStyleSheetinstance and inject zero<style>elements.container.appendChildcalls).Stack
Step
P1-1of the player perf proposal. Followed byP1-2(scoping the mediaMutationObserver) andP1-4(coalescing parent media-time mirror writes) — all three target the studio multi-player render path.