Skip to content

Commit 15b9fa6

Browse files
janechuCopilot
andcommitted
refactor: add template resolver pipeline
Move FASTElement definition orchestration to a function-based template resolver pipeline so extensions run before template resolution and concrete templates are assigned before registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4656c46 commit 15b9fa6

8 files changed

Lines changed: 517 additions & 39 deletions

File tree

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 function-based template resolver sequencing",
4+
"packageName": "@microsoft/fast-element",
5+
"email": "7559015+janechu@users.noreply.github.com",
6+
"dependentChangeType": "none"
7+
}

packages/fast-element/DESIGN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ registry.
103103
- `onAttributeChangedCallback()` is the standard handler that processes attribute changes. During the prerendered bind, it is temporarily swapped to a no-op (see above) to avoid redundant processing of server-rendered attribute values.
104104
- Exposes `addBehavior` / `removeBehavior` for dynamic `HostBehavior` management (used by `ElementStyles`).
105105

106-
`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.
106+
`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`. `PartialFASTElementDefinition.template` may be either a concrete `ElementViewTemplate` or a `FASTElementTemplateResolver` function that receives the composed definition and returns the concrete template (sync or async). `FASTElement.define()` returns `Promise<TType>` — resolving immediately for complete definitions, deferring when `templateOptions` is `"defer-and-hydrate"` and no template is provided, and resolving template resolver functions only after extensions have had a chance to update the definition. `FASTElementDefinition.register()` returns `Promise<Function>` — resolving when a definition with the given name has been registered.
107107

108108
#### Extensions
109109

110-
`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.
110+
`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()`. `FASTElement.define()` also invokes extensions before any template resolver function is asked to produce a concrete `ElementViewTemplate`, so plugins can attach state that affects template resolution without leaving non-template values on `definition.template`.
111111

