From ec9818428ca56fa185bbd044963312079728b4b4 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Sun, 1 Dec 2024 18:20:10 -0800 Subject: [PATCH] WIP: Implements on-demand definitions community protocol. This follows the initial draft of the [on-demand definitions community protocol](https://github.com/webcomponents-cg/community-protocols/pull/67). Each component now automatically includes a static `define` property which defines the custom element. `Dehydrated.prototype.access` and `Dehydrated.prototype.hydrate` both call this `define` function for the custom element class to ensure that it is defined before the element is used. The major effect of this is that `define*Component` no longer automatically defines the provided element in the global registry. This allows elements to be tree shaken when they are unused, but does mean users need to manually define them in the top-level scope if their application relies on upgrading pre-rendered HTML. I'm not a huge fan of this as it is quite easy for users to forget to call `define`, which was half the point of defining all components automatically. However, HydroActive should naturally do the right thing when depending on another component due to `Dehydrated` auto-defining dependencies. Defining entry point components is unfortunately not a problem HydroActive can easily solve. The current implementation does presumably support scoped custom element registries, but this is not tested as the spec has not been implemented by any browser just yet. The polyfill can potentially be used to verify behavior. --- cspell.json | 1 + src/base-component.ts | 11 +-- src/dehydrated.ts | 9 +++ src/signal-component.ts | 11 +-- src/utils/on-demand-definitions.html | 8 +++ src/utils/on-demand-definitions.test.ts | 83 ++++++++++++++++++++++ src/utils/on-demand-definitions.ts | 94 +++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/utils/on-demand-definitions.html create mode 100644 src/utils/on-demand-definitions.test.ts create mode 100644 src/utils/on-demand-definitions.ts 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); + }; +}