From f89aff5e8e32c9f97625b045f452da3e4381d7ab Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:38:39 +0000 Subject: [PATCH 1/9] feat(tree): add alpha Component utilities for open-polymorphic schema Promote the Component composition namespace from the openPolymorphism integration test into @fluidframework/tree as an @alpha API, re-exported from fluid-framework. Adds unit tests, documentation, API report updates, and a changeset. --- .changeset/component-composition-alpha.md | 18 ++ .../dds/tree/api-report/tree.alpha.api.md | 21 ++ packages/dds/tree/src/entrypoints/alpha.ts | 1 + packages/dds/tree/src/index.ts | 1 + .../tree/src/simple-tree/api/componentApi.ts | 256 ++++++++++++++++++ .../dds/tree/src/simple-tree/api/index.ts | 1 + packages/dds/tree/src/simple-tree/index.ts | 1 + .../src/test/openPolymorphism.integration.ts | 170 +----------- .../test/simple-tree/api/componentApi.spec.ts | 129 +++++++++ .../api-report/fluid-framework.alpha.api.md | 21 ++ 10 files changed, 451 insertions(+), 168 deletions(-) create mode 100644 .changeset/component-composition-alpha.md create mode 100644 packages/dds/tree/src/simple-tree/api/componentApi.ts create mode 100644 packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts diff --git a/.changeset/component-composition-alpha.md b/.changeset/component-composition-alpha.md new file mode 100644 index 000000000000..7df475488ca7 --- /dev/null +++ b/.changeset/component-composition-alpha.md @@ -0,0 +1,18 @@ +--- +"@fluidframework/tree": minor +"fluid-framework": minor +"__section": tree +--- +Add `Component` utilities for composing open-polymorphic schema + +A new `@alpha` `Component` namespace is now exported from `@fluidframework/tree` (and re-exported from `fluid-framework`). It provides utilities for composing independently authored application "components" that contribute to a shared configuration, which is useful for implementing ["open polymorphism"](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) schema patterns where the set of allowed types for a field or collection can be extended by separate libraries. + +Each component is expressed as a `Component.Factory`: a function which receives a lazy reference to the composed configuration and returns the content that component contributes. Because the configuration is provided lazily, components may reference (including recursively) types contributed by other components. `Component.composeComponents` combines a set of components into a `Component.ComposedComponents`, from which the aggregated configuration and per-component content can be read. + +```typescript +const composed = Component.composeComponents(allComponents, (c) => ({ + allowedItemTypes: c.getComposed("items"), +})); +``` + +See the worked examples in `openPolymorphism.integration.ts` for end-to-end usage with SharedTree schema. diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index 31cf8db91e2a..fe86be6acadf 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -218,6 +218,27 @@ export interface CommitMetadata { // @alpha export function comparePersistedSchema(persisted: JsonCompatible, view: ImplicitFieldSchema, options: ICodecOptions): Omit; +// @alpha +export namespace Component { + export function composeComponents(allComponents: readonly Factory[], lazyConfiguration: (composed: ComposedComponents) => TConfig): ComposedComponents; + // @sealed + export interface ComposedComponents { + readonly components: readonly TComponent[]; + readonly config: TConfig; + getComponent>(factory: TFactory): ReturnType; + getComposed | undefined ? Property : never]: boolean; + }>(property: TKey): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; + getConfigured>(configurable: TConfigurable): ReturnType; + } + export interface Configurable { + configure(config: TConfigPartial, components: ComposedComponents): TResult; + } + // @input + export type Factory = (lazyConfiguration: () => TConfig) => TComponent; + export type LazyArray = () => readonly (() => T)[]; +} + // @beta export type ConciseTree = Exclude | THandle | ConciseTree[] | { [key: string]: ConciseTree; diff --git a/packages/dds/tree/src/entrypoints/alpha.ts b/packages/dds/tree/src/entrypoints/alpha.ts index c3703d2f5692..b2f4db81edf1 100644 --- a/packages/dds/tree/src/entrypoints/alpha.ts +++ b/packages/dds/tree/src/entrypoints/alpha.ts @@ -170,6 +170,7 @@ export { ChangeMetadata, CodecName, CodecWriteOptions, + Component, CreateIndependentTreeAlphaOptions, DirtyTreeMap, DirtyTreeStatus, diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index ba676e6acdfe..105114688b91 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -187,6 +187,7 @@ export { adaptEnum, enumFromStrings, singletonSchema, + Component, type UnsafeUnknownSchema, type TreeViewAlpha, type TreeViewBeta, diff --git a/packages/dds/tree/src/simple-tree/api/componentApi.ts b/packages/dds/tree/src/simple-tree/api/componentApi.ts new file mode 100644 index 000000000000..f58ffefc4676 --- /dev/null +++ b/packages/dds/tree/src/simple-tree/api/componentApi.ts @@ -0,0 +1,256 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { UsageError } from "@fluidframework/telemetry-utils/internal"; + +import { getOrCreate } from "../../util/index.js"; + +/** + * Utilities for composing application "components" which contribute to a shared configuration. + * + * @remarks + * This namespace helps implement "open polymorphism" design patterns for schema, where the set of allowed types + * for a field or collection can be extended by independently authored libraries (components) instead of being + * fixed up front. + * + * Tree's stored schema do not support open polymorphism: all possible implementations must be explicitly listed. + * View schema can however emulate it by carefully controlling evaluation order: + * the source code can be structured in an open polymorphism style which at runtime evaluates into closed polymorphism + * by having each component register its implementations into a central collection (typically used as + * {@link AllowedTypes}). + * + * Each component is expressed as a {@link Component.Factory}: a function which receives a lazy reference to the + * composed configuration and returns the content that component contributes. + * Because the configuration is provided lazily, components may reference (including recursively) types contributed by + * other components, as long as nothing evaluates the lazy references until composition has completed. + * Use {@link Component.composeComponents} to combine a set of components into a {@link Component.ComposedComponents}. + * + * @privateRemarks + * The examples and integration tests for this pattern live in `openPolymorphism.integration.ts`. + * Those tests and the docs here should be kept in sync. + * + * @alpha + */ +export namespace Component { + /** + * A function which takes in a lazy configuration and returns the content a component contributes. + * + * @remarks + * The lazy configuration allows the component to reference items from the composed configuration, which can + * include items the component itself contributes (allowing recursive references between components). + * + * The execution of the factory must not evaluate `lazyConfiguration` (doing so will error): + * instead the returned `TComponent` can capture `lazyConfiguration` and evaluate it at a later time + * (after all components have been composed). + * + * @typeParam TConfig - The composed configuration type made available to components. + * @typeParam TComponent - The content a component contributes. + * + * @input + * @alpha + */ + export type Factory = (lazyConfiguration: () => TConfig) => TComponent; + + /** + * A function which returns an array of lazy values which each evaluate to `T`. + * + * @remarks + * This mirrors the shape of {@link AllowedTypes} where all of the values are lazy: + * the outer function and inner functions defer evaluation until after composition is complete. + * + * @typeParam T - The type each lazy value evaluates to. + * + * @alpha + */ + export type LazyArray = () => readonly (() => T)[]; + + /** + * An item which can be configured (evaluated) against a composed configuration to produce a result. + * + * @remarks + * Use {@link Component.ComposedComponents.getConfigured} to evaluate a `Configurable`. + * The result is cached, so a given `Configurable` is only evaluated once per composition. + * + * @typeParam TConfigPartial - The configuration type made available when configuring. + * @typeParam TResult - The result produced by configuring. + * @typeParam TComponent - The content components contribute. + * + * @alpha + */ + export interface Configurable { + /** + * Produce the configured result. + * @param config - The composed configuration. + * @param components - The composed components. + */ + configure( + config: TConfigPartial, + components: ComposedComponents, + ): TResult; + } + + /** + * Implementation of {@link Component.ComposedComponents}. + * @remarks + * Not exported: instances are created via {@link Component.composeComponents}. + */ + class Config implements ComposedComponents { + public readonly componentsMap: ReadonlyMap, TComponent>; + + public readonly evaluatedMap: Map, unknown> = + new Map(); + + public readonly components: readonly TComponent[]; + + /** + * Portion of the config computed first. + */ + public readonly config: TConfig; + + public constructor( + allComponents: readonly Factory[], + lazyConfiguration: (composed: ComposedComponents) => TConfig, + ) { + // eslint-disable-next-line no-undef-init -- Explicitly undefined: `config` is populated below, after all components have been constructed, and is read lazily via `lazyConfigInner`. + let config: TConfig | undefined = undefined; + const lazyConfigInner = (): TConfig => { + if (config === undefined) { + throw new UsageError( + "Configuration not yet available: components must not evaluate the lazy configuration during composition.", + ); + } + return config; + }; + this.componentsMap = new Map(allComponents.map((c) => [c, c(lazyConfigInner)])); + this.components = [...this.componentsMap.values()]; + config = lazyConfiguration(this); + this.config = config; + } + + public getComponent>( + factory: TFactory, + ): ReturnType { + const found = this.componentsMap.get(factory); + if (found === undefined) { + throw new UsageError("Requested component not included in this configuration"); + } + return found as ReturnType; + } + + public getConfigured>( + configurable: TConfigurable, + ): ReturnType { + const found: unknown = getOrCreate(this.evaluatedMap, configurable, (c) => + c.configure(this.config, this), + ); + return found as ReturnType; + } + + public getComposed< + TKey extends keyof { + [Property in keyof TComponent as TComponent[Property] extends + | LazyArray + | undefined + ? Property + : never]: boolean; + }, + >( + property: TKey, + ): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[] { + const result = this.components.flatMap((c) => { + const prop = c[property] as LazyArray | undefined; + if (prop === undefined) { + return []; + } + return prop(); + }); + return result as (TComponent[TKey] extends LazyArray ? () => U : never)[]; + } + } + + /** + * Combine multiple {@link Component.Factory|components} into a single {@link Component.ComposedComponents}. + * + * @param allComponents - The components to compose. + * @param lazyConfiguration - Builds the composed configuration from the composed components. + * This is invoked once, after all components have been created, and can aggregate content contributed by the + * components (for example via {@link Component.ComposedComponents.getComposed}). + * @returns The composed components, from which configuration and per-component content can be read. + * + * @typeParam TConfig - The composed configuration type made available to components. + * @typeParam TComponent - The content each component contributes. + * + * @alpha + */ + export function composeComponents( + allComponents: readonly Factory[], + lazyConfiguration: (composed: ComposedComponents) => TConfig, + ): ComposedComponents { + return new Config(allComponents, lazyConfiguration); + } + + /** + * The result of composing multiple components. + * + * @remarks + * Create using {@link Component.composeComponents}. + * + * @typeParam TConfig - The composed configuration type made available to components. + * @typeParam TComponent - The content each component contributes. + * + * @sealed @alpha + */ + export interface ComposedComponents { + /** + * The components which were composed. + */ + readonly components: readonly TComponent[]; + + /** + * The configuration which was produced when composing. + */ + readonly config: TConfig; + + /** + * Get a component by its factory. + * + * @param factory - The factory used to look up the component. Must have been provided when composing. + * @returns The content created by the provided factory. + * This result is cached during composition and not reevaluated. + */ + getComponent>( + factory: TFactory, + ): ReturnType; + + /** + * Configure a {@link Component.Configurable}. + * @remarks + * The result is cached when first evaluated, so subsequent calls with the same `configurable` return the + * same result. + * @param configurable - The item to configure against this composition. + */ + getConfigured>( + configurable: TConfigurable, + ): ReturnType; + + /** + * Compose the contents of a {@link Component.LazyArray} property from all components. + * @param property - The property of the components to compose. + * @returns The concatenation of the lazy values contributed by each component for `property`. + * Components which omit the property contribute nothing. + */ + getComposed< + TKey extends keyof { + [Property in keyof TComponent as TComponent[Property] extends + | LazyArray + | undefined + ? Property + : never]: boolean; + }, + >( + property: TKey, + ): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; + } +} diff --git a/packages/dds/tree/src/simple-tree/api/index.ts b/packages/dds/tree/src/simple-tree/api/index.ts index 4faf9e3749ce..6443ccb455ee 100644 --- a/packages/dds/tree/src/simple-tree/api/index.ts +++ b/packages/dds/tree/src/simple-tree/api/index.ts @@ -65,6 +65,7 @@ export { singletonSchema, createCustomizedFluidFrameworkScopedFactory, } from "./schemaCreationUtilities.js"; +export { Component } from "./componentApi.js"; export { deltaMarksToArrayOps, getIdentifierFromNode, diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 3e4c36530889..18201ee58c37 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -89,6 +89,7 @@ export { adaptEnum, enumFromStrings, singletonSchema, + Component, treeNodeApi, type TreeNodeApi, type ArrayNodeDeltaOp, diff --git a/packages/dds/tree/src/test/openPolymorphism.integration.ts b/packages/dds/tree/src/test/openPolymorphism.integration.ts index ad1d6a922565..2bb249797e2c 100644 --- a/packages/dds/tree/src/test/openPolymorphism.integration.ts +++ b/packages/dds/tree/src/test/openPolymorphism.integration.ts @@ -5,12 +5,12 @@ import { strict as assert } from "node:assert"; -import { UsageError } from "@fluidframework/telemetry-utils/internal"; import { validateUsageError } from "@fluidframework/test-runtime-utils/internal"; import { Tree } from "../shared-tree/index.js"; import { allowUnused, + Component, evaluateLazySchema, SchemaFactory, TreeBeta, @@ -21,7 +21,7 @@ import { type TreeNodeSchema, type Unhydrated, } from "../simple-tree/index.js"; -import { getOrAddInMap, type requireAssignableTo } from "../util/index.js"; +import type { requireAssignableTo } from "../util/index.js"; /** * Examples and tests for open polymorphism design patterns for schema. @@ -573,169 +573,3 @@ export namespace ComponentMinimal { return itemTypes; } } - -/** - * Utilities for helping implement various application component design patterns. - */ -export namespace Component { - /** - * Function which takes in a lazy configuration and returns a collection of schema types. - * @remarks - * This allows the schema to reference items from the configuration, which could include themselves recursively. - * - * The execution of this function may not evaluate `lazyConfiguration` (doing so will error): - * instead the returned `TComponent` can capture the `lazyConfiguration` and evaluate it at a later time (after all components have been composed). - */ - export type Factory = (lazyConfiguration: () => TConfig) => TComponent; - - /** - * A function which returns an array of lazy values (like {@link AllowedTypes} where all of the values are lazy) which evaluate to `T`. - */ - export type LazyArray = () => readonly (() => T)[]; - - class Config implements ComposedComponents { - public readonly componentsMap: ReadonlyMap, TComponent>; - - public readonly evaluatedMap: Map, unknown> = - new Map(); - - public readonly components: readonly TComponent[]; - - /** - * Portion of the config computed first. - */ - public readonly config: TConfig; - - public constructor( - allComponents: readonly Factory[], - lazyConfiguration: (composed: ComposedComponents) => TConfig, - ) { - // eslint-disable-next-line no-undef-init - let config: TConfig | undefined = undefined; - const lazyConfigInner = () => { - if (config === undefined) { - throw new Error("Configuration not yet available"); - } - return config; - }; - this.componentsMap = new Map(allComponents.map((c) => [c, c(lazyConfigInner)])); - this.components = [...this.componentsMap.values()]; - config = lazyConfiguration(this); - this.config = config; - } - - public getComponent>( - factory: TFactory, - ): ReturnType { - const found = this.componentsMap.get(factory); - if (found === undefined) { - throw new UsageError("Requested component not included in this configuration"); - } - return found as ReturnType; - } - - public getConfigured>( - factory: TEvaluatable, - ): ReturnType { - const found: unknown = getOrAddInMap( - this.evaluatedMap, - factory, - factory.configure(this.config, this), - ); - if (found === undefined) { - throw new UsageError("Requested component not included in this configuration"); - } - return found as ReturnType; - } - - public getComposed< - TKey extends keyof { - [Property in keyof TComponent as TComponent[Property] extends LazyArray - ? Property - : never]: boolean; - }, - >( - property: TKey, - ): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[] { - const result = this.components.flatMap((c) => { - const prop = c[property] as LazyArray; - if (prop === undefined) { - return []; - } - return prop(); - }); - return result as (TComponent[TKey] extends LazyArray ? () => U : never)[]; - } - } - - export interface Configurable { - configure( - config: TConfigPartial, - components: ComposedComponents, - ): TResult; - } - - /** - * Combine multiple {@link ComponentMinimal.ComponentSchemaCollection}s into a single {@link AllowedTypes} array. - */ - export function composeComponents( - allComponents: readonly Factory[], - lazyConfiguration: (composed: ComposedComponents) => TConfig, - ): ComposedComponents { - const config = new Config(allComponents, lazyConfiguration); - return config; - } - - /** - * The result of composing multiple components. - * @remarks - * Create using {@link Component.composeComponents}. - * @sealed - */ - export interface ComposedComponents { - /** - * The components which were composed. - */ - readonly components: readonly TComponent[]; - /** - * The configuration which was provided when composing. - */ - readonly config: TConfig; - - /** - * Get a component by its factory. - * - * @param factory - The factory to indicate which component to lookup. Must have been provided when composing. - * @returns The component created by the provided factory. - * This result is cached during composition and not reevaluated. - */ - getComponent>( - factory: TFactory, - ): ReturnType; - - /** - * Configure a {@link Configurable}. - * @remarks - * The result is cached when first evaluated. - */ - getConfigured>( - configurable: TConfigurable, - ): ReturnType; - - /** - * Compose the contents of a lazy array property from all components. - * @param property - The property of the components to compose. - */ - getComposed< - TKey extends keyof { - [Property in keyof TComponent as TComponent[Property] extends - | LazyArray - | undefined - ? Property - : never]: boolean; - }, - >( - property: TKey, - ): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; - } -} diff --git a/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts b/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts new file mode 100644 index 000000000000..9e4616de1cd3 --- /dev/null +++ b/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts @@ -0,0 +1,129 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { validateUsageError } from "@fluidframework/test-runtime-utils/internal"; + +import { Component } from "../../../simple-tree/index.js"; + +/** + * Unit tests for the {@link Component} composition utilities. + * + * @remarks + * These supplement the worked examples/integration tests in `openPolymorphism.integration.ts` by covering the + * individual behaviors of the API in isolation. + */ +describe("Component", () => { + // The composed configuration used by these tests. + interface TestConfig { + readonly items: readonly (() => string)[]; + } + + // The content each component contributes. + interface TestComponent { + readonly items?: Component.LazyArray; + readonly label?: string; + } + + type TestFactory = Component.Factory; + + function compose( + components: readonly TestFactory[], + ): Component.ComposedComponents { + return Component.composeComponents(components, (composed) => ({ + items: composed.getComposed("items"), + })); + } + + it("getComposed aggregates lazy arrays from all components", () => { + const a: TestFactory = () => ({ items: () => [() => "a1", () => "a2"] }); + const b: TestFactory = () => ({ items: () => [() => "b1"] }); + + const composed = compose([a, b]); + const evaluated = composed.config.items.map((lazy) => lazy()); + assert.deepEqual(evaluated, ["a1", "a2", "b1"]); + }); + + it("getComposed skips components which omit the property", () => { + const withItems: TestFactory = () => ({ items: () => [() => "x"] }); + const withoutItems: TestFactory = () => ({ label: "no items" }); + + const composed = compose([withItems, withoutItems]); + assert.deepEqual( + composed.config.items.map((lazy) => lazy()), + ["x"], + ); + }); + + it("getComponent returns the content produced by a factory", () => { + const factory: TestFactory = () => ({ label: "hello" }); + const composed = compose([factory]); + assert.equal(composed.getComponent(factory).label, "hello"); + }); + + it("getComponent throws for a factory not included in the composition", () => { + const included: TestFactory = () => ({ label: "included" }); + const excluded: TestFactory = () => ({ label: "excluded" }); + const composed = compose([included]); + assert.throws(() => composed.getComponent(excluded), validateUsageError(/not included/)); + }); + + it("components can lazily reference the composed configuration", () => { + // This component contributes an item derived from the full composed configuration, + // demonstrating that the lazy configuration can be read after composition completes. + const counter: TestFactory = (lazyConfig) => ({ + items: () => [() => `count:${lazyConfig().items.length}`], + }); + const other: TestFactory = () => ({ items: () => [() => "other"] }); + + const composed = compose([counter, other]); + // Two items total: the counter's own item and "other". + const values = composed.config.items.map((lazy) => lazy()); + assert.deepEqual(values, ["count:2", "other"]); + }); + + it("throws if a component evaluates the configuration during composition", () => { + const eager: TestFactory = (lazyConfig) => { + // Reading the configuration during composition is not allowed. + lazyConfig(); + return {}; + }; + assert.throws( + () => compose([eager]), + validateUsageError(/Configuration not yet available/), + ); + }); + + it("getConfigured evaluates once and caches the result", () => { + let evaluations = 0; + const factory: TestFactory = () => ({ label: "cached" }); + const composed = compose([factory]); + + const configurable: Component.Configurable = { + configure: (config) => { + evaluations += 1; + return config.items.length; + }, + }; + + const first = composed.getConfigured(configurable); + const second = composed.getConfigured(configurable); + assert.equal(first, second); + assert.equal(evaluations, 1); + }); + + it("exposes the composed components and configuration", () => { + const a: TestFactory = () => ({ label: "a" }); + const b: TestFactory = () => ({ label: "b" }); + const composed = compose([a, b]); + + assert.deepEqual( + composed.components.map((c) => c.label), + ["a", "b"], + ); + assert.deepEqual(composed.config.items, []); + }); +}); diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index 1efea09b7af7..54eabf06c30a 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -225,6 +225,27 @@ export interface CommitMetadata { // @alpha export function comparePersistedSchema(persisted: JsonCompatible, view: ImplicitFieldSchema, options: ICodecOptions): Omit; +// @alpha +export namespace Component { + export function composeComponents(allComponents: readonly Factory[], lazyConfiguration: (composed: ComposedComponents) => TConfig): ComposedComponents; + // @sealed + export interface ComposedComponents { + readonly components: readonly TComponent[]; + readonly config: TConfig; + getComponent>(factory: TFactory): ReturnType; + getComposed | undefined ? Property : never]: boolean; + }>(property: TKey): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; + getConfigured>(configurable: TConfigurable): ReturnType; + } + export interface Configurable { + configure(config: TConfigPartial, components: ComposedComponents): TResult; + } + // @input + export type Factory = (lazyConfiguration: () => TConfig) => TComponent; + export type LazyArray = () => readonly (() => T)[]; +} + // @beta export type ConciseTree = Exclude | THandle | ConciseTree[] | { [key: string]: ConciseTree; From 0c4a04575108c7c34a59686cff82511b8f0e056a Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:09:35 -0700 Subject: [PATCH 2/9] Fix changeset title formatting --- .changeset/component-composition-alpha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/component-composition-alpha.md b/.changeset/component-composition-alpha.md index 7df475488ca7..b2fea77bcdf3 100644 --- a/.changeset/component-composition-alpha.md +++ b/.changeset/component-composition-alpha.md @@ -3,7 +3,7 @@ "fluid-framework": minor "__section": tree --- -Add `Component` utilities for composing open-polymorphic schema +Add Component utilities for composing open-polymorphic schema A new `@alpha` `Component` namespace is now exported from `@fluidframework/tree` (and re-exported from `fluid-framework`). It provides utilities for composing independently authored application "components" that contribute to a shared configuration, which is useful for implementing ["open polymorphism"](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) schema patterns where the set of allowed types for a field or collection can be extended by separate libraries. From 84d68528ef255ed423c3a829e20c09fe9db1fcdc Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:14:31 +0000 Subject: [PATCH 3/9] Better file location --- packages/dds/tree/src/{simple-tree/api => }/componentApi.ts | 4 +++- packages/dds/tree/src/index.ts | 2 +- packages/dds/tree/src/simple-tree/api/index.ts | 1 - packages/dds/tree/src/simple-tree/index.ts | 1 - packages/dds/tree/src/test/openPolymorphism.integration.ts | 2 +- .../dds/tree/src/test/simple-tree/api/componentApi.spec.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename packages/dds/tree/src/{simple-tree/api => }/componentApi.ts (97%) diff --git a/packages/dds/tree/src/simple-tree/api/componentApi.ts b/packages/dds/tree/src/componentApi.ts similarity index 97% rename from packages/dds/tree/src/simple-tree/api/componentApi.ts rename to packages/dds/tree/src/componentApi.ts index f58ffefc4676..004a8447f14c 100644 --- a/packages/dds/tree/src/simple-tree/api/componentApi.ts +++ b/packages/dds/tree/src/componentApi.ts @@ -5,7 +5,7 @@ import { UsageError } from "@fluidframework/telemetry-utils/internal"; -import { getOrCreate } from "../../util/index.js"; +import { getOrCreate } from "./util/index.js"; /** * Utilities for composing application "components" which contribute to a shared configuration. @@ -27,6 +27,8 @@ import { getOrCreate } from "../../util/index.js"; * other components, as long as nothing evaluates the lazy references until composition has completed. * Use {@link Component.composeComponents} to combine a set of components into a {@link Component.ComposedComponents}. * + * See {@link https://github.com/microsoft/FluidFramework/blob/main/packages/dds/tree/src/test/openPolymorphism.integration.ts|openPolymorphism.integration.ts} for worked examples of this pattern. + * * @privateRemarks * The examples and integration tests for this pattern live in `openPolymorphism.integration.ts`. * Those tests and the docs here should be kept in sync. diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index 105114688b91..7cf14efaa054 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -187,7 +187,6 @@ export { adaptEnum, enumFromStrings, singletonSchema, - Component, type UnsafeUnknownSchema, type TreeViewAlpha, type TreeViewBeta, @@ -412,3 +411,4 @@ export { utf16LengthForCodePoints, } from "./text/index.js"; export { ExtensibleUnionNode } from "./extensibleUnionNode.js"; +export { Component } from "./componentApi.js"; diff --git a/packages/dds/tree/src/simple-tree/api/index.ts b/packages/dds/tree/src/simple-tree/api/index.ts index 6443ccb455ee..4faf9e3749ce 100644 --- a/packages/dds/tree/src/simple-tree/api/index.ts +++ b/packages/dds/tree/src/simple-tree/api/index.ts @@ -65,7 +65,6 @@ export { singletonSchema, createCustomizedFluidFrameworkScopedFactory, } from "./schemaCreationUtilities.js"; -export { Component } from "./componentApi.js"; export { deltaMarksToArrayOps, getIdentifierFromNode, diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 18201ee58c37..3e4c36530889 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -89,7 +89,6 @@ export { adaptEnum, enumFromStrings, singletonSchema, - Component, treeNodeApi, type TreeNodeApi, type ArrayNodeDeltaOp, diff --git a/packages/dds/tree/src/test/openPolymorphism.integration.ts b/packages/dds/tree/src/test/openPolymorphism.integration.ts index 2bb249797e2c..9ed596f75063 100644 --- a/packages/dds/tree/src/test/openPolymorphism.integration.ts +++ b/packages/dds/tree/src/test/openPolymorphism.integration.ts @@ -10,7 +10,6 @@ import { validateUsageError } from "@fluidframework/test-runtime-utils/internal" import { Tree } from "../shared-tree/index.js"; import { allowUnused, - Component, evaluateLazySchema, SchemaFactory, TreeBeta, @@ -22,6 +21,7 @@ import { type Unhydrated, } from "../simple-tree/index.js"; import type { requireAssignableTo } from "../util/index.js"; +import { Component } from "../componentApi.js"; /** * Examples and tests for open polymorphism design patterns for schema. diff --git a/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts b/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts index 9e4616de1cd3..d1f3832c3a23 100644 --- a/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts @@ -7,7 +7,7 @@ import { strict as assert } from "node:assert"; import { validateUsageError } from "@fluidframework/test-runtime-utils/internal"; -import { Component } from "../../../simple-tree/index.js"; +import { Component } from "../../../componentApi.js"; /** * Unit tests for the {@link Component} composition utilities. From 73f94948bf6f04432b055eeaa362b316ccb2e8d2 Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:53:37 +0000 Subject: [PATCH 4/9] Tweaks, and more examples --- .../dds/tree/api-report/tree.alpha.api.md | 3 +- packages/dds/tree/src/componentApi.ts | 108 ++++- .../src/test/openPolymorphism.integration.ts | 426 ++++++++++++------ .../test/simple-tree/api/componentApi.spec.ts | 152 +++++++ 4 files changed, 515 insertions(+), 174 deletions(-) diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index fe86be6acadf..20efe573d604 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -228,9 +228,10 @@ export namespace Component { getComponent>(factory: TFactory): ReturnType; getComposed | undefined ? Property : never]: boolean; - }>(property: TKey): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; + }>(property: TKey): readonly (Exclude extends LazyArray ? () => U : never)[]; getConfigured>(configurable: TConfigurable): ReturnType; } + const memoize: (factory: () => T) => (() => T); export interface Configurable { configure(config: TConfigPartial, components: ComposedComponents): TResult; } diff --git a/packages/dds/tree/src/componentApi.ts b/packages/dds/tree/src/componentApi.ts index 004a8447f14c..c3e60629752d 100644 --- a/packages/dds/tree/src/componentApi.ts +++ b/packages/dds/tree/src/componentApi.ts @@ -11,15 +11,20 @@ import { getOrCreate } from "./util/index.js"; * Utilities for composing application "components" which contribute to a shared configuration. * * @remarks - * This namespace helps implement "open polymorphism" design patterns for schema, where the set of allowed types + * Nothing in this namespace is specific to tree schema, + * however it is designed to be able to handle needs to components which woth with {@link TreeSchema}. + * + * This is mainly used to implement "open polymorphism", where the set of allowed types * for a field or collection can be extended by independently authored libraries (components) instead of being * fixed up front. * - * Tree's stored schema do not support open polymorphism: all possible implementations must be explicitly listed. - * View schema can however emulate it by carefully controlling evaluation order: + * This basically amounts to dependency injection, where the "injection" is done at "composition" time to build the schema. + * + * Tree's schema do not natively support open polymorphism: all possible implementations must be explicitly listed. + * These tools work around these limitations by carefully controlling evaluation order: * the source code can be structured in an open polymorphism style which at runtime evaluates into closed polymorphism - * by having each component register its implementations into a central collection (typically used as - * {@link AllowedTypes}). + * by having each component register its implementations into a central collection (typically an + * {@link AllowedTypes} array). * * Each component is expressed as a {@link Component.Factory}: a function which receives a lazy reference to the * composed configuration and returns the content that component contributes. @@ -68,6 +73,32 @@ export namespace Component { */ export type LazyArray = () => readonly (() => T)[]; + /** + * Wrap a 0 argument function to cache the result. + * @remarks + * Do not use for impure functions. + * + * Generally {@link Component} utilities have built in caching in most cases, + * but this is occasionally helpful when manually implementing laziness. + * + * Note that this takes a different approach than use in {@link evaluateLazySchema} where the function evaluation is done using a utility that adds caching. + * + * @param factory - The factory function to memoize. + * @returns A function that returns the cached result of the factory. + * @alpha + */ + export const memoize = (factory: () => T): (() => T) => { + let run = false; + let cached: T; + return () => { + if (!run) { + cached = factory(); + run = true; + } + return cached; + }; + }; + /** * An item which can be configured (evaluated) against a composed configuration to produce a result. * @@ -95,20 +126,32 @@ export namespace Component { /** * Implementation of {@link Component.ComposedComponents}. - * @remarks - * Not exported: instances are created via {@link Component.composeComponents}. */ class Config implements ComposedComponents { public readonly componentsMap: ReadonlyMap, TComponent>; - public readonly evaluatedMap: Map, unknown> = + /** + * Cache of results produced by {@link Component.ComposedComponents.getConfigured}. + * + * @remarks + * Maps each {@link Component.Configurable} to the result of evaluating it against this composition. + * This ensures a given `Configurable` is only configured once: subsequent lookups return the cached result. + */ + private readonly evaluatedMap: Map, unknown> = new Map(); - public readonly components: readonly TComponent[]; - /** - * Portion of the config computed first. + * Cache of results produced by {@link Component.ComposedComponents.getComposed}. + * + * @remarks + * Maps each composed property to the array produced for it. + * This ensures a given property is only composed once: subsequent lookups return the same array + * (including the same lazy values), so repeated calls with the same property are reference-stable. */ + private readonly composedMap: Map = new Map(); + + public readonly components: readonly TComponent[]; + public readonly config: TConfig; public constructor( @@ -144,10 +187,14 @@ export namespace Component { public getConfigured>( configurable: TConfigurable, ): ReturnType { - const found: unknown = getOrCreate(this.evaluatedMap, configurable, (c) => - c.configure(this.config, this), - ); - return found as ReturnType; + const configured: unknown = getOrCreate(this.evaluatedMap, configurable, (c) => { + const result = c.configure(this.config, this); + if (result === undefined) { + throw new UsageError("Configurable must not return undefined"); + } + return result; + }); + return configured as ReturnType; } public getComposed< @@ -160,15 +207,21 @@ export namespace Component { }, >( property: TKey, - ): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[] { - const result = this.components.flatMap((c) => { - const prop = c[property] as LazyArray | undefined; - if (prop === undefined) { - return []; - } - return prop(); - }); - return result as (TComponent[TKey] extends LazyArray ? () => U : never)[]; + ): readonly (Exclude extends LazyArray + ? () => U + : never)[] { + const result = getOrCreate(this.composedMap, property, () => + this.components.flatMap((c) => { + const prop = c[property] as LazyArray | undefined; + if (prop === undefined) { + return []; + } + return prop(); + }), + ); + return result as (Exclude extends LazyArray + ? () => U + : never)[]; } } @@ -239,6 +292,9 @@ export namespace Component { /** * Compose the contents of a {@link Component.LazyArray} property from all components. + * @remarks + * The result is cached when first evaluated, so subsequent calls with the same `property` return the + * same result. * @param property - The property of the components to compose. * @returns The concatenation of the lazy values contributed by each component for `property`. * Components which omit the property contribute nothing. @@ -253,6 +309,8 @@ export namespace Component { }, >( property: TKey, - ): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; + ): readonly (Exclude extends LazyArray + ? () => U + : never)[]; } } diff --git a/packages/dds/tree/src/test/openPolymorphism.integration.ts b/packages/dds/tree/src/test/openPolymorphism.integration.ts index 9ed596f75063..7377090dabf2 100644 --- a/packages/dds/tree/src/test/openPolymorphism.integration.ts +++ b/packages/dds/tree/src/test/openPolymorphism.integration.ts @@ -360,185 +360,315 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { ); }); - // A more complex example showing some challenging edge cases. - // Includes: - // - A component with a schema in multiple open polymorphic collections. - // - Access to exports from components which depend on the injected set of components. - it("Component library", () => { - /** - * Example application component interface. - */ - interface MyAppComponentContent { + describe("Component library", () => { + // An open polymorphic collection of schema with implementations provided by components. + it("minimal open polymorphism", () => { /** - * Item types contributed by this component. + * Example application component content type. */ - items?: Component.LazyArray; - /** - * Background types contributed by this component. - */ - backgrounds?: Component.LazyArray; - } + interface MyAppComponentContent { + /** + * Item types contributed by this component. + * We are just typing them as TreeNodeSchema here to keep things simple. + * Real use would often provide some static factory to be able to create instances, as well as some APIs all item nodes should implement. + */ + readonly items: Component.LazyArray; + } - type MyAppComponent = Component.Factory; + // To keep this example simple, we are just using the provided `ComposedComponents` + // type for the configuration passed into the component factories. + // There are a lot of customization options for this, but this example is simply avoiding all of them. + type Composed = Component.ComposedComponents; + type MyAppComponent = Component.Factory; - interface BackgroundExtensions { - html(): string; - } - type Background = TreeNode & BackgroundExtensions; - interface BackgroundStatic { - readonly description: string; - default(): Unhydrated; - } - type BackgroundSchema = TreeNodeSchema & - BackgroundStatic; + // A simple component, which does not depend on any other context. + const textComponent: MyAppComponent = () => ({ + items: () => [() => TextItem], + }); - // An example component which recursively depends on all components. - const containerComponent: MyAppComponent = (lazyConfig) => { - function createContainer(config: MyAppConfigPartial): ItemSchema { - class Container extends sf.array("Container", config.allowedItemTypes) {} - class ContainerItem extends sf.object("ContainerItem", { - ...itemFields, - container: Container, - }) { - public static readonly description = "Text"; - public static default(): TextItem { - return new TextItem({ text: "", location: { x: 0, y: 0 } }); - } + // A component which creates an item type which recursively depends on all item types. + const containerComponent: MyAppComponent = (config) => ({ + items: () => [ + () => class extends sf.array("Container", config().getComposed("items")) {}, + ], + }); - public foo(): void {} - } + const appConfig = Component.composeComponents( + [containerComponent, textComponent], + // As noted above, we are not customizing the config, so just pass it through unchanged. + (config) => config, + ); + + // The config's items can now be used to create a TreeViewConfiguration, root schema, or whatever else is needed. + class Root extends sf.object("Root", { + content: appConfig.getComposed("items"), + }) {} + + // The schema can be used like any other, but it lacks full compile time knowledge of the allowed types, + // so some casts are required to create or insert values in some cases. + const root = new Root({ + // We have no compile time information about what node types are legal here, + // so the strict typing for insertable content is `never`. + // Runtime checks are robust so we can cast here and rely on them. + content: new TextItem({ text: "x", location: { x: 0, y: 0 } }) as never, + }); + // Reading content is type safe, but type is not very specific in this example. + const child = root.content; + assert(Tree.is(child, TextItem)); + assert.equal(child.text, "x"); + }); - return ContainerItem; + it("minimal open polymorphism with `getComponent`", () => { + interface MyAppComponentContent { + readonly items: Component.LazyArray; } - return { - items: () => [() => createContainer(lazyConfig())], + type Composed = Component.ComposedComponents; + + // A component with a more specific type, exposing its `container` property so it can be queried for after composition. + const containerComponent = (config: () => Composed) => { + const container = Component.memoize( + () => class extends sf.array("Container", config().getComposed("items")) {}, + ); + return { + container, + items: () => [container], + } satisfies MyAppComponentContent & { container: unknown }; }; - }; - const textComponent: MyAppComponent = () => ({ - items: () => [() => TextItem], + const appConfig = Component.composeComponents([containerComponent], (config) => config); + + const Container = appConfig.getComponent(containerComponent).container(); + const node = new Container(); + // We have no compile time information about what node types are legal here (so the cast is needed), but it is runtime checked. + node.insertAtStart(new Container() as never); }); - class Color - extends sf.object("Color", { r: sf.number, g: sf.number, b: sf.number }) - implements Background - { - public html(): string { - return `rgb(${this.r}, ${this.g}, ${this.b})`; + it("minimal open polymorphism with static factory", () => { + interface MyAppComponentContent { + // Here we add in some requirements for the items to provide static descriptions and a default value factory. + // This could be used to generate a menu of item types to insert. + readonly items: Component.LazyArray; } - public static readonly description = "Color Background"; - public static default(): Color { - return new Color({ r: 0, g: 0, b: 0 }); + interface MyStatics { + readonly description: string; + // Real use would often want to further constrain this return type. + default(): Unhydrated; } - } - const colorsComponent: MyAppComponent = () => ({ - backgrounds: () => [() => Color], + + type Composed = Component.ComposedComponents; + type MyAppComponent = Component.Factory; + + // A simple item type, with the required statics. + class MyItem extends sf.object("MyItem", {}) { + public static readonly description = "A stateless placeholder item"; + public static default(): MyItem { + return new MyItem({}); + } + } + const myComponent: MyAppComponent = () => ({ + items: () => [() => MyItem], + }); + + // Another component + const textComponent: MyAppComponent = () => ({ + items: () => [() => TextItem], + }); + + const appConfig = Component.composeComponents( + [myComponent, textComponent], + (config) => config, + ); + + // We could use this config to build a menu showing the description of each and let a user select one of the available item types to insert. + const menu = appConfig.getComposed("items").map((lazy) => lazy()); + // Suppose they select the first item, we can create an instance like this: + const item = menu[0].default(); }); - // Example component showing how a single schema can be shared between multiple open polymorphic collections. - // Also shows how a component can export a lazy schema reference. - const comboComponent = ((lazyConfig: () => MyAppConfigPartial) => { - const blank = () => { - // This could use config if needed. - const config = lazyConfig(); + // A more complex example showing some challenging edge cases. + // Includes: + // - A component with a schema in multiple open polymorphic collections. + // - Access to exports from components which depend on the injected set of components. + it("Component library edge cases", () => { + /** + * Example application component interface. + */ + interface MyAppComponentContent { + /** + * Item types contributed by this component. + */ + items?: Component.LazyArray; + /** + * Background types contributed by this component. + */ + backgrounds?: Component.LazyArray; + } + + type MyAppComponent = Component.Factory; - class Blank extends sf.object("Blank", { ...itemFields }) implements Background, Item { - public html(): string { - return "transparent"; - } - public static readonly description = "Blank"; - public static default(): Blank { - return new Blank({ location: { x: 0, y: 0 } }); + interface BackgroundExtensions { + html(): string; + } + type Background = TreeNode & BackgroundExtensions; + interface BackgroundStatic { + readonly description: string; + default(): Unhydrated; + } + type BackgroundSchema = TreeNodeSchema & + BackgroundStatic; + + // An example component which recursively depends on all components. + const containerComponent: MyAppComponent = (lazyConfig) => { + function createContainer(config: MyAppConfigPartial): ItemSchema { + class Container extends sf.array("Container", config.allowedItemTypes) {} + class ContainerItem extends sf.object("ContainerItem", { + ...itemFields, + container: Container, + }) { + public static readonly description = "Text"; + public static default(): TextItem { + return new TextItem({ text: "", location: { x: 0, y: 0 } }); + } + + public foo(): void {} } - public foo(): void {} + + return ContainerItem; } - return Blank; - }; - return { - items: () => [blank], - backgrounds: () => [blank], - // This is not required, but shows that components can also export evaluated content if needed. - blank, + + return { + items: () => [() => createContainer(lazyConfig())], + }; }; - }) satisfies MyAppComponent; - /** - * Subset of `MyAppConfig` which is available while composing components. - */ - interface MyAppConfigPartial { + const textComponent: MyAppComponent = () => ({ + items: () => [() => TextItem], + }); + + class Color + extends sf.object("Color", { r: sf.number, g: sf.number, b: sf.number }) + implements Background + { + public html(): string { + return `rgb(${this.r}, ${this.g}, ${this.b})`; + } + public static readonly description = "Color Background"; + public static default(): Color { + return new Color({ r: 0, g: 0, b: 0 }); + } + } + const colorsComponent: MyAppComponent = () => ({ + backgrounds: () => [() => Color], + }); + + // Example component showing how a single schema can be shared between multiple open polymorphic collections. + // Also shows how a component can export a lazy schema reference. + const comboComponent = ((lazyConfig: () => MyAppConfigPartial) => { + const blank = () => { + // This could use config if needed. + const config = lazyConfig(); + + class Blank + extends sf.object("Blank", { ...itemFields }) + implements Background, Item + { + public html(): string { + return "transparent"; + } + public static readonly description = "Blank"; + public static default(): Blank { + return new Blank({ location: { x: 0, y: 0 } }); + } + public foo(): void {} + } + return Blank; + }; + return { + items: () => [blank], + backgrounds: () => [blank], + // This is not required, but shows that components can also export evaluated content if needed. + blank, + }; + }) satisfies MyAppComponent; + /** - * {@link AllowedTypes} containing all ItemSchema contributed by components. + * Subset of `MyAppConfig` which is available while composing components. */ - readonly allowedItemTypes: readonly (() => ItemSchema)[]; - readonly allowedBackgroundTypes: readonly (() => BackgroundSchema)[]; - } + interface MyAppConfigPartial { + /** + * {@link AllowedTypes} containing all ItemSchema contributed by components. + */ + readonly allowedItemTypes: readonly (() => ItemSchema)[]; + readonly allowedBackgroundTypes: readonly (() => BackgroundSchema)[]; + } - /** - * Example configuration type for an application. - * - * Contains a collection of schema to demonstrate how ComponentSchemaCollection works for schema dependency inversions. - */ - interface MyAppConfig extends MyAppConfigPartial { /** - * Set of all ItemSchema contributed by components. - * @remarks - * Same content as {@link MyAppConfig.allowedItemTypes}, but normalized into a Set. + * Example configuration type for an application. * - * This is included to demonstrate how and where to use evaluated schema. + * Contains a collection of schema to demonstrate how ComponentSchemaCollection works for schema dependency inversions. */ - readonly items: ReadonlySet; - readonly backgrounds: ReadonlySet; - readonly composed: Component.ComposedComponents< - MyAppConfigPartial, - MyAppComponentContent - >; - } + interface MyAppConfig extends MyAppConfigPartial { + /** + * Set of all ItemSchema contributed by components. + * @remarks + * Same content as {@link MyAppConfig.allowedItemTypes}, but normalized into a Set. + * + * This is included to demonstrate how and where to use evaluated schema. + */ + readonly items: ReadonlySet; + readonly backgrounds: ReadonlySet; + readonly composed: Component.ComposedComponents< + MyAppConfigPartial, + MyAppComponentContent + >; + } - /** - * The application specific compose logic. - * - * Information from the components can be aggregated into the configuration. - */ - function composeComponents(allComponents: readonly MyAppComponent[]): MyAppConfig { - const composed = Component.composeComponents(allComponents, (c) => { - const config: MyAppConfigPartial = { - allowedItemTypes: c.getComposed("items"), - allowedBackgroundTypes: c.getComposed("backgrounds"), - }; - return config; - }); + /** + * The application specific compose logic. + * + * Information from the components can be aggregated into the configuration. + */ + function composeComponents(allComponents: readonly MyAppComponent[]): MyAppConfig { + const composed = Component.composeComponents(allComponents, (c) => { + const config: MyAppConfigPartial = { + allowedItemTypes: c.getComposed("items"), + allowedBackgroundTypes: c.getComposed("backgrounds"), + }; + return config; + }); + + // At this point it is now legal to evaluate lazy schema: + const items = new Set(composed.config.allowedItemTypes.map(evaluateLazySchema)); + const backgrounds = new Set( + composed.config.allowedBackgroundTypes.map(evaluateLazySchema), + ); + return { composed, ...composed.config, items, backgrounds }; + } - // At this point it is now legal to evaluate lazy schema: - const items = new Set(composed.config.allowedItemTypes.map(evaluateLazySchema)); - const backgrounds = new Set( - composed.config.allowedBackgroundTypes.map(evaluateLazySchema), + const appConfig = composeComponents([ + containerComponent, + textComponent, + colorsComponent, + comboComponent, + ]); + + class Root extends sf.object("Root", { + content: appConfig.allowedItemTypes, + backgrounds: appConfig.allowedBackgroundTypes, + }) {} + + // Export the tree config appropriate for this schema. + // This is passed into the SharedTree when it is initialized. + // This eagerly evaluates the schema, so anything that used by these schema must be defined before this point. + const treeConfig = new TreeViewConfiguration({ schema: Root }); + + const blankNode = TreeBeta.create( + // Example for how to access content from a components which might depend on the full set of composed components. + [appConfig.composed.getComponent(comboComponent).blank], + { location: { x: 0, y: 0 } }, ); - return { composed, ...composed.config, items, backgrounds }; - } - - const appConfig = composeComponents([ - containerComponent, - textComponent, - colorsComponent, - comboComponent, - ]); - - class Root extends sf.object("Root", { - content: appConfig.allowedItemTypes, - backgrounds: appConfig.allowedBackgroundTypes, - }) {} - - // Export the tree config appropriate for this schema. - // This is passed into the SharedTree when it is initialized. - // This eagerly evaluates the schema, so anything that used by these schema must be defined before this point. - const treeConfig = new TreeViewConfiguration({ schema: Root }); - - const blankNode = TreeBeta.create( - // Example for how to access content from a components which might depend on the full set of composed components. - [appConfig.composed.getComponent(comboComponent).blank], - { location: { x: 0, y: 0 } }, - ); + }); }); }); diff --git a/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts b/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts index d1f3832c3a23..9637f4cded70 100644 --- a/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/componentApi.spec.ts @@ -17,6 +17,130 @@ import { Component } from "../../../componentApi.js"; * individual behaviors of the API in isolation. */ describe("Component", () => { + // A trivial example that uses none of the lazy array collections. + it("Minimal self contained example", () => { + type MyComponent = string; + type MyComponentFactory = Component.Factory<{}, MyComponent>; + + const a: MyComponentFactory = () => "a"; + const b: MyComponentFactory = () => "b"; + + const composed = Component.composeComponents([a, b], (all) => all.components); + + assert.deepEqual(composed.components, ["a", "b"]); + }); + + it("self contained demo", () => { + /** + * Some arbitrary item type to collect from the components. + */ + interface MyItem { + readonly name: string; + readonly data: unknown; + } + + interface MyComponent { + items?: Component.LazyArray; + } + + interface InputConfig { + readonly includeUnstableFeatures: boolean; + } + + interface ComposeConfig { + readonly input: InputConfig; + readonly lazy: () => LazyComposeConfig; + } + + interface LazyComposeConfig { + /** + * The items contributed by all components, lazily evaluated. + * @remarks + * Components can refer to (and capture) this array during construction, + * but can't evaluate its items until after composition completes. + */ + readonly items: readonly (() => MyItem)[]; + } + + interface OutputConfig { + readonly items: readonly MyItem[]; + } + + type MyComponentFactory = Component.Factory; + + // Example showing how a component could use configuration settings when computing its output. + const unstableItem = { name: "unstable", data: 1 }; + const unstableComponent: MyComponentFactory = (config) => ({ + items: () => (config().input.includeUnstableFeatures ? [() => unstableItem] : []), + }); + + // Example showing how a component can consume composed configuration values co-recursively. + const recursiveComponent: MyComponentFactory = (config) => { + return { + items: () => [ + Component.memoize(() => ({ name: "recursive", data: config().lazy().items })), + ], + }; + }; + + function myConfigure( + inputConfig: InputConfig, + allComponents: readonly MyComponentFactory[], + ): OutputConfig { + const composed = Component.composeComponents(allComponents, (c) => { + const config: ComposeConfig = { + input: inputConfig, + lazy: () => lazy, + }; + return config; + }); + + // At this point it is now legal to evaluate lazy values: + const lazy = { + items: composed.getComposed("items"), + }; + const items = composed.config.lazy().items.map((x) => x()); + const items2 = composed.getComposed("items"); + assert.deepEqual(composed.config.lazy().items, items2); + + return { items }; + } + + assert.deepEqual(myConfigure({ includeUnstableFeatures: true }, []).items, []); + assert.deepEqual( + myConfigure({ includeUnstableFeatures: false }, [unstableComponent]).items, + [], + ); + assert.deepEqual( + myConfigure({ includeUnstableFeatures: true }, [unstableComponent]).items, + [unstableItem], + ); + + { + const composed = myConfigure({ includeUnstableFeatures: true }, [recursiveComponent]); + assert.equal(composed.items.length, 1); + const item = composed.items[0]; + const data = item.data as (() => unknown)[]; + assert.equal(data.length, 1); + const innerItem = data[0]() as MyItem; + assert.equal(innerItem, item); + } + { + const composed = myConfigure({ includeUnstableFeatures: true }, [ + unstableComponent, + recursiveComponent, + ]); + assert.equal(composed.items.length, 2); + assert.equal(composed.items[0], unstableItem); + const item = composed.items[1]; + const data = item.data as (() => unknown)[]; + assert.equal(data.length, 2); + assert.equal(data[0](), unstableItem); + const innerItem = data[1]() as MyItem; + assert.equal(innerItem, item); + } + }); + // The composed configuration used by these tests. interface TestConfig { readonly items: readonly (() => string)[]; @@ -126,4 +250,32 @@ describe("Component", () => { ); assert.deepEqual(composed.config.items, []); }); + + describe("memoize", () => { + it("evaluates the factory only once and caches the result", () => { + let calls = 0; + const value = {}; + const cached = Component.memoize(() => { + calls += 1; + return value; + }); + + assert.equal(calls, 0); // Not evaluated until first call. + assert.equal(cached(), value); + assert.equal(cached(), value); + assert.equal(calls, 1); + }); + + it("caches a returned undefined", () => { + let calls = 0; + const cached = Component.memoize(() => { + calls += 1; + return undefined; + }); + + assert.equal(cached(), undefined); + assert.equal(cached(), undefined); + assert.equal(calls, 1); + }); + }); }); From 8373b1cdaf222ce1739f878e18250765bed9c04d Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:28:02 +0000 Subject: [PATCH 5/9] fix comment phrasing --- packages/dds/tree/src/componentApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dds/tree/src/componentApi.ts b/packages/dds/tree/src/componentApi.ts index c3e60629752d..62c334441f8a 100644 --- a/packages/dds/tree/src/componentApi.ts +++ b/packages/dds/tree/src/componentApi.ts @@ -12,7 +12,7 @@ import { getOrCreate } from "./util/index.js"; * * @remarks * Nothing in this namespace is specific to tree schema, - * however it is designed to be able to handle needs to components which woth with {@link TreeSchema}. + * however it is designed to be able to handle the needs of components which work with {@link TreeSchema}. * * This is mainly used to implement "open polymorphism", where the set of allowed types * for a field or collection can be extended by independently authored libraries (components) instead of being From b5b34e746e97144ad8d52e910f13311599076c6e Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:28:24 +0000 Subject: [PATCH 6/9] Update APi report --- .../fluid-framework/api-report/fluid-framework.alpha.api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index 54eabf06c30a..f0405347028d 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -235,9 +235,10 @@ export namespace Component { getComponent>(factory: TFactory): ReturnType; getComposed | undefined ? Property : never]: boolean; - }>(property: TKey): readonly (TComponent[TKey] extends LazyArray ? () => U : never)[]; + }>(property: TKey): readonly (Exclude extends LazyArray ? () => U : never)[]; getConfigured>(configurable: TConfigurable): ReturnType; } + const memoize: (factory: () => T) => (() => T); export interface Configurable { configure(config: TConfigPartial, components: ComposedComponents): TResult; } From 9a94c7350fede655f0f09c2545bfbaf28e0354da Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:42:59 +0000 Subject: [PATCH 7/9] More example cleanup --- .../src/test/openPolymorphism.integration.ts | 107 +++++++++++++++++- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/dds/tree/src/test/openPolymorphism.integration.ts b/packages/dds/tree/src/test/openPolymorphism.integration.ts index 7377090dabf2..4b4f3f6af6ea 100644 --- a/packages/dds/tree/src/test/openPolymorphism.integration.ts +++ b/packages/dds/tree/src/test/openPolymorphism.integration.ts @@ -37,6 +37,7 @@ import { Component } from "../componentApi.js"; * View schema however can emulate it by carefully controlling evaluation order: * the source code can be structured in an open polymorphism style which at runtime evaluate into closed polymorphism by having each implementation register itself into a central {@link AllowedTypes}. * There are a few ways to do this, some of which are demonstrated below. + * Of particular note is the {@link Component} design pattern, which leverages utilities in the {@link Component} namespace. */ /** @@ -108,9 +109,10 @@ class TextItem } describe("Open Polymorphism design pattern examples and tests for them", () => { + // A simple pattern for doing open polymorphism with a mutable static registry. // Currently, allowed type arrays are processed eagerly, making this pattern no longer work. - describe.skip("mutable static registry", () => { - it("without customizeSchemaTyping", () => { + describe("mutable static registry", () => { + it.skip("without customizeSchemaTyping", () => { // ------------- // Registry for items. If using this pattern, this would typically be defined alongside the Item interface. @@ -182,7 +184,7 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { assert.throws(() => ItemTypes.push(TextItem)); }); - it("recursive case", () => { + it.skip("recursive case", () => { const ItemTypes: ItemSchema[] = []; // Example recursive item implementation @@ -210,6 +212,7 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { }); // Example component design pattern which avoids the mutable static registry and instead composes declarative components. + // This doesn't rely on any "components" framework, and rather just implements the minimal subset it needs inline. it("components", () => { /** * Example application component interface. @@ -273,7 +276,7 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { }); }); - // Example using a components library (`Component` namespace below). + // Example using the simplified/minimal `ComponentMinimal` library below. // Same as the above, but with some reusable logic factored out. it("ComponentMinimal library", () => { /** @@ -360,7 +363,103 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { ); }); + // Examples using the package exported `Component` library. describe("Component library", () => { + // Same as the above, but using the `Component` library + // This has minimal changes from the above example to keep it well aligned with the others, + // and thus isn't necessarily the cleanest example of use of the `Component` library. + // There are more dedicated examples of the `Component` library below, as well as in its own test suite. + it("Example", () => { + /** + * Example application component interface. + */ + type MyAppComponent = Component.Factory; + + function createContainer(config: MyAppConfigPartial): ItemSchema { + class Container extends sf.array("Container", config.allowedItemTypes()) {} + class ContainerItem extends sf.object("ContainerItem", { + ...itemFields, + container: Container, + }) { + public static readonly description = "Text"; + public static default(): TextItem { + return new TextItem({ text: "", location: { x: 0, y: 0 } }); + } + + public foo(): void {} + } + + return ContainerItem; + } + + // An example component which recursively depends on all components. + const containerComponent: MyAppComponent = (lazyConfig) => ({ + allowedItemTypes: () => [() => createContainer(lazyConfig())], + }); + + const textComponent: MyAppComponent = () => ({ + allowedItemTypes: () => [() => TextItem], + }); + + /** + * Subset of `MyAppConfig` which is available while composing components. + * Also used as content type for the component factories in this example. + */ + interface MyAppConfigPartial { + /** + * {@link AllowedTypes} containing all ItemSchema contributed by components. + */ + readonly allowedItemTypes: Component.LazyArray; + } + + /** + * Example configuration type for an application. + * + * Contains a collection of schema to demonstrate how ComponentSchemaCollection works for schema dependency inversions. + */ + interface MyAppConfig extends MyAppConfigPartial { + /** + * Set of all ItemSchema contributed by components. + * @remarks + * Same content as {@link MyAppConfig.allowedItemTypes}, but normalized into a Set. + * + * This is included to demonstrate how and where to use evaluated schema. + */ + readonly items: ReadonlySet; + } + + /** + * The application specific compose logic. + * + * Information from the components can be aggregated into the configuration. + */ + function composeComponents(allComponents: readonly MyAppComponent[]): MyAppConfig { + // Compose all components + const composed = Component.composeComponents( + allComponents, + (lazyConfig): MyAppConfigPartial => ({ + allowedItemTypes: () => lazyConfig.getComposed("allowedItemTypes"), + }), + ); + const config: MyAppConfigPartial = composed.config; + const ItemTypes = composed.config.allowedItemTypes(); + // At this point it is now legal to evaluate lazy schema: + // This is equivalent to normalizeAllowedTypes(ItemTypes).evaluateSet(), but preserves more type information. + const items = new Set(ItemTypes.map(evaluateLazySchema)); + return { ...config, items }; + } + + const appConfig = composeComponents([containerComponent, textComponent]); + + // Export the tree config appropriate for this schema. + // This is passed into the SharedTree when it is initialized. + // This eagerly evaluates the schema, so anything that used by these schema must be defined before this point. + const treeConfig = new TreeViewConfiguration( + // Schema for the root + { schema: appConfig.allowedItemTypes() }, + ); + }); + // An open polymorphic collection of schema with implementations provided by components. it("minimal open polymorphism", () => { /** From e4d480fe019604845fb45b4d9d8127428ee9330c Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:13:14 +0000 Subject: [PATCH 8/9] Add note about skips --- packages/dds/tree/src/test/openPolymorphism.integration.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/dds/tree/src/test/openPolymorphism.integration.ts b/packages/dds/tree/src/test/openPolymorphism.integration.ts index 4b4f3f6af6ea..9d7033e8d923 100644 --- a/packages/dds/tree/src/test/openPolymorphism.integration.ts +++ b/packages/dds/tree/src/test/openPolymorphism.integration.ts @@ -112,6 +112,7 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { // A simple pattern for doing open polymorphism with a mutable static registry. // Currently, allowed type arrays are processed eagerly, making this pattern no longer work. describe("mutable static registry", () => { + // See note on describe block for why this is skipped. it.skip("without customizeSchemaTyping", () => { // ------------- // Registry for items. If using this pattern, this would typically be defined alongside the Item interface. @@ -184,6 +185,7 @@ describe("Open Polymorphism design pattern examples and tests for them", () => { assert.throws(() => ItemTypes.push(TextItem)); }); + // See note on describe block for why this is skipped. it.skip("recursive case", () => { const ItemTypes: ItemSchema[] = []; From c4a9224b9b3e9ec642f311138086e5efbb9775c8 Mon Sep 17 00:00:00 2001 From: "Craig Macomber (Microsoft)" <42876482+CraigMacomber@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:19:57 +0000 Subject: [PATCH 9/9] Add link --- .changeset/component-composition-alpha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/component-composition-alpha.md b/.changeset/component-composition-alpha.md index b2fea77bcdf3..2411b3fe01ab 100644 --- a/.changeset/component-composition-alpha.md +++ b/.changeset/component-composition-alpha.md @@ -15,4 +15,4 @@ const composed = Component.composeComponents(allComponents, (c) => ({ })); ``` -See the worked examples in `openPolymorphism.integration.ts` for end-to-end usage with SharedTree schema. +See the worked examples in [openPolymorphism.integration.ts](https://github.com/microsoft/FluidFramework/blob/main/packages/dds/tree/src/test/openPolymorphism.integration.ts) for end-to-end usage with SharedTree schema.