Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7a6d75f

Browse files
committedDec 4, 2024
WIP: Implements on-demand definitions community protocol.
This follows the initial draft of the [on-demand definitions 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.
1 parent 2ab19be commit 7a6d75f

7 files changed

+209
-8
lines changed
 

‎cspell.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"dictionaries": [],
66
"words": [
77
"Clazz",
8+
"Defineable",
89
"hydroactive",
910
"prerendered",
1011
"templating"

‎src/base-component.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ComponentAccessor } from './component-accessor.js';
22
import { applyDefinition, ComponentDefinition, HydroActiveComponent } from './hydroactive-component.js';
33
import { skewerCaseToPascalCase } from './utils/casing.js';
4+
import { createDefine, Defineable } from './utils/on-demand-definitions.js';
45
import { Class } from './utils/types.js';
56

67
/** The type of the lifecycle hook invoked when the component hydrates. */
@@ -18,8 +19,11 @@ export type BaseHydrateLifecycle<CompDef extends ComponentDefinition> =
1819
export function defineBaseComponent<CompDef extends ComponentDefinition>(
1920
tagName: string,
2021
hydrate: BaseHydrateLifecycle<CompDef>,
21-
): Class<HydroActiveComponent & CompDef> {
22+
): Class<HydroActiveComponent & CompDef> & Defineable {
2223
const Component = class extends HydroActiveComponent {
24+
// Implement the on-demand definitions community protocol.
25+
static define = createDefine(tagName, this);
26+
2327
public override hydrate(): void {
2428
// Hydrate this element.
2529
const compDef = hydrate(ComponentAccessor.fromComponent(this));
@@ -33,7 +37,6 @@ export function defineBaseComponent<CompDef extends ComponentDefinition>(
3337
value: skewerCaseToPascalCase(tagName),
3438
});
3539

36-
customElements.define(tagName, Component);
37-
38-
return Component as unknown as Class<HydroActiveComponent & CompDef>;
40+
return Component as unknown as
41+
Class<HydroActiveComponent & CompDef> & Defineable;
3942
}

‎src/dehydrated.ts

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { PropsOf, hydrate, isHydrated } from './hydration.js';
44
import { isCustomElement, isUpgraded } from './custom-elements.js';
55
import { QueryAllResult, QueryResult, QueryRoot } from './query-root.js';
66
import { Class } from './utils/types.js';
7+
import { defineIfSupported } from './utils/on-demand-definitions.js';
78

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

95+
// Implement on-demand definitions protocol by calling the function if
96+
// present.
97+
defineIfSupported(elementClass);
98+
9499
if (!(this.#native instanceof elementClass)) {
95100
throw new Error(`Custom element \`${
96101
(this.#native as Element).tagName.toLowerCase()}\` does not extend \`${
@@ -130,6 +135,10 @@ export class Dehydrated<out El extends Element> implements Queryable<El> {
130135
? [ props?: PropsOf<InstanceType<Clazz>> ]
131136
: [ props: PropsOf<InstanceType<Clazz>> ]
132137
): ElementAccessor<InstanceType<Clazz>> {
138+
// Implement on-demand definitions protocol by calling the function if
139+
// present.
140+
defineIfSupported(elementClass);
141+
133142
hydrate(this.#native, elementClass, props);
134143
return ElementAccessor.from(this.#native);
135144
}

‎src/signal-component.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { applyDefinition, ComponentDefinition, HydroActiveComponent } from './hy
44
import { SignalComponentAccessor } from './signal-component-accessor.js';
55
import { ReactiveRootImpl } from './signals/reactive-root.js';
66
import { skewerCaseToPascalCase } from './utils/casing.js';
7+
import { createDefine, Defineable } from './utils/on-demand-definitions.js';
78
import { Class } from './utils/types.js';
89

910
/** The type of the lifecycle hook invoked when the component hydrates. */
@@ -21,8 +22,11 @@ export type SignalHydrateLifecycle<CompDef extends ComponentDefinition> =
2122
export function defineSignalComponent<CompDef extends ComponentDefinition>(
2223
tagName: string,
2324
hydrate: SignalHydrateLifecycle<CompDef>,
24-
): Class<HydroActiveComponent & CompDef> {
25+
): Class<HydroActiveComponent & CompDef> & Defineable {
2526
const Component = class extends HydroActiveComponent {
27+
// Implement the on-demand definitions community protocol.
28+
static define = createDefine(tagName, this);
29+
2630
public override hydrate(): void {
2731
// Create an accessor for this element.
2832
const root = ReactiveRootImpl.from(
@@ -45,7 +49,6 @@ export function defineSignalComponent<CompDef extends ComponentDefinition>(
4549
value: skewerCaseToPascalCase(tagName),
4650
});
4751

48-
customElements.define(tagName, Component);
49-
50-
return Component as unknown as Class<HydroActiveComponent & CompDef>;
52+
return Component as unknown as
53+
Class<HydroActiveComponent & CompDef> & Defineable;
5154
}

‎src/utils/on-demand-definitions.html

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>`on-demand-definitions` tests</title>
5+
<meta charset="utf8">
6+
</head>
7+
<body></body>
8+
</html>
+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { createDefine, defineIfSupported } from './on-demand-definitions.js';
2+
3+
describe('on-demand-definitions', () => {
4+
describe('defineIfSupported', () => {
5+
it('calls static `define` on a supporting class', () => {
6+
class MyElement extends HTMLElement {
7+
static define = jasmine.createSpy<() => void>('define');
8+
}
9+
10+
defineIfSupported(MyElement);
11+
12+
expect(MyElement.define).toHaveBeenCalledOnceWith();
13+
});
14+
15+
it('ignores classes which do not implement the protocol', () => {
16+
class MyElement extends HTMLElement {
17+
// No `define` property.
18+
// static define(): void { /* ... */ }
19+
}
20+
21+
expect(() => defineIfSupported(MyElement)).not.toThrow();
22+
});
23+
});
24+
25+
describe('createDefine', () => {
26+
it('defines in the global registry', () => {
27+
class MyElement extends HTMLElement {
28+
static define = createDefine('define-protocol--global-reg', this);
29+
}
30+
31+
expect(customElements.get('define-protocol--global-reg')).toBeUndefined();
32+
33+
MyElement.define();
34+
35+
expect(customElements.get('define-protocol--global-reg')).toBe(MyElement);
36+
});
37+
38+
it('no-ops when called multiple times', () => {
39+
class MyElement extends HTMLElement {
40+
static define = createDefine('define-protocol--multi', this);
41+
}
42+
43+
MyElement.define();
44+
expect(() => MyElement.define()).not.toThrow();
45+
});
46+
47+
it('no-ops when `customElements.define` was already called', () => {
48+
class MyElement extends HTMLElement {
49+
static define = createDefine('define-protocol--already-defined', this);
50+
}
51+
52+
customElements.define('define-protocol--already-defined', MyElement);
53+
54+
expect(() => MyElement.define()).not.toThrow();
55+
});
56+
57+
it('throws when the element was already defined with a different class', () => {
58+
class MyElement extends HTMLElement {
59+
static define = createDefine('define-protocol--conflict', this);
60+
}
61+
62+
customElements.define(
63+
'define-protocol--conflict', class extends HTMLElement {});
64+
65+
expect(() => MyElement.define()).toThrowError(/already defined/);
66+
});
67+
68+
it('passes through element definition options', () => {
69+
class MyElement extends HTMLParagraphElement {
70+
static define = createDefine('define-protocol--options', this, {
71+
extends: 'p',
72+
});
73+
}
74+
75+
MyElement.define();
76+
77+
const p = document.createElement('p', {
78+
is: 'define-protocol--options',
79+
});
80+
expect(p).toBeInstanceOf(MyElement);
81+
});
82+
});
83+
});

‎src/utils/on-demand-definitions.ts

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @fileoverview Provides primitives to easily implement the on-demand
3+
* definitions community protocol.
4+
*
5+
* @see https://github.com/webcomponents-cg/community-protocols/pull/67
6+
*/
7+
8+
/**
9+
* Defines the custom element.
10+
*
11+
* @param registry The registry to define the custom element in. Defaults to the
12+
* global {@link customElements} registry.
13+
* @param tagName The tag name to define the custom element as. Uses a default
14+
* tag name when not specified. Using an explicit tag name is only supported
15+
* when using a non-global registry
16+
*/
17+
export type Define =
18+
(registry?: CustomElementRegistry, tagName?: string) => void;
19+
20+
/**
21+
* A class definition which implements the on-demand definitions community
22+
* protocol.
23+
*
24+
* Note that because `define` is static, this type should be applied to the
25+
* custom element class type, not the instance type.
26+
*
27+
* ```typescript
28+
* class MyElement extends HTMLElement {
29+
* static define() { ... }
30+
* }
31+
*
32+
* const definable = MyElement as Defineable;
33+
* ```
34+
*/
35+
export interface Defineable {
36+
/**
37+
* Defines the custom element.
38+
*
39+
* @param registry The registry to define the custom element in. Defaults to
40+
* the global {@link customElements} registry.
41+
* @param tagName The tag name to define the custom element as. Uses a default
42+
* tag name when not specified. Using an explicit tag name is only
43+
* supported when a using non-global registry
44+
*/
45+
define: Define;
46+
}
47+
48+
/**
49+
* Defines the provided custom element in the global registry if that element
50+
* implements the on-demand definitions community protocol.
51+
*
52+
* @param Clazz The custom element class to define.
53+
*/
54+
export function defineIfSupported(Clazz: typeof Element): void {
55+
(Clazz as Partial<Defineable>).define?.();
56+
}
57+
58+
/**
59+
* Creates a {@link Define} function which defines the given custom element with
60+
* the default tag name. The returned function should be used as the static
61+
* `define` function in a {@link Defineable} custom element.
62+
*
63+
* @param defaultTagName The tag name to use in the global registry and by
64+
* default for scoped registries.
65+
* @param Clazz The custom element class to define.
66+
* @param options Options for the {@link CustomElementRegistry.prototype.define}
67+
* call.
68+
*/
69+
export function createDefine(
70+
defaultTagName: string,
71+
Clazz: typeof HTMLElement,
72+
options?: ElementDefinitionOptions,
73+
): Define {
74+
return (registry = customElements, tagName = defaultTagName) => {
75+
// Tag name can only be modified when not in the global registry.
76+
if (registry === customElements && tagName !== defaultTagName) {
77+
throw new Error('Cannot use a non-default tag name in the global custom element registry.');
78+
}
79+
80+
// Check if the tag name was already defined by another class.
81+
const existing = registry.get(tagName);
82+
if (existing) {
83+
if (existing === Clazz) {
84+
return; // Already defined as the correct class, no-op.
85+
} else {
86+
throw new Error(`Tag name \`${tagName}\` already defined as \`${
87+
existing.name}\`.`);
88+
}
89+
}
90+
91+
// Define the class.
92+
registry.define(tagName, Clazz, options);
93+
};
94+
}

0 commit comments

Comments
 (0)
Please sign in to comment.