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';