diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index ff2a3f77..c5b263a1 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -17,6 +17,7 @@ import { tooltipState, } from '@videojs/skins/minimal/tailwind/audio.tailwind'; import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; import { SkinMixin } from '../skin-mixin'; // Side-effect imports: register all custom elements used in the template. @@ -127,7 +128,7 @@ export class MinimalAudioSkinTailwindElement extends SkinMixin(ReactiveElement) static getTemplateHTML = getTemplateHTML; } -customElements.define(MinimalAudioSkinTailwindElement.tagName, MinimalAudioSkinTailwindElement); +safeDefine(MinimalAudioSkinTailwindElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index adb308f5..0b2b6286 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -1,5 +1,6 @@ import { ReactiveElement } from '@videojs/element'; import { renderIcon } from '@videojs/icons/render/minimal'; +import { safeDefine } from '../safe-define'; import { createStyles, SkinMixin } from '../skin-mixin'; import styles from './minimal-skin.css?inline'; @@ -110,7 +111,7 @@ export class MinimalAudioSkinElement extends SkinMixin(ReactiveElement) { static getTemplateHTML = getTemplateHTML; } -customElements.define(MinimalAudioSkinElement.tagName, MinimalAudioSkinElement); +safeDefine(MinimalAudioSkinElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 5161395f..331a29db 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -17,6 +17,7 @@ import { tooltipState, } from '@videojs/skins/default/tailwind/audio.tailwind'; import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; import { SkinMixin } from '../skin-mixin'; // Side-effect imports: register all custom elements used in the template. @@ -122,7 +123,7 @@ export class AudioSkinTailwindElement extends SkinMixin(ReactiveElement) { static getTemplateHTML = getTemplateHTML; } -customElements.define(AudioSkinTailwindElement.tagName, AudioSkinTailwindElement); +safeDefine(AudioSkinTailwindElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index c0f688cb..26fea0c1 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -1,5 +1,6 @@ import { ReactiveElement } from '@videojs/element'; import { renderIcon } from '@videojs/icons/render'; +import { safeDefine } from '../safe-define'; import { createStyles, SkinMixin } from '../skin-mixin'; import styles from './skin.css?inline'; @@ -105,7 +106,7 @@ export class AudioSkinElement extends SkinMixin(ReactiveElement) { static getTemplateHTML = getTemplateHTML; } -customElements.define(AudioSkinElement.tagName, AudioSkinElement); +safeDefine(AudioSkinElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/media/simple-hls-video.ts b/packages/html/src/define/media/simple-hls-video.ts index 6cbb73ec..fde2f14b 100644 --- a/packages/html/src/define/media/simple-hls-video.ts +++ b/packages/html/src/define/media/simple-hls-video.ts @@ -1,10 +1,11 @@ import { SimpleHlsVideo } from '../../media/simple-hls-video'; +import { safeDefine } from '../safe-define'; export class SimpleHlsVideoElement extends SimpleHlsVideo { static readonly tagName = 'simple-hls-video'; } -customElements.define(SimpleHlsVideoElement.tagName, SimpleHlsVideoElement); +safeDefine(SimpleHlsVideoElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/safe-define.ts b/packages/html/src/define/safe-define.ts index f5ff2f9b..975fc2de 100644 --- a/packages/html/src/define/safe-define.ts +++ b/packages/html/src/define/safe-define.ts @@ -2,7 +2,8 @@ type DefinableElement = CustomElementConstructor & { tagName: string }; /** Define a custom element only if not already registered. */ export function safeDefine(element: DefinableElement): void { - if (!customElements.get(element.tagName)) { - customElements.define(element.tagName, element); - } + const registry = globalThis.customElements; + if (!registry || registry.get(element.tagName)) return; + + registry.define(element.tagName, element); } diff --git a/packages/html/src/define/skin-mixin.ts b/packages/html/src/define/skin-mixin.ts index 759145d3..b0032480 100644 --- a/packages/html/src/define/skin-mixin.ts +++ b/packages/html/src/define/skin-mixin.ts @@ -4,17 +4,45 @@ import rootStyles from './base.css?inline'; import sharedStyles from './shared.css?inline'; const STYLES_ID = '__media-styles'; +type SkinStyles = CSSStyleSheet | string; function ensureRootStyles(): void { - if (document.getElementById(STYLES_ID)) return; - const style = document.createElement('style'); + const doc = globalThis.document; + if (!doc || doc.getElementById(STYLES_ID)) return; + + const style = doc.createElement('style'); style.id = STYLES_ID; style.textContent = rootStyles; - document.head.appendChild(style); + doc.head.appendChild(style); } -const sharedSheet = new CSSStyleSheet(); -sharedSheet.replaceSync(sharedStyles); +function isConstructableStyleSheet(value: SkinStyles): value is CSSStyleSheet { + return typeof globalThis.CSSStyleSheet !== 'undefined' && value instanceof globalThis.CSSStyleSheet; +} + +function getStyleText(style: SkinStyles): string { + if (typeof style === 'string') return style; + + return Array.from(style.cssRules) + .map((rule) => rule.cssText) + .join('\n'); +} + +function applyShadowStyles(shadowRoot: ShadowRoot, styles: SkinStyles[]): void { + if (styles.every(isConstructableStyleSheet) && 'adoptedStyleSheets' in shadowRoot) { + shadowRoot.adoptedStyleSheets = styles; + return; + } + + const doc = shadowRoot.ownerDocument; + for (const styleText of styles.map(getStyleText)) { + const style = doc.createElement('style'); + style.textContent = styleText; + shadowRoot.appendChild(style); + } +} + +const sharedSheet = createStyles(sharedStyles); /** * Mixin for skin elements that renders the template from a static @@ -26,10 +54,10 @@ sharedSheet.replaceSync(sharedStyles); */ export function SkinMixin>( BaseClass: Base -): Base & { shadowRootOptions: ShadowRootInit; styles?: CSSStyleSheet } { +): Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles } { class SkinElement extends (BaseClass as Constructor) { static shadowRootOptions: ShadowRootInit = { mode: 'open' }; - static styles?: CSSStyleSheet; + static styles?: SkinStyles; constructor(...args: any[]) { super(...args); @@ -40,25 +68,29 @@ export function SkinMixin>( const ctor = this.constructor as typeof SkinElement & { getTemplateHTML?: () => string }; this.attachShadow(ctor.shadowRootOptions); - const sheets: CSSStyleSheet[] = [sharedSheet]; - if (ctor.styles) { - sheets.push(ctor.styles); - } - this.shadowRoot!.adoptedStyleSheets = sheets; - if (ctor.getTemplateHTML) { this.shadowRoot!.innerHTML = ctor.getTemplateHTML(); } + + const sheets: SkinStyles[] = [sharedSheet]; + if (ctor.styles) { + sheets.push(ctor.styles); + } + applyShadowStyles(this.shadowRoot!, sheets); } } } - return SkinElement as unknown as Base & { shadowRootOptions: ShadowRootInit; styles?: CSSStyleSheet }; + return SkinElement as unknown as Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles }; } -/** Create a shared `CSSStyleSheet` from a CSS string. */ -export function createStyles(css: string): CSSStyleSheet { - const sheet = new CSSStyleSheet(); +/** Create a constructable stylesheet when available, otherwise return raw CSS. */ +export function createStyles(css: string): SkinStyles { + if (typeof globalThis.CSSStyleSheet === 'undefined') { + return css; + } + + const sheet = new globalThis.CSSStyleSheet(); sheet.replaceSync(css); return sheet; } diff --git a/packages/html/src/define/tests/safe-define.test.ts b/packages/html/src/define/tests/safe-define.test.ts index 646f3f66..2e87ac9b 100644 --- a/packages/html/src/define/tests/safe-define.test.ts +++ b/packages/html/src/define/tests/safe-define.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { safeDefine } from '../safe-define'; describe('safeDefine', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('registers a custom element', () => { class TestElement extends HTMLElement { static tagName = 'test-sd-register'; @@ -33,4 +37,14 @@ describe('safeDefine', () => { safeDefine(Replacement); expect(customElements.get('test-sd-no-replace')).toBe(Original); }); + + it('does nothing when customElements is unavailable', () => { + vi.stubGlobal('customElements', undefined); + + class TestElement extends HTMLElement { + static tagName = 'test-sd-ssr'; + } + + expect(() => safeDefine(TestElement)).not.toThrow(); + }); }); diff --git a/packages/html/src/define/tests/ssr-safety.test.ts b/packages/html/src/define/tests/ssr-safety.test.ts new file mode 100644 index 00000000..2c4a7dd3 --- /dev/null +++ b/packages/html/src/define/tests/ssr-safety.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('SSR-safe define imports', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('imports video skin without browser-only globals', async () => { + vi.stubGlobal('customElements', undefined); + vi.stubGlobal('CSSStyleSheet', undefined); + + await expect(import('../video/skin')).resolves.toBeDefined(); + }); + + it('imports simple-hls-video without customElements', async () => { + vi.stubGlobal('customElements', undefined); + + await expect(import('../media/simple-hls-video')).resolves.toBeDefined(); + }); +}); diff --git a/packages/html/src/define/ui/captions-button.ts b/packages/html/src/define/ui/captions-button.ts index 73ae1b38..fa4a55d1 100644 --- a/packages/html/src/define/ui/captions-button.ts +++ b/packages/html/src/define/ui/captions-button.ts @@ -1,6 +1,7 @@ import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { safeDefine } from '../safe-define'; -customElements.define(CaptionsButtonElement.tagName, CaptionsButtonElement); +safeDefine(CaptionsButtonElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 52d77272..0e9cd9ca 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -22,6 +22,7 @@ import { tooltipState, } from '@videojs/skins/minimal/tailwind/video.tailwind'; import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; import { SkinMixin } from '../skin-mixin'; // Side-effect imports: register all custom elements used in the template. @@ -189,7 +190,7 @@ export class MinimalVideoSkinTailwindElement extends SkinMixin(ReactiveElement) static getTemplateHTML = getTemplateHTML; } -customElements.define(MinimalVideoSkinTailwindElement.tagName, MinimalVideoSkinTailwindElement); +safeDefine(MinimalVideoSkinTailwindElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index c03ae4e8..1b5e7d7e 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -1,5 +1,6 @@ import { ReactiveElement } from '@videojs/element'; import { renderIcon } from '@videojs/icons/render/minimal'; +import { safeDefine } from '../safe-define'; import { createStyles, SkinMixin } from '../skin-mixin'; import styles from './minimal-skin.css?inline'; @@ -158,7 +159,7 @@ export class MinimalVideoSkinElement extends SkinMixin(ReactiveElement) { static getTemplateHTML = getTemplateHTML; } -customElements.define(MinimalVideoSkinElement.tagName, MinimalVideoSkinElement); +safeDefine(MinimalVideoSkinElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 3c1255a7..356c23f2 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -22,6 +22,7 @@ import { tooltipState, } from '@videojs/skins/default/tailwind/video.tailwind'; import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; import { SkinMixin } from '../skin-mixin'; // Side-effect imports: register all custom elements used in the template. @@ -184,7 +185,7 @@ export class VideoSkinTailwindElement extends SkinMixin(ReactiveElement) { static getTemplateHTML = getTemplateHTML; } -customElements.define(VideoSkinTailwindElement.tagName, VideoSkinTailwindElement); +safeDefine(VideoSkinTailwindElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 9ae833ae..84bdf890 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -1,5 +1,6 @@ import { ReactiveElement } from '@videojs/element'; import { renderIcon } from '@videojs/icons/render'; +import { safeDefine } from '../safe-define'; import { createStyles, SkinMixin } from '../skin-mixin'; import styles from './skin.css?inline'; @@ -156,7 +157,7 @@ export class VideoSkinElement extends SkinMixin(ReactiveElement) { static getTemplateHTML = getTemplateHTML; } -customElements.define(VideoSkinElement.tagName, VideoSkinElement); +safeDefine(VideoSkinElement); declare global { interface HTMLElementTagNameMap { diff --git a/packages/sandbox/templates/html-video/main.ts b/packages/sandbox/templates/html-video/main.ts index 0c537ecc..0c4cfa4b 100644 --- a/packages/sandbox/templates/html-video/main.ts +++ b/packages/sandbox/templates/html-video/main.ts @@ -1,6 +1,5 @@ import '@app/styles.css'; import '@videojs/html/video/player'; -import '@videojs/html/ui/poster'; import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard';