Skip to content

Commit d206d72

Browse files
authored
feat: add extensions array argument to FASTElement.define() (#7465)
# Pull Request ## 📖 Description Adds support for an optional extensions array parameter on `FASTElement.define()` and `FASTElementDefinition.define()`. Extensions are `FASTElementExtension` callbacks that receive the resolved `FASTElementDefinition` and are invoked **before** platform registration via `customElements.define()`, enabling a plugin pattern for element registration hooks. **Example:** ```typescript class MyComponent extends FASTElement {} MyComponent.define({ name: "my-component" }, [myPlugin()]); ``` ## 👩‍💻 Reviewer Notes - Extensions run before `customElements.define()` so side-effects are available before element upgrade. - The `FASTElementExtension` type receives the full `FASTElementDefinition` (not `PartialFASTElementDefinition`) so extensions have access to resolved metadata. - Both method-style (`MyEl.define(config, extensions)`) and static-style (`FASTElement.define(MyEl, config, extensions)`) calling conventions are supported. - Rebased onto latest `releases/fast-element-v3` which made `compose`/`define` async (returning Promises). The extensions parameter integrates with the new async `define` flow — extensions are passed through to `FASTElementDefinition.define()` and invoked before platform registration. - Removed the separate `composeAsync`/`defineAsync` methods that were previously in this branch since the base branch now handles async natively. ## 📑 Test Plan All existing fast-element tests pass. Added 7 new Playwright tests covering: - Method-style define with extensions - Static-style define with extensions - Multiple extensions called in order - Extensions called before platform registration - Empty extensions array - Extension receives FASTElementDefinition instance - Factory pattern with extensions ## ✅ Checklist ### General - [x] I have included a change request file using `$ npm run change` - [x] I have added tests for my changes. - [x] I have tested my changes. - [x] I have updated the project documentation to reflect my changes. - [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/main/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/main/CODE_OF_CONDUCT.md#our-standards) for this project.
1 parent 2759b18 commit d206d72

14 files changed

Lines changed: 483 additions & 122 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Add extensions array argument to FASTElement.define() and FASTElementDefinition.define()",
4+
"packageName": "@microsoft/fast-element",
5+
"email": "7559015+janechu@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

packages/fast-element/DESIGN.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ The `KernelServiceId` object controls which numeric/string keys are used for sha
103103

104104
`FASTElementDefinition` wraps all the metadata for a custom element class: its tag name, template, styles, and observed attribute list. It is created by `FASTElement.compose()` (which returns `Promise<FASTElementDefinition>`, always resolving immediately) and registered globally via `fastElementRegistry`. `FASTElement.define()` returns `Promise<TType>` — resolving immediately for complete definitions or deferring when `templateOptions` is `"defer-and-hydrate"` and no template is provided. `FASTElementDefinition.register()` returns `Promise<Function>` — resolving when a definition with the given name has been registered.
105105

106+
#### Extensions
107+
108+
`FASTElement.define()` and `FASTElementDefinition.define()` accept an optional array of **extension callbacks** (`FASTElementExtension`). Each extension is a function that receives the resolved `FASTElementDefinition` and is invoked **before** the element is registered with the platform via `customElements.define()`. This allows plugins to inspect or act on the definition metadata (name, template, styles, attributes) before any existing DOM elements are upgraded.
109+
110+
```typescript
111+
type FASTElementExtension = (definition: FASTElementDefinition) => void;
112+
```
113+
114+
Extensions are typically produced by factory functions:
115+
116+
```typescript
117+
function myPlugin() {
118+
return (definition: FASTElementDefinition) => {
119+
console.log(`Registering: ${definition.name}`);
120+
};
121+
}
122+
123+
MyComponent.define({ name: "my-component" }, [myPlugin()]);
124+
```
125+
106126
---
107127

108128
### Observables & Notifiers

packages/fast-element/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,31 @@ this.$fastController.isPrerendered.then(prerendered => {
7272
});
7373
```
7474

75-
Custom directives can also await `controller.isPrerendered` (a `Promise<boolean>` on the `ViewController` interface) to determine whether the view's content was prerendered.
75+
Custom directives can also await `controller.isPrerendered` (a `Promise<boolean>` on the `ViewController` interface) to determine whether the view's content was prerendered.
76+
77+
## Define Extensions
78+
79+
`FASTElement.define()` accepts an optional second argument — an array of extension callbacks that are invoked with the resolved element definition before the element is registered with the platform. This enables a plugin pattern where reusable behaviors can hook into element registration.
80+
81+
```typescript
82+
import { FASTElement } from "@microsoft/fast-element";
83+
import type { FASTElementExtension } from "@microsoft/fast-element";
84+
85+
function logger(): FASTElementExtension {
86+
return definition => {
87+
console.log(`Defining element: ${definition.name}`);
88+
};
89+
}
90+
91+
class MyComponent extends FASTElement {
92+
// component code
93+
}
94+
95+
// Method style
96+
MyComponent.define({ name: "my-component", template, styles }, [logger()]);
97+
98+
// Static style
99+
FASTElement.define(MyComponent, { name: "my-component" }, [logger()]);
100+
```
101+
102+
Each extension receives the full `FASTElementDefinition`, which includes the resolved element name, type, template, styles, and attribute metadata. Extensions run before `customElements.define()`, so any setup they perform is available when existing DOM elements are upgraded.

packages/fast-element/docs/api-report.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ export class FASTElementDefinition<TType extends Constructable<HTMLElement> = Co
453453
readonly attributeLookup: Record<string, AttributeDefinition>;
454454
readonly attributes: ReadonlyArray<AttributeDefinition>;
455455
static compose<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>>(type: TType, nameOrDef?: string | PartialFASTElementDefinition): Promise<FASTElementDefinition<TType>>;
456-
define(registry?: CustomElementRegistry): this;
456+
define(registry?: CustomElementRegistry, extensions?: FASTElementExtension[]): this;
457457
readonly elementOptions: ElementDefinitionOptions;
458458
static readonly getByType: (key: Function) => FASTElementDefinition<Constructable<HTMLElement>> | undefined;
459459
static readonly getForInstance: (object: any) => FASTElementDefinition<Constructable<HTMLElement>> | undefined;
@@ -475,6 +475,9 @@ export class FASTElementDefinition<TType extends Constructable<HTMLElement> = Co
475475
readonly type: TType;
476476
}
477477

478+
// @public
479+
export type FASTElementExtension = (definition: FASTElementDefinition) => void;
480+
478481
// Warning: (ae-internal-missing-underscore) The name "fastElementRegistry" should be prefixed with an underscore because the declaration is marked as @internal
479482
//
480483
// @internal

packages/fast-element/src/components/fast-definitions.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ export interface TemplateLifecycleCallbacks {
6363
elementDidDefine?(name: string): void;
6464
}
6565

66+
/**
67+
* A callback that receives a FASTElementDefinition during element registration.
68+
* Extensions are invoked before the element is registered with the platform,
69+
* allowing plugins to inspect or act on the resolved definition.
70+
* @public
71+
*/
72+
export type FASTElementExtension = (definition: FASTElementDefinition) => void;
73+
6674
/**
6775
* Represents metadata configuration for a custom element.
6876
* @public
@@ -261,13 +269,24 @@ export class FASTElementDefinition<
261269
/**
262270
* Defines a custom element based on this definition.
263271
* @param registry - The element registry to define the element in.
272+
* @param extensions - An optional array of extension callbacks to invoke
273+
* with this definition before platform registration.
264274
* @remarks
265275
* This operation is idempotent per registry.
266276
*/
267-
public define(registry: CustomElementRegistry = this.registry): this {
277+
public define(
278+
registry: CustomElementRegistry = this.registry,
279+
extensions?: FASTElementExtension[],
280+
): this {
268281
const type = this.type;
269282

270283
if (!registry.get(this.name)) {
284+
if (extensions) {
285+
for (const extension of extensions) {
286+
extension(this);
287+
}
288+
}
289+
271290
this.platformDefined = true;
272291
registry.define(this.name, type as any, this.elementOptions);
273292
this.lifecycleCallbacks?.elementDidDefine?.(this.name);
@@ -334,6 +353,7 @@ export class FASTElementDefinition<
334353
);
335354
});
336355
};
356+
337357
}
338358

339359
Observable.defineProperty(FASTElementDefinition.prototype, "template");

packages/fast-element/src/components/fast-element.pw.spec.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,200 @@ test.describe("FASTElement", () => {
2525

2626
expect(hasProperties).toBe(true);
2727
});
28+
29+
test.describe("define with extensions", () => {
30+
test("should call extensions with the definition when using method style", async ({
31+
page,
32+
}) => {
33+
await page.goto("/");
34+
35+
const result = await page.evaluate(async () => {
36+
// @ts-expect-error: Client module.
37+
const { FASTElement, uniqueElementName } = await import("/main.js");
38+
39+
const extensionCalls: string[] = [];
40+
41+
class TestElement extends FASTElement {}
42+
43+
const elName = uniqueElementName();
44+
const extension = (def: any) => {
45+
extensionCalls.push(def.name);
46+
};
47+
48+
await TestElement.define({ name: elName }, [extension]);
49+
50+
return {
51+
callCount: extensionCalls.length,
52+
receivedName: extensionCalls[0],
53+
expectedName: elName,
54+
};
55+
});
56+
57+
expect(result.callCount).toBe(1);
58+
expect(result.receivedName).toBe(result.expectedName);
59+
});
60+
61+
test("should call extensions with the definition when using static style", async ({
62+
page,
63+
}) => {
64+
await page.goto("/");
65+
66+
const result = await page.evaluate(async () => {
67+
// @ts-expect-error: Client module.
68+
const { FASTElement, uniqueElementName } = await import("/main.js");
69+
70+
const extensionCalls: string[] = [];
71+
72+
class TestElement extends FASTElement {}
73+
74+
const elName = uniqueElementName();
75+
const extension = (def: any) => {
76+
extensionCalls.push(def.name);
77+
};
78+
79+
await FASTElement.define(TestElement, { name: elName }, [extension]);
80+
81+
return {
82+
callCount: extensionCalls.length,
83+
receivedName: extensionCalls[0],
84+
expectedName: elName,
85+
};
86+
});
87+
88+
expect(result.callCount).toBe(1);
89+
expect(result.receivedName).toBe(result.expectedName);
90+
});
91+
92+
test("should call multiple extensions in order", async ({ page }) => {
93+
await page.goto("/");
94+
95+
const result = await page.evaluate(async () => {
96+
// @ts-expect-error: Client module.
97+
const { FASTElement, uniqueElementName } = await import("/main.js");
98+
99+
const calls: number[] = [];
100+
101+
class TestElement extends FASTElement {}
102+
103+
await TestElement.define({ name: uniqueElementName() }, [
104+
() => calls.push(1),
105+
() => calls.push(2),
106+
() => calls.push(3),
107+
]);
108+
109+
return calls;
110+
});
111+
112+
expect(result).toEqual([1, 2, 3]);
113+
});
114+
115+
test("should call extensions before platform registration", async ({ page }) => {
116+
await page.goto("/");
117+
118+
const result = await page.evaluate(async () => {
119+
// @ts-expect-error: Client module.
120+
const { FASTElement, uniqueElementName } = await import("/main.js");
121+
122+
let wasDefinedDuringExtension = false;
123+
124+
class TestElement extends FASTElement {}
125+
126+
const elName = uniqueElementName();
127+
128+
await TestElement.define({ name: elName }, [
129+
() => {
130+
wasDefinedDuringExtension =
131+
customElements.get(elName) !== undefined;
132+
},
133+
]);
134+
135+
const isDefinedAfter = customElements.get(elName) !== undefined;
136+
137+
return { wasDefinedDuringExtension, isDefinedAfter };
138+
});
139+
140+
expect(result.wasDefinedDuringExtension).toBe(false);
141+
expect(result.isDefinedAfter).toBe(true);
142+
});
143+
144+
test("should work with an empty extensions array", async ({ page }) => {
145+
await page.goto("/");
146+
147+
const isDefined = await page.evaluate(async () => {
148+
// @ts-expect-error: Client module.
149+
const { FASTElement, uniqueElementName } = await import("/main.js");
150+
151+
class TestElement extends FASTElement {}
152+
153+
const elName = uniqueElementName();
154+
await TestElement.define({ name: elName }, []);
155+
156+
return customElements.get(elName) !== undefined;
157+
});
158+
159+
expect(isDefined).toBe(true);
160+
});
161+
162+
test("should pass a FASTElementDefinition to the extension", async ({ page }) => {
163+
await page.goto("/");
164+
165+
const result = await page.evaluate(async () => {
166+
// @ts-expect-error: Client module.
167+
const { FASTElement, FASTElementDefinition, uniqueElementName } =
168+
await import("/main.js");
169+
170+
let receivedDef: any = null;
171+
172+
class TestElement extends FASTElement {}
173+
174+
const elName = uniqueElementName();
175+
await TestElement.define({ name: elName }, [
176+
def => {
177+
receivedDef = def;
178+
},
179+
]);
180+
181+
return {
182+
isDefinitionInstance: receivedDef instanceof FASTElementDefinition,
183+
hasName: receivedDef?.name === elName,
184+
hasType: typeof receivedDef?.type === "function",
185+
};
186+
});
187+
188+
expect(result.isDefinitionInstance).toBe(true);
189+
expect(result.hasName).toBe(true);
190+
expect(result.hasType).toBe(true);
191+
});
192+
193+
test("factory pattern should work with extensions", async ({ page }) => {
194+
await page.goto("/");
195+
196+
const result = await page.evaluate(async () => {
197+
// @ts-expect-error: Client module.
198+
const { FASTElement, uniqueElementName } = await import("/main.js");
199+
200+
const calls: string[] = [];
201+
202+
function myPlugin() {
203+
return (def: any) => {
204+
calls.push(`plugin:${def.name}`);
205+
};
206+
}
207+
208+
class TestElement extends FASTElement {}
209+
210+
const elName = uniqueElementName();
211+
await TestElement.define({ name: elName }, [myPlugin()]);
212+
213+
return {
214+
callCount: calls.length,
215+
firstCall: calls[0],
216+
expectedCall: `plugin:${elName}`,
217+
};
218+
});
219+
220+
expect(result.callCount).toBe(1);
221+
expect(result.firstCall).toBe(result.expectedCall);
222+
});
223+
});
28224
});

packages/fast-element/src/components/fast-element.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Observable } from "../observation/observable.js";
33
import { ElementController } from "./element-controller.js";
44
import {
55
FASTElementDefinition,
6+
type FASTElementExtension,
67
type PartialFASTElementDefinition,
78
TemplateOptions,
89
} from "./fast-definitions.js";
@@ -129,17 +130,28 @@ function compose<TType extends Constructable<HTMLElement> = Constructable<HTMLEl
129130
function define<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>>(
130131
this: TType,
131132
nameOrDef: string | PartialFASTElementDefinition,
133+
extensions?: FASTElementExtension[],
132134
): Promise<TType>;
133135
function define<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>>(
134136
type: TType,
135137
nameOrDef?: string | PartialFASTElementDefinition,
138+
extensions?: FASTElementExtension[],
136139
): Promise<TType>;
137140
function define<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>>(
138141
type: TType | string | PartialFASTElementDefinition,
139-
nameOrDef?: string | PartialFASTElementDefinition,
142+
nameOrDef?: string | PartialFASTElementDefinition | FASTElementExtension[],
143+
extensions?: FASTElementExtension[],
140144
): Promise<TType> {
145+
if (Array.isArray(nameOrDef)) {
146+
extensions = nameOrDef;
147+
nameOrDef = undefined;
148+
}
149+
141150
const composePromise = isFunction(type)
142-
? FASTElementDefinition.compose(type, nameOrDef)
151+
? FASTElementDefinition.compose(
152+
type,
153+
nameOrDef as string | PartialFASTElementDefinition | undefined,
154+
)
143155
: FASTElementDefinition.compose(this, type);
144156

145157
return composePromise.then(def => {
@@ -150,14 +162,14 @@ function define<TType extends Constructable<HTMLElement> = Constructable<HTMLEle
150162
handleChange: () => {
151163
notifier.unsubscribe(subscriber, "template");
152164
def.lifecycleCallbacks?.templateDidUpdate?.(def.name);
153-
resolve(def.define().type);
165+
resolve(def.define(undefined, extensions).type);
154166
},
155167
};
156168
notifier.subscribe(subscriber, "template");
157169
});
158170
}
159171

160-
return def.define().type;
172+
return def.define(undefined, extensions).type;
161173
});
162174
}
163175

0 commit comments

Comments
 (0)