Skip to content

perf(player): share PLAYER_STYLES via adoptedStyleSheets#394

Open
vanceingalls wants to merge 1 commit intoperf/x-1-emit-performance-metricfrom
perf/p1-1-share-player-styles-via-adopted-stylesheets
Open

perf(player): share PLAYER_STYLES via adoptedStyleSheets#394
vanceingalls wants to merge 1 commit intoperf/x-1-emit-performance-metricfrom
perf/p1-1-share-player-styles-via-adopted-stylesheets

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 21, 2026

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

  • 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.
  • Integration test in hyperframes-player.test.ts confirms two real <hyperframes-player> elements adopt the same CSSStyleSheet instance and inject zero <style> elements.
  • 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.

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).
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The test "falls back to a <style> element when adoptedStyleSheets is unsupported" uses Object.defineProperty to make the setter throw, but the production code (if (sheet && Array.isArray(adopted))) only reads the getter. In the tested scenario the getter returns undefined, so Array.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.

  2. sharedSheet memoization 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. _resetSharedPlayerStyleSheet is the escape hatch — good that it's exported.

Approved.

Rames Jusso

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants