Skip to content

fix: revert FASTElement.compose() to synchronous return in @microsoft/fast-element #7576

@radium-v

Description

@radium-v

🐛 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:

  1. Every existing v2 call site breaks with TS2339 on .define(), .template, .styles, etc.
  2. 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).
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions