Skip to content

Commit

Permalink
WIP: Adds static define implementation.
Browse files Browse the repository at this point in the history
This follows the initial draft of the [static `define` community protocol](webcomponents-cg/community-protocols#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.
  • Loading branch information
dgp1130 committed Dec 2, 2024
1 parent f1c0ffd commit 5b08764
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 8 deletions.
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dictionaries": [],
"words": [
"Clazz",
"Defineable",
"hydroactive",
"prerendered",
"templating"
Expand Down
11 changes: 7 additions & 4 deletions src/base-component.ts
Original file line number Diff line number Diff line change
@@ -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/define-protocol.js';
import { Class } from './utils/types.js';

/** The type of the lifecycle hook invoked when the component hydrates. */
Expand All @@ -18,8 +19,11 @@ export type BaseHydrateLifecycle<CompDef extends ComponentDefinition> =
export function defineBaseComponent<CompDef extends ComponentDefinition>(
tagName: string,
hydrate: BaseHydrateLifecycle<CompDef>,
): Class<HydroActiveComponent & CompDef> {
): Class<HydroActiveComponent & CompDef> & Defineable {
const Component = class extends HydroActiveComponent {
// Implement the static `define` community protocol.
static define = createDefine(tagName, this);

public override hydrate(): void {
// Hydrate this element.
const compDef = hydrate(ComponentAccessor.fromComponent(this));
Expand All @@ -33,7 +37,6 @@ export function defineBaseComponent<CompDef extends ComponentDefinition>(
value: skewerCaseToPascalCase(tagName),
});

customElements.define(tagName, Component);

return Component as unknown as Class<HydroActiveComponent & CompDef>;
return Component as unknown as
Class<HydroActiveComponent & CompDef> & Defineable;
}
7 changes: 7 additions & 0 deletions src/dehydrated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/define-protocol.js';

/**
* Represents a "dehydrated" reference to an element. The element is *not*
Expand Down Expand Up @@ -91,6 +92,9 @@ export class Dehydrated<out El extends Element> implements Queryable<El> {
this.#native.tagName.toLowerCase()}\` requires an element class.`);
}

// Implement static `define` 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 \`${
Expand Down Expand Up @@ -130,6 +134,9 @@ export class Dehydrated<out El extends Element> implements Queryable<El> {
? [ props?: PropsOf<InstanceType<Clazz>> ]
: [ props: PropsOf<InstanceType<Clazz>> ]
): ElementAccessor<InstanceType<Clazz>> {
// Implement static `define` protocol by calling the function if present.
defineIfSupported(elementClass);

hydrate(this.#native, elementClass, props);
return ElementAccessor.from(this.#native);
}
Expand Down
11 changes: 7 additions & 4 deletions src/signal-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/define-protocol.js';
import { Class } from './utils/types.js';

/** The type of the lifecycle hook invoked when the component hydrates. */
Expand All @@ -21,8 +22,11 @@ export type SignalHydrateLifecycle<CompDef extends ComponentDefinition> =
export function defineSignalComponent<CompDef extends ComponentDefinition>(
tagName: string,
hydrate: SignalHydrateLifecycle<CompDef>,
): Class<HydroActiveComponent & CompDef> {
): Class<HydroActiveComponent & CompDef> & Defineable {
const Component = class extends HydroActiveComponent {
// Implement the static `define` community protocol.
static define = createDefine(tagName, this);

public override hydrate(): void {
// Create an accessor for this element.
const root = ReactiveRootImpl.from(
Expand All @@ -45,7 +49,6 @@ export function defineSignalComponent<CompDef extends ComponentDefinition>(
value: skewerCaseToPascalCase(tagName),
});

customElements.define(tagName, Component);

return Component as unknown as Class<HydroActiveComponent & CompDef>;
return Component as unknown as
Class<HydroActiveComponent & CompDef> & Defineable;
}
8 changes: 8 additions & 0 deletions src/utils/define-protocol.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<title>`define-protocol` tests</title>
<meta charset="utf8">
</head>
<body></body>
</html>
83 changes: 83 additions & 0 deletions src/utils/define-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createDefine, defineIfSupported } from './define-protocol.js';

describe('define-protocol', () => {
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);
});
});
});
93 changes: 93 additions & 0 deletions src/utils/define-protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @fileoverview Provides primitives to easily implement the static `define`
* 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 static `define` 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 static `define` community protocol.
*
* @param Clazz The custom element class to define.
*/
export function defineIfSupported(Clazz: typeof Element): void {
(Clazz as Partial<Defineable>).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);
};
}

0 comments on commit 5b08764

Please sign in to comment.