112112
```typescript
113113
type FASTElementExtension = (definition: FASTElementDefinition) => void;

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ export const FASTElement: {
426426
export class FASTElementDefinition<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>> {
427427
readonly attributeLookup: Record<string, AttributeDefinition>;
428428
readonly attributes: ReadonlyArray<AttributeDefinition>;
429-
static compose<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>>(type: TType, nameOrDef?: string | PartialFASTElementDefinition): Promise<FASTElementDefinition<TType>>;
429+
static compose<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>>(type: TType, nameOrDef?: string | PartialFASTElementDefinition<TType>): Promise<FASTElementDefinition<TType>>;
430430
define(registry?: CustomElementRegistry, extensions?: FASTElementExtension[]): this;
431431
readonly elementOptions: ElementDefinitionOptions;
432432
static readonly getByType: (key: Function) => FASTElementDefinition<Constructable<HTMLElement>> | undefined;
@@ -457,6 +457,9 @@ export type FASTElementExtension = (definition: FASTElementDefinition) => void;
457457
// @internal
458458
export const fastElementRegistry: TypeRegistry<FASTElementDefinition>;
459459

460+
// @public
461+
export type FASTElementTemplateResolver<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>> = (definition: FASTElementDefinition<TType>) => ElementViewTemplate<InstanceType<TType>> | Promise<ElementViewTemplate<InstanceType<TType>>>;
462+
460463
// @public
461464
export interface FASTGlobal {
462465
addMessages(messages: Record<number, string>): void;
@@ -725,15 +728,16 @@ export const Parser: Readonly<{
725728
}>;
726729

727730
// @public
728-
export interface PartialFASTElementDefinition {
731+
export interface PartialFASTElementDefinition<TType extends Constructable<HTMLElement> = Constructable<HTMLElement>> {
729732
readonly attributes?: (AttributeConfiguration | string)[];
730733
readonly elementOptions?: ElementDefinitionOptions;
731734
readonly lifecycleCallbacks?: TemplateLifecycleCallbacks;
732735
readonly name: string;
733736
readonly registry?: CustomElementRegistry;
734737
readonly shadowOptions?: Partial<ShadowRootOptions> | null;
735738
readonly styles?: ComposableStyles | ComposableStyles[];
736-
readonly template?: ElementViewTemplate;
739+
// Warning: (ae-forgotten-export) The symbol "FASTElementTemplateInput" needs to be exported by the entry point index.d.ts
740+
readonly template?: FASTElementTemplateInput<TType>;
737741
// @alpha
738742
readonly templateOptions?: TemplateOptions;
739743
}

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,110 @@ test.describe("FASTElementDefinition", () => {
389389
expect(extendsFASTElement).toBe(true);
390390
});
391391
});
392+
393+
test.describe("template resolvers", () => {
394+
test("keeps resolver-backed templates unresolved until define-time", async ({
395+
page,
396+
}) => {
397+
await page.goto("/");
398+
399+
const result = await page.evaluate(async () => {
400+
// @ts-expect-error: Client module.
401+
const { FASTElement, FASTElementDefinition, html, uniqueElementName } =
402+
await import("/main.js");
403+
404+
let resolveTemplate!: (value: any) => void;
405+
406+
class TestElement extends FASTElement {}
407+
408+
const elName = uniqueElementName();
409+
const template = html<TestElement>`<span>resolved</span>`;
410+
const templatePromise = new Promise<any>(resolve => {
411+
resolveTemplate = resolve;
412+
});
413+
414+
const definition = await FASTElementDefinition.compose(TestElement, {
415+
name: elName,
416+
template: () => templatePromise,
417+
});
418+
419+
const templateBeforeResolve = definition.template === undefined;
420+
const definedBeforeResolve = customElements.get(elName) !== undefined;
421+
422+
definition.define();
423+
424+
resolveTemplate(template);
425+
await customElements.whenDefined(elName);
426+
427+
const element = document.createElement(elName) as any;
428+
document.body.appendChild(element);
429+
await new Promise(resolve => requestAnimationFrame(resolve));
430+
431+
return {
432+
templateBeforeResolve,
433+
definedBeforeResolve,
434+
resolvedTemplateMatches: definition.template === template,
435+
shadowText: element.shadowRoot?.textContent ?? "",
436+
};
437+
});
438+
439+
expect(result.templateBeforeResolve).toBe(true);
440+
expect(result.definedBeforeResolve).toBe(false);
441+
expect(result.resolvedTemplateMatches).toBe(true);
442+
expect(result.shadowText).toContain("resolved");
443+
});
444+
445+
test("applies extensions only once while async registration is pending", async ({
446+
page,
447+
}) => {
448+
await page.goto("/");
449+
450+
const result = await page.evaluate(async () => {
451+
// @ts-expect-error: Client module.
452+
const { FASTElement, FASTElementDefinition, html, uniqueElementName } =
453+
await import("/main.js");
454+
455+
let resolveTemplate!: (value: any) => void;
456+
const extensionCalls: string[] = [];
457+
458+
class TestElement extends FASTElement {}
459+
460+
const elName = uniqueElementName();
461+
const template = html<TestElement>`<span>resolved</span>`;
462+
const templatePromise = new Promise<any>(resolve => {
463+
resolveTemplate = resolve;
464+
});
465+
466+
const definition = await FASTElementDefinition.compose(TestElement, {
467+
name: elName,
468+
template: () => templatePromise,
469+
});
470+
471+
const extension = () => {
472+
extensionCalls.push("called");
473+
};
474+
475+
definition.define(customElements, [extension]);
476+
definition.define(customElements, [extension]);
477+
478+
await Promise.resolve();
479+
480+
const definedWhilePending = customElements.get(elName) !== undefined;
481+
const callCountWhilePending = extensionCalls.length;
482+
483+
resolveTemplate(template);
484+
await customElements.whenDefined(elName);
485+
486+
return {
487+
definedWhilePending,
488+
callCountWhilePending,
489+
finalCallCount: extensionCalls.length,
490+
};
491+
});
492+
493+
expect(result.definedWhilePending).toBe(false);
494+
expect(result.callCountWhilePending).toBe(1);
495+
expect(result.finalCallCount).toBe(1);
496+
});
497+
});
392498
});

0 commit comments

Comments
 (0)