🐛 Bug Report
In @microsoft/fast-element@3.0.0-rc.1, both FASTElement.compose() and FASTElementDefinition.compose() were changed to return Promise<FASTElementDefinition> even though, per the migration guide, the Promise "always resolves immediately." The change was made for API symmetry with define() (which is genuinely async when declarativeTemplate() defers template resolution), but compose() does no async work — it just wraps a synchronously-constructed definition in Promise.resolve(). The net effect is a contagious API breakage that forces every consumer to await (or .then()) a Promise that never had anything to wait for, with no functional benefit.
💻 Repro or Code Sample
Current implementation in packages/fast-element/src/components/fast-definitions.ts:
public static compose<
TType extends Constructable<HTMLElement> = Constructable<HTMLElement>,
>(
type: TType,
nameOrDef?: string | PartialFASTElementDefinition<TType>,
): Promise<FASTElementDefinition<TType>> {
const definition =
fastElementBaseTypes.has(type) || fastElementRegistry.getByType(type)
? new FASTElementDefinition<TType>(class extends type {}, nameOrDef)
: new FASTElementDefinition<TType>(type, nameOrDef);
return Promise.resolve(definition);
}
The body is fully synchronous; the Promise.resolve() wrap is the only async element.
A canonical consumer pattern, used across the Fluent UI web components (42 occurrences) — looks like:
// my-button.definition.ts
export const definition = MyButton.compose({ name, template, styles });
// define.ts
definition.define(FluentDesignSystem.registry);
After upgrading to 3.0.0-rc.1 the second line fails to type-check, and at runtime definition.define(...) would throw because Promise has no define method:
TS2339: Property 'define' does not exist on type 'Promise<FASTElementDefinition<typeof MyButton>>'.
There is no await workaround at module scope without adopting top-level await (which forces a tsconfig target bump to ES2022 and is incompatible with several bundler/SSR targets), and chaining .then(d => d.define(...)) ships an unobservable Promise that callers can't synchronize on.
🤔 Expected Behavior
FASTElement.compose() and FASTElementDefinition.compose() should return FASTElementDefinition<TType> synchronously. The chained pattern MyElement.compose(opts).define(registry) should keep working without await.
FASTElement.define() returning Promise is fine — it's genuinely async when template: declarativeTemplate() defers template resolution. That's where API symmetry actually matters.
😯 Current Behavior
compose() returns a Promise that is never pending, so:
- Every existing v2 call site breaks with TS2339 on
.define(), .template, .styles, etc.
- Module-scope usage is broken — the migration guide's recommended migration is
(await FASTElementDefinition.compose(...)).define(), but top-level await is not available in every consumer environment (CommonJS interop, older TS targets, certain bundler/SSR combinations).
- Callers can't tell when the definition is "ready" because the Promise resolves on the next microtask regardless. Any code that synchronously reads
.template, .styles, or invokes .define() after the Promise resolves is doing pure ceremony.
- Existing patterns are unmigratable without rewriting every
*.definition.ts and define.ts pair or introducing IIFE wrappers that swallow async errors.
💁 Possible Solution
Revert FASTElement.compose() and FASTElementDefinition.compose() to a synchronous FASTElementDefinition<TType> return type. Keep FASTElement.define() returning Promise<TType> (that change is justified by declarativeTemplate() and the deferred template flow).
This preserves the v3 design intent for define() while eliminating the contagious async wrap on compose(). The change is back-compatible at the call site — code already written against Promise<FASTElementDefinition> (e.g. (await Element.compose(...)).define(...)) continues to work because await on a non-Promise unwraps to the value.
🔦 Context
This was caught while upgrading the Fluent UI web components monorepo from @microsoft/fast-element@2.10.4 to 3.0.0-rc.1. The migration affected ~42 component packages, each following the canonical FAST pattern of declaring export const definition = Element.compose({ ... }) at module scope and consuming it elsewhere via definition.define(registry). With the current v3 RC behavior, the entire pattern needs rewriting just to satisfy the type system, with no observable behavioral benefit — and the suggested top-level-await migration isn't available to all of our consumers.
Catching this before RC stabilizes avoids forcing every FAST consumer through a needless mechanical rewrite (and an ES2022 target bump) that the v3 design didn't intend to require.
The other v3 define-side changes (define() returning Promise<TType>, removal of defineAsync/composeAsync/registerAsync, declarativeTemplate() replacing templateOptions: 'defer-and-hydrate') are all sensible and migrate cleanly. The async-compose() piece is the odd one out.
🌍 Your Environment
- OS & Device: macOS on Apple Silicon
- Browser: N/A (TypeScript compile-time issue, reproduces in any environment)
- Version:
@microsoft/fast-element@3.0.0-rc.1
🐛 Bug Report
In
@microsoft/fast-element@3.0.0-rc.1, bothFASTElement.compose()andFASTElementDefinition.compose()were changed to returnPromise<FASTElementDefinition>even though, per the migration guide, the Promise "always resolves immediately." The change was made for API symmetry withdefine()(which is genuinely async whendeclarativeTemplate()defers template resolution), butcompose()does no async work — it just wraps a synchronously-constructed definition inPromise.resolve(). The net effect is a contagious API breakage that forces every consumer toawait(or.then()) a Promise that never had anything to wait for, with no functional benefit.💻 Repro or Code Sample
Current implementation in
packages/fast-element/src/components/fast-definitions.ts:The body is fully synchronous; the
Promise.resolve()wrap is the only async element.A canonical consumer pattern, used across the Fluent UI web components (42 occurrences) — looks like:
After upgrading to
3.0.0-rc.1the second line fails to type-check, and at runtimedefinition.define(...)would throw becausePromisehas nodefinemethod:There is no
awaitworkaround at module scope without adopting top-level await (which forces atsconfigtargetbump to ES2022 and is incompatible with several bundler/SSR targets), and chaining.then(d => d.define(...))ships an unobservable Promise that callers can't synchronize on.🤔 Expected Behavior
FASTElement.compose()andFASTElementDefinition.compose()should returnFASTElementDefinition<TType>synchronously. The chained patternMyElement.compose(opts).define(registry)should keep working withoutawait.FASTElement.define()returningPromiseis fine — it's genuinely async whentemplate: declarativeTemplate()defers template resolution. That's where API symmetry actually matters.😯 Current Behavior
compose()returns aPromisethat is never pending, so:.define(),.template,.styles, etc.(await FASTElementDefinition.compose(...)).define(), but top-levelawaitis not available in every consumer environment (CommonJS interop, older TS targets, certain bundler/SSR combinations)..template,.styles, or invokes.define()after the Promise resolves is doing pure ceremony.*.definition.tsanddefine.tspair or introducing IIFE wrappers that swallow async errors.💁 Possible Solution
Revert
FASTElement.compose()andFASTElementDefinition.compose()to a synchronousFASTElementDefinition<TType>return type. KeepFASTElement.define()returningPromise<TType>(that change is justified bydeclarativeTemplate()and the deferred template flow).This preserves the v3 design intent for
define()while eliminating the contagious async wrap oncompose(). The change is back-compatible at the call site — code already written againstPromise<FASTElementDefinition>(e.g.(await Element.compose(...)).define(...)) continues to work becauseawaiton a non-Promise unwraps to the value.🔦 Context
This was caught while upgrading the Fluent UI web components monorepo from
@microsoft/fast-element@2.10.4to3.0.0-rc.1. The migration affected ~42 component packages, each following the canonical FAST pattern of declaringexport const definition = Element.compose({ ... })at module scope and consuming it elsewhere viadefinition.define(registry). With the current v3 RC behavior, the entire pattern needs rewriting just to satisfy the type system, with no observable behavioral benefit — and the suggested top-level-await migration isn't available to all of our consumers.Catching this before RC stabilizes avoids forcing every FAST consumer through a needless mechanical rewrite (and an ES2022 target bump) that the v3 design didn't intend to require.
The other v3
define-side changes (define()returningPromise<TType>, removal ofdefineAsync/composeAsync/registerAsync,declarativeTemplate()replacingtemplateOptions: 'defer-and-hydrate') are all sensible and migrate cleanly. The async-compose()piece is the odd one out.🌍 Your Environment
@microsoft/fast-element@3.0.0-rc.1