diff --git a/cspell.json b/cspell.json index 927dc04..b2fbe84 100644 --- a/cspell.json +++ b/cspell.json @@ -5,6 +5,7 @@ "dictionaries": [], "words": [ "Clazz", + "Defineable", "hydroactive", "prerendered", "templating" diff --git a/src/base-component.ts b/src/base-component.ts index 697a9f5..10cd298 100644 --- a/src/base-component.ts +++ b/src/base-component.ts @@ -1,6 +1,7 @@ import { ComponentAccessor } from './component-accessor.js'; import { applyDefinition, ComponentDefinition, HydroActiveComponent } from './hydroactive-component.js'; import { skewerCaseToPascalCase } from './utils/casing.js'; +import { createDefine, Defineable } from './utils/on-demand-definitions.js'; import { Class } from './utils/types.js'; /** The type of the lifecycle hook invoked when the component hydrates. */ @@ -18,8 +19,11 @@ export type BaseHydrateLifecycle = export function defineBaseComponent( tagName: string, hydrate: BaseHydrateLifecycle, -): Class { +): Class & Defineable { const Component = class extends HydroActiveComponent { + // Implement the on-demand definitions community protocol. + static define = createDefine(tagName, this); + public override hydrate(): void { // Hydrate this element. const compDef = hydrate(ComponentAccessor.fromComponent(this)); @@ -33,7 +37,6 @@ export function defineBaseComponent( value: skewerCaseToPascalCase(tagName), }); - customElements.define(tagName, Component); - - return Component as unknown as Class; + return Component as unknown as + Class & Defineable; } diff --git a/src/dehydrated.ts b/src/dehydrated.ts index ffd53d0..5b5e324 100644 --- a/src/dehydrated.ts +++ b/src/dehydrated.ts @@ -4,6 +4,7 @@ import { PropsOf, hydrate, isHydrated } from './hydration.js'; import { isCustomElement, isUpgraded } from './custom-elements.js'; import { QueryAllResult, QueryResult, QueryRoot } from './query-root.js'; import { Class } from './utils/types.js'; +import { defineIfSupported } from './utils/on-demand-definitions.js'; /** * Represents a "dehydrated" reference to an element. The element is *not* @@ -91,6 +92,10 @@ export class Dehydrated implements Queryable { this.#native.tagName.toLowerCase()}\` requires an element class.`); } + // Implement on-demand definitions protocol by calling the function if + // present. + defineIfSupported(elementClass); + if (!(this.#native instanceof elementClass)) { throw new Error(`Custom element \`${ (this.#native as Element).tagName.toLowerCase()}\` does not extend \`${ @@ -130,6 +135,10 @@ export class Dehydrated implements Queryable { ? [ props?: PropsOf> ] : [ props: PropsOf> ] ): ElementAccessor> { + // Implement on-demand definitions protocol by calling the function if + // present. + defineIfSupported(elementClass); + hydrate(this.#native, elementClass, props); return ElementAccessor.from(this.#native); } diff --git a/src/signal-component.ts b/src/signal-component.ts index 5864067..1d100c7 100644 --- a/src/signal-component.ts +++ b/src/signal-component.ts @@ -4,6 +4,7 @@ import { applyDefinition, ComponentDefinition, HydroActiveComponent } from './hy import { SignalComponentAccessor } from './signal-component-accessor.js'; import { ReactiveRootImpl } from './signals/reactive-root.js'; import { skewerCaseToPascalCase } from './utils/casing.js'; +import { createDefine, Defineable } from './utils/on-demand-definitions.js'; import { Class } from './utils/types.js'; /** The type of the lifecycle hook invoked when the component hydrates. */ @@ -21,8 +22,11 @@ export type SignalHydrateLifecycle = export function defineSignalComponent( tagName: string, hydrate: SignalHydrateLifecycle, -): Class { +): Class & Defineable { const Component = class extends HydroActiveComponent { + // Implement the on-demand definitions community protocol. + static define = createDefine(tagName, this); + public override hydrate(): void { // Create an accessor for this element. const root = ReactiveRootImpl.from( @@ -45,7 +49,6 @@ export function defineSignalComponent( value: skewerCaseToPascalCase(tagName), }); - customElements.define(tagName, Component); - - return Component as unknown as Class; + return Component as unknown as + Class & Defineable; } diff --git a/src/utils/on-demand-definitions.html b/src/utils/on-demand-definitions.html new file mode 100644 index 0000000..6bbebde --- /dev/null +++ b/src/utils/on-demand-definitions.html @@ -0,0 +1,8 @@ + + + + `on-demand-definitions` tests + + + + diff --git a/src/utils/on-demand-definitions.test.ts b/src/utils/on-demand-definitions.test.ts new file mode 100644 index 0000000..3cda2e2 --- /dev/null +++ b/src/utils/on-demand-definitions.test.ts @@ -0,0 +1,83 @@ +import { createDefine, defineIfSupported } from './on-demand-definitions.js'; + +describe('on-demand-definitions', () => { + describe('defineIfSupported', () => { + it('calls static `define` on a supporting class', () => { + class MyElement extends HTMLElement { + static define = jasmine.createSpy<() => void>('define'); + } + + defineIfSupported(MyElement); + + expect(MyElement.define).toHaveBeenCalledOnceWith(); + }); + + it('ignores classes which do not implement the protocol', () => { + class MyElement extends HTMLElement { + // No `define` property. + // static define(): void { /* ... */ } + } + + expect(() => defineIfSupported(MyElement)).not.toThrow(); + }); + }); + + describe('createDefine', () => { + it('defines in the global registry', () => { + class MyElement extends HTMLElement { + static define = createDefine('define-protocol--global-reg', this); + } + + expect(customElements.get('define-protocol--global-reg')).toBeUndefined(); + + MyElement.define(); + + expect(customElements.get('define-protocol--global-reg')).toBe(MyElement); + }); + + it('no-ops when called multiple times', () => { + class MyElement extends HTMLElement { + static define = createDefine('define-protocol--multi', this); + } + + MyElement.define(); + expect(() => MyElement.define()).not.toThrow(); + }); + + it('no-ops when `customElements.define` was already called', () => { + class MyElement extends HTMLElement { + static define = createDefine('define-protocol--already-defined', this); + } + + customElements.define('define-protocol--already-defined', MyElement); + + expect(() => MyElement.define()).not.toThrow(); + }); + + it('throws when the element was already defined with a different class', () => { + class MyElement extends HTMLElement { + static define = createDefine('define-protocol--conflict', this); + } + + customElements.define( + 'define-protocol--conflict', class extends HTMLElement {}); + + expect(() => MyElement.define()).toThrowError(/already defined/); + }); + + it('passes through element definition options', () => { + class MyElement extends HTMLParagraphElement { + static define = createDefine('define-protocol--options', this, { + extends: 'p', + }); + } + + MyElement.define(); + + const p = document.createElement('p', { + is: 'define-protocol--options', + }); + expect(p).toBeInstanceOf(MyElement); + }); + }); +}); diff --git a/src/utils/on-demand-definitions.ts b/src/utils/on-demand-definitions.ts new file mode 100644 index 0000000..fe5027e --- /dev/null +++ b/src/utils/on-demand-definitions.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview Provides primitives to easily implement the on-demand + * definitions community protocol. + * + * @see https://github.com/webcomponents-cg/community-protocols/pull/67 + */ + +/** + * Defines the custom element. + * + * @param registry The registry to define the custom element in. Defaults to the + * global {@link customElements} registry. + * @param tagName The tag name to define the custom element as. Uses a default + * tag name when not specified. Using an explicit tag name is only supported + * when using a non-global registry + */ +export type Define = + (registry?: CustomElementRegistry, tagName?: string) => void; + +/** + * A class definition which implements the on-demand definitions community + * protocol. + * + * Note that because `define` is static, this type should be applied to the + * custom element class type, not the instance type. + * + * ```typescript + * class MyElement extends HTMLElement { + * static define() { ... } + * } + * + * const definable = MyElement as Defineable; + * ``` + */ +export interface Defineable { + /** + * Defines the custom element. + * + * @param registry The registry to define the custom element in. Defaults to + * the global {@link customElements} registry. + * @param tagName The tag name to define the custom element as. Uses a default + * tag name when not specified. Using an explicit tag name is only + * supported when a using non-global registry + */ + define: Define; +} + +/** + * Defines the provided custom element in the global registry if that element + * implements the on-demand definitions community protocol. + * + * @param Clazz The custom element class to define. + */ +export function defineIfSupported(Clazz: typeof Element): void { + (Clazz as Partial).define?.(); +} + +/** + * Creates a {@link Define} function which defines the given custom element with + * the default tag name. The returned function should be used as the static + * `define` function in a {@link Defineable} custom element. + * + * @param defaultTagName The tag name to use in the global registry and by + * default for scoped registries. + * @param Clazz The custom element class to define. + * @param options Options for the {@link CustomElementRegistry.prototype.define} + * call. + */ +export function createDefine( + defaultTagName: string, + Clazz: typeof HTMLElement, + options?: ElementDefinitionOptions, +): Define { + return (registry = customElements, tagName = defaultTagName) => { + // Tag name can only be modified when not in the global registry. + if (registry === customElements && tagName !== defaultTagName) { + throw new Error('Cannot use a non-default tag name in the global custom element registry.'); + } + + // Check if the tag name was already defined by another class. + const existing = registry.get(tagName); + if (existing) { + if (existing === Clazz) { + return; // Already defined as the correct class, no-op. + } else { + throw new Error(`Tag name \`${tagName}\` already defined as \`${ + existing.name}\`.`); + } + } + + // Define the class. + registry.define(tagName, Clazz, options); + }; +}