Skip to content

perf(player): share PLAYER_STYLES via adoptedStyleSheets#394

Merged
vanceingalls merged 1 commit intomainfrom
perf/p1-1-share-player-styles-via-adopted-stylesheets
Apr 22, 2026
Merged

perf(player): share PLAYER_STYLES via adoptedStyleSheets#394
vanceingalls merged 1 commit intomainfrom
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.

Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 21, 2026

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

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

@jrusso1020 — thanks for the review. Two non-blocking observations to address:

the test "falls back to a <style> element when adoptedStyleSheets is unsupported" uses Object.defineProperty to make the setter throw, but the production code only reads the getter

You're right — the test exercises the case where the getter returns undefined, which is what production drops on (via Array.isArray(undefined) === false). The "getter returns array, setter throws" branch isn't covered by a test. I left it as-is for now because no real environment exhibits that combination — every browser that supports adoptedStyleSheets enforces both halves of the descriptor together — but happy to add a defensive try/catch around the assignment if you'd prefer the belt-and-braces version.

sharedSheet memoization key is process-scoped. In a multi-document test environment ... _resetSharedPlayerStyleSheet is the escape hatch — good that it's exported.

Right call out. The escape hatch is intentionally exported for exactly this reason; the runtime path doesn't need a per-document keying because every shadow root in a single document shares the same CSSStyleSheet constructor. Adding a WeakMap<Document, CSSStyleSheet> would catch the cross-document case but penalize the common path, so I'd rather keep _resetSharedPlayerStyleSheet as the documented seam and let test infra call it explicitly.

Nothing else outstanding.

@vanceingalls vanceingalls changed the base branch from perf/x-1-emit-performance-metric to graphite-base/394 April 22, 2026 22:08
@graphite-app graphite-app Bot changed the base branch from graphite-base/394 to main April 22, 2026 22:09
@graphite-app
Copy link
Copy Markdown

graphite-app Bot commented Apr 22, 2026

Merge activity

  • Apr 22, 10:09 PM UTC: This pull request can not be added to the Graphite merge queue. Please try rebasing and resubmitting to merge when ready.
  • Apr 22, 10:09 PM UTC: Graphite disabled "merge when ready" on this PR due to: a merge conflict with the target branch; resolve the conflict and try again..
  • Apr 22, 10:40 PM UTC: @vanceingalls merged this pull request with Graphite.

@vanceingalls vanceingalls force-pushed the perf/p1-1-share-player-styles-via-adopted-stylesheets branch from c5deea7 to 7f09109 Compare April 22, 2026 22:20
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).
@vanceingalls vanceingalls force-pushed the perf/p1-1-share-player-styles-via-adopted-stylesheets branch from 7f09109 to 317424b Compare April 22, 2026 22:36
@vanceingalls vanceingalls merged commit ed62894 into main Apr 22, 2026
21 checks passed
@vanceingalls vanceingalls mentioned this pull request Apr 22, 2026
3 tasks
vanceingalls added a commit that referenced this pull request Apr 23, 2026
## Summary

Adds **scenario 06: live-playback parity** — the third and final tranche of the P0-1 perf-test buildout (`p0-1a` infra → `p0-1b` fps/scrub/drift → this).

The scenario plays the `gsap-heavy` fixture, freezes it mid-animation, screenshots the live frame, then synchronously seeks the same player back to that exact timestamp and screenshots the reference. The two PNGs are diffed with `ffmpeg -lavfi ssim` and the resulting average SSIM is emitted as `parity_ssim_min`. Baseline gate: **SSIM ≥ 0.95**.

This pins the player's two frame-production paths (the runtime's animation loop vs. `_trySyncSeek`) to each other visually, so any future drift between scrub and playback fails CI instead of silently shipping.

## Motivation

`<hyperframes-player>` produces frames two different ways:

