fix(html): HTML SSR safety and sandbox skin chunking (#1155)

This commit is contained in:
Sam Potts
2026-03-31 07:26:08 +11:00
committed by GitHub
parent 3fc6cbb9bc
commit 0433722ee4
15 changed files with 109 additions and 32 deletions
@@ -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 {
@@ -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 {
@@ -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 {
+2 -1
View File
@@ -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 {
@@ -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 {
+4 -3
View File
@@ -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);
}
+49 -17
View File
@@ -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<Base extends Constructor<ReactiveElement>>(
BaseClass: Base
): Base & { shadowRootOptions: ShadowRootInit; styles?: CSSStyleSheet } {
): Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles } {
class SkinElement extends (BaseClass as Constructor<ReactiveElement>) {
static shadowRootOptions: ShadowRootInit = { mode: 'open' };
static styles?: CSSStyleSheet;
static styles?: SkinStyles;
constructor(...args: any[]) {
super(...args);
@@ -40,25 +68,29 @@ export function SkinMixin<Base extends Constructor<ReactiveElement>>(
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;
}
@@ -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();
});
});
@@ -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();
});
});
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
+2 -1
View File
@@ -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 {