Skip to content

feat(player): SSR via declarative shadow DOM (stacked on #2758) - #2793

Draft
tombeckenham wants to merge 3 commits into
heygen-com:mainfrom
tombeckenham:feat/player-ssr-dsd
Draft

feat(player): SSR via declarative shadow DOM (stacked on #2758)#2793
tombeckenham wants to merge 3 commits into
heygen-com:mainfrom
tombeckenham:feat/player-ssr-dsd

Conversation

@tombeckenham

Copy link
Copy Markdown

Note

Stacked on #2758 — review only the last commit (a48708e74); the first commits are the React bindings PR. Will rebase onto main once #2758 merges. (A formal stacked base isn't possible cross-fork.)

What this is

Prototype answering "can the player load with SSR?" — yes, via Declarative Shadow DOM. The server serializes the player's shadow DOM into a <template shadowrootmode="open">; the browser parses it during HTML streaming, so the composition iframe starts loading and paints its first frame before any client JavaScript runs. When @hyperframes/player registers, the element upgrade adopts the server-rendered DOM instead of rebuilding it — the in-flight iframe is never reloaded.

How

  • src/ssr.ts (new) — DOM-free serializer: renderPlayerShadowDomHtml() (template inner HTML) and renderPlayerTagHtml() (full tag, for non-React SSR). Pre-JS fit is pure CSS: container-type: size + scale(min(calc(100cqw/w), calc(100cqh/h))); the client's scaleIframeToFit overrides inline after upgrade. Attribute values are escaped; dimensions are numerically coerced before CSS interpolation.
  • setupPlayerShadowDom (iframe-dom.ts) — constructor adopts an existing declarative root (same iframe node, same poster); foreign/malformed roots are cleared and rebuilt imperatively.
  • Reload guards_applySrc/_applySrcdoc skip assignment when the iframe already carries the identical prepared URL, so the upgrade-time attribute replay can't restart an in-flight SSR load. prepareSrc() extracted so serializer and element produce byte-identical srcs.
  • Already-loaded kickconnectedCallback invokes the probe manually for an adopted iframe that finished loading before upgrade (its load event pre-dates the listener); about:blank filtered, cross-origin falls back to the load event.
  • React — new ssr prop emits the template during server rendering only. The browser consumes the template while parsing (removed from light DOM), the client renders no child, so hydration matches with no suppression. String attributes additionally ride on the host tag.

Evidence (manual, Chrome)

  • Zero-JS page (renderToString output, no scripts): composition first frame painted at correct scale inside the declarative shadow root.
  • Late-JS page (player global build after markup): upgrade at 104ms during the iframe's initial load, load completed once at 108ms, a document mark set on that load survived the session (no reload), player.iframeElement is the same node, and the player reached ready with duration: 4.
  • 353 tests pass (+9 over feat(player): React bindings at @hyperframes/player/react + example app #2758): serializer↔adopter contract suite (adoption, malformed-root rebuild, shader-src byte-equality, escaping, CSS-injection coercion) and a React ssr renderToString test. The element's 127 pre-existing tests are untouched and green.

Open items before this leaves draft

  • Public ./ssr export + package-subpaths.json contract (serializer currently internal)
  • Cross-origin readiness handshake (postMessage) instead of the load-event fallback
  • Real SSR-framework fixture (Next.js) in CI, replacing the manual browser proof
  • Decide whether ssr should be default-on for the React binding once hardened

…le app

- New ./react subpath on @hyperframes/player: typed <HyperframesPlayer>
  component with camelCase props for every player attribute, callback
  props for every player event (CustomEvent detail unwrapped), and an
  imperative ref handle (play/pause/seek/stopMedia, color grading,
  currentTime/duration/paused/ready/scenes/element/iframeElement).
- SSR-safe: the custom element registers via a dynamic package
  self-reference on mount, so the player module never evaluates during
  server rendering; ensurePlayerDefined() allows eager registration.
- react is an optional peer dependency (^18 || ^19); the react entry
  builds separately (esm+cjs, react + player self-reference external) so
  consumers code-split the element bundle.
- New examples/react-player workspace (@hyperframes/react-player-example):
  vite demo app driving props, events, and the ref handle against a
  bundled copy of the motion-blur registry composition; examples/* added
  to workspaces, CI paths filter, and the tracked-examples gitignore
  negations.
- Player README documents the bindings; package-subpaths.json carries
  the ./react contract.
- Bind event listeners in a layout effect declared before attribute
  sync, so events the element dispatches synchronously during the
  initial attribute application (ratechange, volumechange, fast-srcdoc
  ready) are captured; stub now mirrors that dispatch behavior.
- Swap useLayoutEffect for an isomorphic variant (useEffect on the
  server) to avoid the React 18 SSR warning, and add a node-environment
  renderToString suite proving no player-module evaluation, no console
  warnings, and hydration-stable bare markup.
- Add the three missing event callbacks: onRuntimeProtocolError,
  onAudioOwnershipChange, onPlaybackError.
- Add an event-surface drift test: scans player sources for
  dispatchEvent sites and fails when PLAYER_EVENT_CALLBACKS misses one
  (or claims a phantom event).
- Add elementProps passthrough for host attributes (id, role, tabIndex,
  aria-*, data-*, DOM handlers).
- Update the latest-callback ref in a layout effect instead of during
  render (concurrent-rendering safety).

Addresses review feedback from Magi and Jerrai on heygen-com#2758.
Prototype of SSR for <hyperframes-player>: the shadow DOM — styles,
container, composition iframe, optional poster — serializes into a
<template shadowrootmode="open"> that the browser parses during HTML
streaming, so the composition starts loading and paints its first frame
before any client JavaScript runs.

- ssr.ts (new): DOM-free serializer (renderPlayerShadowDomHtml /
  renderPlayerTagHtml). Pre-JS fit uses container-query CSS
  (scale(min(100cqw/w, 100cqh/h))); the client's scaleIframeToFit
  overrides it inline after upgrade. Attribute values escaped,
  dimensions coerced numeric before CSS interpolation.
- setupPlayerShadowDom (iframe-dom.ts): the element constructor adopts
  a server-rendered declarative root — same iframe node, same poster —
  instead of rebuilding; foreign/malformed roots are cleared and
  rebuilt.
- _applySrc/_applySrcdoc guards: the upgrade-time attribute replay
  skips the assignment when the iframe already carries the identical
  prepared URL, so an in-flight SSR load is never restarted.
  prepareSrc() extracted from prepareSrcForElement so serializer and
  element produce byte-identical srcs.
- connectedCallback kicks the probe for an adopted iframe that
  finished loading before upgrade (its load event pre-dates the
  listener).
- React: new ssr prop renders the template server-side only — the
  browser consumes it during parsing, client renders no child, so
  hydration matches without suppression. String attributes ride on the
  host tag for crawlers and the upgrade replay.

Verified in Chrome: a zero-JS page paints the composition's first
frame at correct scale; with the player script loading late, the
upgrade adopts the mid-flight iframe (same node, no reload — document
mark survives) and reaches ready with the probed duration.

Known gaps before productionizing: no public ./ssr export or
package-subpaths entry, cross-origin iframes fall back to the load
event (no readiness handshake), no SSR-framework CI fixture.
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.

1 participant