1. **Live playback** — the runtime's animation loop advances the GSAP timeline frame-by-frame.
2. **Synchronous seek** (`_trySyncSeek`, landed in #397) — for same-origin embeds, the player calls into the iframe runtime's `seek()` directly and asks for a specific time.

These paths must agree. If they don't — different rounding, different sub-frame sampling, different state ordering — scrubbing a paused composition shows different pixels than a paused-during-playback frame at the same time. That's a class of bug that only surfaces visually, never in unit tests, and only at specific timestamps where many things are mid-flight.

`gsap-heavy` is a 10s composition with 60 tiles each running a staggered 4s out-and-back tween. At t=5.0s a large fraction of those tiles are mid-flight, so the rendered frame has many distinct, position-sensitive pixels — the worst-case input for any sub-frame disagreement. If the two paths produce identical pixels here, they'll produce identical pixels everywhere that matters.

## What changed

- **`packages/player/tests/perf/scenarios/06-parity.ts`** — new scenario (~340 lines). Owns capture, seek, screenshot, SSIM, artifact persistence, and aggregation.
- **`packages/player/tests/perf/index.ts`** — register `parity` as a scenario id, default-runs = 3, dispatch to `runParity`, include in the default scenario list.
- **`packages/player/tests/perf/perf-gate.ts`** — extend `PerfBaseline` with `paritySsimMin`.
- **`packages/player/tests/perf/baseline.json`** — `paritySsimMin: 0.95`.
- **`.github/workflows/player-perf.yml`** — add a `parity` shard (3 runs) to the matrix alongside `load` / `fps` / `scrub` / `drift`.

## How the scenario works

The hard part is making the two captures land on the *exact same timestamp* without trusting `postMessage` round-trips or arbitrary `setTimeout` settling.

1. **Install an iframe-side rAF watcher** before issuing `play()`. The watcher polls `__player.getTime()` every animation frame and, the first time `getTime() >= 5.0`, calls `__player.pause()` *from inside the same rAF tick*. `pause()` is synchronous (it calls `timeline.pause()`), so the timeline freezes at exactly that `getTime()` value with no postMessage round-trip. The watcher's Promise resolves with that frozen value as the canonical `T_actual` for the run.
2. **Confirm `isPlaying() === true`** via `frame.waitForFunction` before awaiting the watcher. Without this, the test can hang if `play()` hasn't kicked the timeline yet.
3. **Wait for paint** — two `requestAnimationFrame` ticks on the host page. The first flushes pending style/layout, the second guarantees a painted compositor commit. Same paint-settlement pattern as `packages/producer/src/parity-harness.ts`.
4. **Screenshot the live frame** — `page.screenshot({ type: "png" })`.
5. **Synchronously seek to `T_actual`** — call `el.seek(capturedTime)` on the host page. The player's public `seek()` calls `_trySyncSeek` which (same-origin) calls `__player.seek()` synchronously, so no postMessage await is needed. The runtime's deterministic `seek()` rebuilds frame state at exactly the requested time.
6. **Wait for paint** again, screenshot the reference frame.
7. **Diff with ffmpeg** — `ffmpeg -hide_banner -i reference.png -i actual.png -lavfi ssim -f null -`. ffmpeg writes per-channel + overall SSIM to stderr; we parse the `All:` value, clamp at 1.0 (ffmpeg occasionally reports 1.000001 on identical inputs), and treat it as the run's score.
8. **Persist artifacts** under `tests/perf/results/parity/run-N/` (`actual.png`, `reference.png`, `captured-time.txt`) so CI can upload them and so a failed run is locally reproducible. Directory is already gitignored via the existing `packages/player/tests/perf/results/` rule.

### Aggregation

`min()` across runs, **not** mean. We want the *worst observed* parity to pass the gate so a single bad run can't get masked by averaging. Both per-run scores and the aggregate are logged.

### Output metric

| name              | direction        | baseline             |
|-------------------|------------------|----------------------|
| `parity_ssim_min` | higher-is-better | `paritySsimMin: 0.95` |

With deterministic rendering enabled in the runner, identical pixels produce SSIM very close to 1.0; the 0.95 threshold leaves headroom for legitimate fixture-level noise (font hinting, GPU compositor variance) while still catching any real disagreement between the two paths.

## Test plan

- `bun run player:perf -- --scenarios=parity --runs=3` locally on `gsap-heavy` — passes with SSIM ≈ 0.999 across all 3 runs.
- Inspected `results/parity/run-1/actual.png` and `reference.png` side-by-side — visually identical.
- Inspected `captured-time.txt` to confirm `T_actual` lands just past 5.0s (within one frame).
- Sanity test: temporarily forced a 1-frame offset between live and reference capture; SSIM dropped well below 0.95 as expected, confirming the threshold catches real drift.
- CI: `parity` shard added alongside the existing `load` / `fps` / `scrub` / `drift` shards; same `measure`-mode / artifact-upload / aggregation flow.
- `bunx oxlint` and `bunx oxfmt --check` clean on the new scenario.

## Stack

This is the top of the perf stack:

1. #393 `perf/x-1-emit-performance-metric` — performance.measure() emission
2. #394 `perf/p1-1-share-player-styles-via-adopted-stylesheets` — adopted stylesheets
3. #395 `perf/p1-2-scope-media-mutation-observer` — scoped MutationObserver
4. #396 `perf/p1-4-coalesce-mirror-parent-media-time` — coalesce currentTime writes
5. #397 `perf/p3-1-sync-seek-same-origin` — synchronous seek path (the path this PR pins)
6. #398 `perf/p3-2-srcdoc-composition-switching` — srcdoc switching
7. #399 `perf/p0-1a-perf-test-infra` — server, runner, perf-gate, CI
8. #400 `perf/p0-1b-perf-tests-for-fps-scrub-drift` — fps / scrub / drift scenarios
9. **#401 `perf/p0-1c-live-playback-parity-test` ← you are here**

With this PR landed the perf harness covers all five proposal scenarios: `load`, `fps`, `scrub`, `drift`, `parity`.
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