fix(html): prevent tsdown from stripping custom element registrations (#703)

This commit is contained in:
rahim
2026-03-04 19:06:43 +11:00
committed by GitHub
parent 1edeadefed
commit 9a5dfab4e8
36 changed files with 337 additions and 68 deletions
+5 -2
View File
@@ -1,7 +1,8 @@
import { audioFeatures } from '@videojs/core/dom';
import { MediaContainerElement } from '../../media/container-element';
import { createPlayer } from '../../player/create-player';
import { MediaElement } from '../../ui/media-element';
import '../media/container';
import { safeDefine } from '../safe-define';
const { ProviderMixin } = createPlayer({
features: audioFeatures,
@@ -11,7 +12,9 @@ export class AudioPlayerElement extends ProviderMixin(MediaElement) {
static readonly tagName = 'audio-player';
}
customElements.define(AudioPlayerElement.tagName, AudioPlayerElement);
// Provider must be defined before consumer for context handshake during upgrade.
safeDefine(AudioPlayerElement);
safeDefine(MediaContainerElement);
declare global {
interface HTMLElementTagNameMap {
@@ -1,7 +1,8 @@
import { backgroundFeatures } from '@videojs/core/dom';
import { MediaContainerElement } from '../../media/container-element';
import { createPlayer } from '../../player/create-player';
import { MediaElement } from '../../ui/media-element';
import '../media/container';
import { safeDefine } from '../safe-define';
const { ProviderMixin } = createPlayer({
features: backgroundFeatures,
@@ -11,7 +12,9 @@ export class BackgroundVideoPlayerElement extends ProviderMixin(MediaElement) {
static readonly tagName = 'background-video-player';
}
customElements.define(BackgroundVideoPlayerElement.tagName, BackgroundVideoPlayerElement);
// Provider must be defined before consumer for context handshake during upgrade.
safeDefine(BackgroundVideoPlayerElement);
safeDefine(MediaContainerElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,5 +1,6 @@
import { ReactiveElement } from '@videojs/element';
import { namedNodeMapToObject } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
function getTemplateHTML(_attrs: Record<string, string>) {
return /*html*/ `
@@ -24,7 +25,7 @@ export class BackgroundVideoSkinElement extends ReactiveElement {
}
}
customElements.define(BackgroundVideoSkinElement.tagName, BackgroundVideoSkinElement);
safeDefine(BackgroundVideoSkinElement);
declare global {
interface HTMLElementTagNameMap {
@@ -1,10 +1,11 @@
import { BackgroundVideo } from '../../media/background-video';
import { safeDefine } from '../safe-define';
export class BackgroundVideoElement extends BackgroundVideo {
static readonly tagName = 'background-video';
}
customElements.define(BackgroundVideoElement.tagName, BackgroundVideoElement);
safeDefine(BackgroundVideoElement);
declare global {
interface HTMLElementTagNameMap {
+3 -10
View File
@@ -1,14 +1,7 @@
import { playerContext } from '../../player/context';
import { createContainerMixin } from '../../store/container-mixin';
import { MediaElement } from '../../ui/media-element';
import { MediaContainerElement } from '../../media/container-element';
import { safeDefine } from '../safe-define';
const ContainerMixin = createContainerMixin(playerContext);
export class MediaContainerElement extends ContainerMixin(MediaElement) {
static readonly tagName = 'media-container';
}
customElements.define(MediaContainerElement.tagName, MediaContainerElement);
safeDefine(MediaContainerElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,10 +1,11 @@
import { HlsVideo } from '../../media/hls-video';
import { safeDefine } from '../safe-define';
export class HlsVideoElement extends HlsVideo {
static readonly tagName = 'hls-video';
}
customElements.define(HlsVideoElement.tagName, HlsVideoElement);
safeDefine(HlsVideoElement);
declare global {
interface HTMLElementTagNameMap {
+8
View File
@@ -0,0 +1,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);
}
}
@@ -0,0 +1,171 @@
import { afterAll, beforeAll, describe, expect, it, type MockInstance, vi } from 'vitest';
/**
* Tests that composite define files register all expected custom elements
* and that provider/parent elements are defined before consumer/child elements.
*
* Tests run sequentially. Each dynamically imports a composite define file and
* checks the batch of `customElements.define()` calls that resulted. Because
* modules are cached within a test file, shared sub-elements (e.g. slider parts)
* are only registered by the first composite that imports them subsequent
* composites skip them via `safeDefine`. This is intentional and tested.
*/
describe('composite define registration', () => {
let spy: MockInstance;
/** Tag names registered since `offset` (the call count before an import). */
function batchSince(offset: number): string[] {
return spy.mock.calls.slice(offset).map((call) => call[0] as string);
}
beforeAll(() => {
spy = vi.spyOn(customElements, 'define');
});
afterAll(() => {
spy.mockRestore();
});
// ── Player composites ────────────────────────────────────────────────
describe('video/player', () => {
it('registers video-player before media-container', async () => {
const before = spy.mock.calls.length;
await import('../video/player');
const batch = batchSince(before);
expect(batch).toContain('video-player');
expect(batch).toContain('media-container');
expect(batch.indexOf('video-player')).toBeLessThan(batch.indexOf('media-container'));
});
});
describe('audio/player', () => {
it('registers audio-player', async () => {
const before = spy.mock.calls.length;
await import('../audio/player');
const batch = batchSince(before);
expect(batch).toContain('audio-player');
// media-container already registered by video/player — safeDefine skips it
expect(batch).not.toContain('media-container');
});
});
describe('background/player', () => {
it('registers background-video-player', async () => {
const before = spy.mock.calls.length;
await import('../background/player');
const batch = batchSince(before);
expect(batch).toContain('background-video-player');
expect(batch).not.toContain('media-container');
});
});
// ── Slider composites ────────────────────────────────────────────────
describe('ui/time-slider', () => {
it('registers media-time-slider before sub-elements', async () => {
const before = spy.mock.calls.length;
await import('../ui/time-slider');
const batch = batchSince(before);
// Parent slider must be first (provider for sliderContext)
expect(batch[0]).toBe('media-time-slider');
// All sub-elements registered (first composite to claim them)
expect(batch).toContain('media-slider-buffer');
expect(batch).toContain('media-slider-fill');
expect(batch).toContain('media-slider-thumb');
expect(batch).toContain('media-slider-track');
expect(batch).toContain('media-slider-value');
});
});
describe('ui/volume-slider', () => {
it('registers media-volume-slider and skips already-defined sub-elements', async () => {
const before = spy.mock.calls.length;
await import('../ui/volume-slider');
const batch = batchSince(before);
expect(batch).toContain('media-volume-slider');
// Sub-elements already registered by time-slider — safeDefine skips them
expect(batch).not.toContain('media-slider-fill');
expect(batch).not.toContain('media-slider-thumb');
expect(batch).not.toContain('media-slider-track');
expect(batch).not.toContain('media-slider-value');
});
});
describe('ui/slider', () => {
it('registers media-slider', async () => {
const before = spy.mock.calls.length;
await import('../ui/slider');
const batch = batchSince(before);
expect(batch).toContain('media-slider');
});
});
// ── Other composites ─────────────────────────────────────────────────
describe('ui/time', () => {
it('registers media-time before sub-elements', async () => {
const before = spy.mock.calls.length;
await import('../ui/time');
const batch = batchSince(before);
expect(batch[0]).toBe('media-time');
expect(batch).toContain('media-time-group');
expect(batch).toContain('media-time-separator');
});
});
describe('ui/controls', () => {
it('registers media-controls before sub-elements', async () => {
const before = spy.mock.calls.length;
await import('../ui/controls');
const batch = batchSince(before);
expect(batch[0]).toBe('media-controls');
expect(batch).toContain('media-controls-group');
});
});
// ── Final state ──────────────────────────────────────────────────────
describe('registry completeness', () => {
it('all expected elements are registered after importing all composites', () => {
const expected = [
// Players + container
'video-player',
'audio-player',
'background-video-player',
'media-container',
// Sliders
'media-slider',
'media-time-slider',
'media-volume-slider',
// Slider sub-elements
'media-slider-buffer',
'media-slider-fill',
'media-slider-thumb',
'media-slider-track',
'media-slider-value',
// Time
'media-time',
'media-time-group',
'media-time-separator',
// Controls
'media-controls',
'media-controls-group',
];
for (const tagName of expected) {
expect(customElements.get(tagName), `${tagName} should be registered`).toBeDefined();
}
});
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { safeDefine } from '../safe-define';
describe('safeDefine', () => {
it('registers a custom element', () => {
class TestElement extends HTMLElement {
static tagName = 'test-sd-register';
}
expect(customElements.get('test-sd-register')).toBeUndefined();
safeDefine(TestElement);
expect(customElements.get('test-sd-register')).toBe(TestElement);
});
it('does not throw when element is already registered', () => {
class TestElement extends HTMLElement {
static tagName = 'test-sd-no-throw';
}
customElements.define('test-sd-no-throw', TestElement);
expect(() => safeDefine(TestElement)).not.toThrow();
});
it('does not replace an existing registration', () => {
class Original extends HTMLElement {
static tagName = 'test-sd-no-replace';
}
class Replacement extends HTMLElement {
static tagName = 'test-sd-no-replace';
}
safeDefine(Original);
safeDefine(Replacement);
expect(customElements.get('test-sd-no-replace')).toBe(Original);
});
});
@@ -1,6 +1,7 @@
import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element';
import { safeDefine } from '../safe-define';
customElements.define(BufferingIndicatorElement.tagName, BufferingIndicatorElement);
safeDefine(BufferingIndicatorElement);
declare global {
interface HTMLElementTagNameMap {
@@ -1,6 +1,7 @@
import { ControlsGroupElement } from '../../ui/controls/controls-group-element';
import { safeDefine } from '../safe-define';
customElements.define(ControlsGroupElement.tagName, ControlsGroupElement);
safeDefine(ControlsGroupElement);
declare global {
interface HTMLElementTagNameMap {
+4 -3
View File
@@ -1,8 +1,9 @@
import { ControlsElement } from '../../ui/controls/controls-element';
import { ControlsGroupElement } from '../../ui/controls/controls-group-element';
import { safeDefine } from '../safe-define';
import './controls-group';
customElements.define(ControlsElement.tagName, ControlsElement);
safeDefine(ControlsElement);
safeDefine(ControlsGroupElement);
declare global {
interface HTMLElementTagNameMap {
@@ -1,6 +1,7 @@
import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element';
import { safeDefine } from '../safe-define';
customElements.define(FullscreenButtonElement.tagName, FullscreenButtonElement);
safeDefine(FullscreenButtonElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { MuteButtonElement } from '../../ui/mute-button/mute-button-element';
import { safeDefine } from '../safe-define';
customElements.define(MuteButtonElement.tagName, MuteButtonElement);
safeDefine(MuteButtonElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { PiPButtonElement } from '../../ui/pip-button/pip-button-element';
import { safeDefine } from '../safe-define';
customElements.define(PiPButtonElement.tagName, PiPButtonElement);
safeDefine(PiPButtonElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { PlayButtonElement } from '../../ui/play-button/play-button-element';
import { safeDefine } from '../safe-define';
customElements.define(PlayButtonElement.tagName, PlayButtonElement);
safeDefine(PlayButtonElement);
declare global {
interface HTMLElementTagNameMap {
@@ -1,6 +1,7 @@
import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element';
import { safeDefine } from '../safe-define';
customElements.define(PlaybackRateButtonElement.tagName, PlaybackRateButtonElement);
safeDefine(PlaybackRateButtonElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { PopoverElement } from '../../ui/popover/popover-element';
import { safeDefine } from '../safe-define';
customElements.define(PopoverElement.tagName, PopoverElement);
safeDefine(PopoverElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { PosterElement } from '../../ui/poster/poster-element';
import { safeDefine } from '../safe-define';
customElements.define(PosterElement.tagName, PosterElement);
safeDefine(PosterElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { SeekButtonElement } from '../../ui/seek-button/seek-button-element';
import { safeDefine } from '../safe-define';
customElements.define(SeekButtonElement.tagName, SeekButtonElement);
safeDefine(SeekButtonElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { SliderBufferElement } from '../../ui/slider/slider-buffer-element';
import { safeDefine } from '../safe-define';
customElements.define(SliderBufferElement.tagName, SliderBufferElement);
safeDefine(SliderBufferElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { safeDefine } from '../safe-define';
customElements.define(SliderFillElement.tagName, SliderFillElement);
safeDefine(SliderFillElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { safeDefine } from '../safe-define';
customElements.define(SliderThumbElement.tagName, SliderThumbElement);
safeDefine(SliderThumbElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { safeDefine } from '../safe-define';
customElements.define(SliderTrackElement.tagName, SliderTrackElement);
safeDefine(SliderTrackElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { SliderValueElement } from '../../ui/slider/slider-value-element';
import { safeDefine } from '../safe-define';
customElements.define(SliderValueElement.tagName, SliderValueElement);
safeDefine(SliderValueElement);
declare global {
interface HTMLElementTagNameMap {
+11 -6
View File
@@ -1,11 +1,16 @@
import { SliderElement } from '../../ui/slider/slider-element';
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { SliderValueElement } from '../../ui/slider/slider-value-element';
import { safeDefine } from '../safe-define';
import './slider-fill';
import './slider-thumb';
import './slider-track';
import './slider-value';
customElements.define(SliderElement.tagName, SliderElement);
// Parent slider first — sub-elements consume its context.
safeDefine(SliderElement);
safeDefine(SliderFillElement);
safeDefine(SliderThumbElement);
safeDefine(SliderTrackElement);
safeDefine(SliderValueElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { ThumbnailElement } from '../../ui/thumbnail/thumbnail-element';
import { safeDefine } from '../safe-define';
customElements.define(ThumbnailElement.tagName, ThumbnailElement);
safeDefine(ThumbnailElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -1,6 +1,7 @@
import { TimeGroupElement } from '../../ui/time/time-group-element';
import { safeDefine } from '../safe-define';
customElements.define(TimeGroupElement.tagName, TimeGroupElement);
safeDefine(TimeGroupElement);
declare global {
interface HTMLElementTagNameMap {
@@ -1,6 +1,7 @@
import { TimeSeparatorElement } from '../../ui/time/time-separator-element';
import { safeDefine } from '../safe-define';
customElements.define(TimeSeparatorElement.tagName, TimeSeparatorElement);
safeDefine(TimeSeparatorElement);
declare global {
interface HTMLElementTagNameMap {
+13 -7
View File
@@ -1,12 +1,18 @@
import { SliderBufferElement } from '../../ui/slider/slider-buffer-element';
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { SliderValueElement } from '../../ui/slider/slider-value-element';
import { TimeSliderElement } from '../../ui/time-slider/time-slider-element';
import { safeDefine } from '../safe-define';
import './slider-buffer';
import './slider-fill';
import './slider-thumb';
import './slider-track';
import './slider-value';
customElements.define(TimeSliderElement.tagName, TimeSliderElement);
// Parent slider first — sub-elements consume its context.
safeDefine(TimeSliderElement);
safeDefine(SliderBufferElement);
safeDefine(SliderFillElement);
safeDefine(SliderThumbElement);
safeDefine(SliderTrackElement);
safeDefine(SliderValueElement);
declare global {
interface HTMLElementTagNameMap {
+6 -4
View File
@@ -1,9 +1,11 @@
import { TimeElement } from '../../ui/time/time-element';
import { TimeGroupElement } from '../../ui/time/time-group-element';
import { TimeSeparatorElement } from '../../ui/time/time-separator-element';
import { safeDefine } from '../safe-define';
import './time-group';
import './time-separator';
customElements.define(TimeElement.tagName, TimeElement);
safeDefine(TimeElement);
safeDefine(TimeGroupElement);
safeDefine(TimeSeparatorElement);
declare global {
interface HTMLElementTagNameMap {
+11 -6
View File
@@ -1,11 +1,16 @@
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { SliderValueElement } from '../../ui/slider/slider-value-element';
import { VolumeSliderElement } from '../../ui/volume-slider/volume-slider-element';
import { safeDefine } from '../safe-define';
import './slider-fill';
import './slider-thumb';
import './slider-track';
import './slider-value';
customElements.define(VolumeSliderElement.tagName, VolumeSliderElement);
// Parent slider first — sub-elements consume its context.
safeDefine(VolumeSliderElement);
safeDefine(SliderFillElement);
safeDefine(SliderThumbElement);
safeDefine(SliderTrackElement);
safeDefine(SliderValueElement);
declare global {
interface HTMLElementTagNameMap {
+5 -2
View File
@@ -1,7 +1,8 @@
import { videoFeatures } from '@videojs/core/dom';
import { MediaContainerElement } from '../../media/container-element';
import { createPlayer } from '../../player/create-player';
import { MediaElement } from '../../ui/media-element';
import '../media/container';
import { safeDefine } from '../safe-define';
const { ProviderMixin } = createPlayer({
features: videoFeatures,
@@ -11,7 +12,9 @@ export class VideoPlayerElement extends ProviderMixin(MediaElement) {
static readonly tagName = 'video-player';
}
customElements.define(VideoPlayerElement.tagName, VideoPlayerElement);
// Provider must be defined before consumer for context handshake during upgrade.
safeDefine(VideoPlayerElement);
safeDefine(MediaContainerElement);
declare global {
interface HTMLElementTagNameMap {
+2 -1
View File
@@ -4,7 +4,8 @@ export * from '@videojs/core/dom';
// Store
export type { Comparator, Selector } from '@videojs/store';
export { createSelector, shallowEqual } from '@videojs/store';
// Media
export { MediaContainerElement } from './media/container-element';
// Player
export * from './player/context';
export * from './player/create-player';
@@ -0,0 +1,9 @@
import { playerContext } from '../player/context';
import { createContainerMixin } from '../store/container-mixin';
import { MediaElement } from '../ui/media-element';
const ContainerMixin = createContainerMixin(playerContext);
export class MediaContainerElement extends ContainerMixin(MediaElement) {
static readonly tagName = 'media-container';
}
+6 -4
View File
@@ -7,10 +7,12 @@ type BuildMode = 'dev' | 'default';
const buildModes: BuildMode[] = ['dev', 'default'];
const defineEntries = Object.fromEntries(
globSync('src/define/**/*.ts').map((file) => {
const key = file.replace('src/', '').replace('.ts', '');
return [key, file];
})
globSync('src/define/**/*.ts')
.filter((file) => !file.includes('.test.'))
.map((file) => {
const key = file.replace('src/', '').replace('.ts', '');
return [key, file];
})
);
const presetEntries = Object.fromEntries(