From fc7f7894ede1c693de9baa9116326b42b10a9b39 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:16:40 -0700 Subject: [PATCH 1/5] feat: export binding abstract class module --- packages/fast-element/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/fast-element/package.json b/packages/fast-element/package.json index 4ce93802e19..0326eb40f45 100644 --- a/packages/fast-element/package.json +++ b/packages/fast-element/package.json @@ -30,6 +30,10 @@ "types": "./dist/dts/debug.d.ts", "default": "./dist/esm/debug.js" }, + "./binding/binding.js": { + "types": "./dist/dts/binding/binding.d.ts", + "default": "./dist/esm/binding/binding.js" + }, "./binding/two-way.js": { "types": "./dist/dts/binding/two-way.d.ts", "default": "./dist/esm/binding/two-way.js" From 8e8e0a5b3b7751170184a5f4936d326d884a8964 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:31:55 -0700 Subject: [PATCH 2/5] feat: add signal-based binding support to ObserverMap and TemplateElement --- .../fast-html/src/components/observer-map.ts | 16 +- packages/fast-html/src/components/template.ts | 49 ++-- .../fast-html/src/components/utilities.ts | 212 +++++++++++++++++- 3 files changed, 253 insertions(+), 24 deletions(-) diff --git a/packages/fast-html/src/components/observer-map.ts b/packages/fast-html/src/components/observer-map.ts index 40ab0766d1a..543ed53b561 100644 --- a/packages/fast-html/src/components/observer-map.ts +++ b/packages/fast-html/src/components/observer-map.ts @@ -1,5 +1,6 @@ import { Observable } from "@microsoft/fast-element/observable.js"; -import { assignObservables, deepMerge } from "./utilities.js"; +import { Signal } from "@microsoft/fast-element/binding/signal.js"; +import { assignObservables, deepMerge, getElementSignalName } from "./utilities.js"; import type { JSONSchema, Schema } from "./schema.js"; /** @@ -9,6 +10,7 @@ import type { JSONSchema, Schema } from "./schema.js"; export class ObserverMap { private schema: Schema; private classPrototype: any; + private signaling = new WeakSet(); constructor(classPrototype: any, schema: Schema) { this.classPrototype = classPrototype; @@ -74,8 +76,15 @@ export class ObserverMap { ): ((prev: any, next: any) => void) => { const getAndAssignObservablesAlias = this.getAndAssignObservables; const schema = this.schema; + const signaling = this.signaling; function instanceResolverChanged(this: any, prev: any, next: any): void { + const isReentrant = signaling.has(this); + + if (!isReentrant) { + signaling.add(this); + } + const isObjectAssignment = next !== null && typeof next === "object"; const isManagedArray = Array.isArray(next) && (next as any)?.$fastController; const shouldAssignProxy = @@ -99,6 +108,11 @@ export class ObserverMap { this[propertyName] = next; } + if (!isReentrant) { + signaling.delete(this); + Signal.send(getElementSignalName(this)); + } + existingChangedMethod?.call(this, prev, next); } diff --git a/packages/fast-html/src/components/template.ts b/packages/fast-html/src/components/template.ts index 54bb97d1031..854b7106446 100644 --- a/packages/fast-html/src/components/template.ts +++ b/packages/fast-html/src/components/template.ts @@ -23,6 +23,7 @@ import { AttributeDirective, bindingResolver, ChainedExpression, + createSignalBinding, DataBindingBehaviorConfig, getExpressionChain, getNextBehavior, @@ -344,9 +345,19 @@ class TemplateElement extends FASTElement { observerMap ); - externalValues.push( - when(whenLogic, this.resolveTemplateOrBehavior(strings, values)) - ); + const whenTemplate = this.resolveTemplateOrBehavior(strings, values); + + if (observerMap) { + externalValues.push( + createSignalBinding( + (source, context) => + whenLogic(source, context) ? whenTemplate : null, + level + ) + ); + } else { + externalValues.push(when(whenLogic, whenTemplate)); + } break; } @@ -384,12 +395,9 @@ class TemplateElement extends FASTElement { observerMap ); - externalValues.push( - repeat( - (x, c) => binding(x, c), - this.resolveTemplateOrBehavior(strings, values) - ) - ); + const repeatTemplate = this.resolveTemplateOrBehavior(strings, values); + + externalValues.push(repeat((x, c) => binding(x, c), repeatTemplate)); break; } @@ -486,8 +494,16 @@ class TemplateElement extends FASTElement { parentContext, level ); - const contentBinding = (x: any, c: any) => binding(x, c); - values.push(contentBinding); + + if (observerMap) { + values.push( + createSignalBinding((x: any, c: any) => binding(x, c), level) + ); + } else { + const contentBinding = (x: any, c: any) => binding(x, c); + values.push(contentBinding); + } + await this.resolveInnerHTML( rootPropertyName, innerHTML.slice(behaviorConfig.closingEndIndex, innerHTML.length), @@ -581,8 +597,15 @@ class TemplateElement extends FASTElement { parentContext, level ); - const attributeBinding = (x: any, c: any) => binding(x, c); - values.push(attributeBinding); + + if (observerMap) { + values.push( + createSignalBinding((x: any, c: any) => binding(x, c), level) + ); + } else { + const attributeBinding = (x: any, c: any) => binding(x, c); + values.push(attributeBinding); + } } await this.resolveInnerHTML( diff --git a/packages/fast-html/src/components/utilities.ts b/packages/fast-html/src/components/utilities.ts index a718185fcee..c1e7f7a0986 100644 --- a/packages/fast-html/src/components/utilities.ts +++ b/packages/fast-html/src/components/utilities.ts @@ -1,3 +1,12 @@ +import type { + ExpressionController, + ExpressionNotifier, + ExpressionObserver, + Expression as FASTExpression, + Subscriber, +} from "@microsoft/fast-element"; +import { Binding } from "@microsoft/fast-element/binding/binding.js"; +import { Signal } from "@microsoft/fast-element/binding/signal.js"; import { Observable } from "@microsoft/fast-element/observable.js"; import { defsPropertyName, @@ -127,6 +136,164 @@ const Operator = { type Operator = (typeof Operator)[keyof typeof Operator]; +/** + * Counter for generating unique signal identifiers. + */ +let nextSignalId = 0; + +/** + * Maps element instances to their unique signal name. + */ +const signalNameMap = new WeakMap(); + +/** + * Gets or creates a unique signal name for an element instance. + * @param instance - The element instance. + * @returns A unique signal name string, or empty string for non-object values. + */ +export function getElementSignalName(instance: any): string { + if (instance === null || instance === undefined || typeof instance !== "object") { + return ""; + } + + let name = signalNameMap.get(instance); + + if (!name) { + name = `fh:${nextSignalId++}`; + signalNameMap.set(instance, name); + } + + return name; +} + +/** + * An observer that combines signal-based pub/sub with standard Observable + * expression watching. Signal subscriptions handle deep property changes + * relayed through proxies, while the Observable expression observer handles + * direct property access tracking (e.g., array item properties set up via + * Observable.defineProperty). + */ +class ObserverMapObserver + implements ExpressionObserver +{ + private isNotBound = true; + private signals: string[] = []; + private innerObserver: ExpressionNotifier; + + constructor( + private readonly dataBinding: ObserverMapBinding, + private readonly subscriber: Subscriber + ) { + // Create a standard Observable expression observer that watches + // property access during evaluation, just like oneWay bindings do + this.innerObserver = Observable.binding( + dataBinding.evaluate, + this, + true // volatile so it re-watches on every evaluation + ); + } + + bind(controller: ExpressionController): TReturn { + if (this.isNotBound) { + this.signals = this.dataBinding.getSignals(controller); + + for (const sig of this.signals) { + Signal.subscribe(sig, this); + } + + controller.onUnbind(this); + this.isNotBound = false; + } + + return this.innerObserver.bind(controller); + } + + unbind(controller: ExpressionController) { + this.isNotBound = true; + + for (const sig of this.signals) { + Signal.unsubscribe(sig, this); + } + + this.signals = []; + this.innerObserver.dispose(); + } + + handleChange() { + this.subscriber.handleChange(this.dataBinding.evaluate, this); + } +} + +/** + * A binding that uses Signal-based pub/sub for change notification. + * Extends the Binding base class from fast-element, mirroring the + * SignalBinding pattern but supporting multiple signal subscriptions + * to handle both element-level and repeat-item-level notifications. + */ +class ObserverMapBinding extends Binding< + TSource, + TReturn, + TParent +> { + signalsFn: (controller: ExpressionController) => string[]; + + constructor( + evaluate: FASTExpression, + signalsFn: (controller: ExpressionController) => string[] + ) { + super(evaluate); + this.signalsFn = signalsFn; + } + + getSignals(controller: ExpressionController): string[] { + return this.signalsFn(controller); + } + + createObserver( + subscriber: Subscriber + ): ExpressionObserver { + return new ObserverMapObserver(this, subscriber); + } +} + +/** + * Creates a signal-aware binding from an expression and nesting level. + * At level 0, the binding subscribes only to the element's signal. + * At level 1+, it subscribes to both the source's signal (repeat item) + * and the element's signal, so it reacts to changes at any level. + * @param expression - The binding expression function. + * @param level - The nesting depth for signal name resolution. + * @returns A Binding instance that reacts to the appropriate signals. + */ +export function createSignalBinding( + expression: (source: any, context: any) => any, + level: number +): Binding { + const signalsFn = (controller: ExpressionController): string[] => { + const sourceSignal = getElementSignalName(controller.source); + + if (level === 0) { + return [sourceSignal]; + } + + let ctx = controller.context as any; + + for (let i = level; i > 1; i--) { + ctx = ctx.parentContext; + } + + const elementSignal = getElementSignalName(ctx.parent); + + if (sourceSignal === elementSignal) { + return [sourceSignal]; + } + + return [sourceSignal, elementSignal]; + }; + + return new ObserverMapBinding(expression, signalsFn); +} + /** * A map of proxied objects */ @@ -1054,11 +1221,16 @@ function assignObservablesToArray( Object.assign(item, originalItem); } - - // Notify observers of the target object's root property - Observable.notify(target, rootProperty); } }); + + // Signal the owning element so content bindings that depend on + // array properties (e.g., .length) re-evaluate after splices + const signalName = getElementSignalName(target); + + if (signalName) { + Signal.send(signalName); + } }, }); @@ -1351,16 +1523,36 @@ function getTargetsForObject(object: any): ObservedTargetsAndProperties[] { } /** - * Notify any observables mapped to the object + * Notify any signal subscribers mapped to the object. + * Uses Signal.send to broadcast changes to all bindings + * subscribed to the element's signal channel. + * Walks the target chain upward so that nested proxies + * ultimately signal the owning element. * @param targetObject The object that is mapped to a target and rootProperty */ -function notifyObservables(targetObject: any) { - getTargetsForObject(targetObject).forEach( - (targetItem: ObservedTargetsAndProperties) => { - // Trigger notification for property changes - Observable.notify(targetItem.target, targetItem.rootProperty); +function notifyObservables(targetObject: any, visited?: Set) { + if (!visited) { + visited = new Set(); + } + + if (visited.has(targetObject)) { + return; + } + + visited.add(targetObject); + + const targets = getTargetsForObject(targetObject); + + for (const targetItem of targets) { + const signalName = getElementSignalName(targetItem.target); + + if (signalName) { + Signal.send(signalName); } - ); + + // Walk up: if the target itself has registered targets, notify those too + notifyObservables(targetItem.target, visited); + } } /** From 2126911ea985e0dd232e6f62952a8b06b91c0348 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:32:24 -0700 Subject: [PATCH 3/5] Change files --- ...-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json | 7 +++++++ ...oft-fast-html-feec1ebb-e724-4f54-b041-06420b00ee93.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json create mode 100644 change/@microsoft-fast-html-feec1ebb-e724-4f54-b041-06420b00ee93.json diff --git a/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json b/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json new file mode 100644 index 00000000000..617a0bd8d1e --- /dev/null +++ b/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: export binding abstract class module", + "packageName": "@microsoft/fast-element", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@microsoft-fast-html-feec1ebb-e724-4f54-b041-06420b00ee93.json b/change/@microsoft-fast-html-feec1ebb-e724-4f54-b041-06420b00ee93.json new file mode 100644 index 00000000000..1f4d6f76d92 --- /dev/null +++ b/change/@microsoft-fast-html-feec1ebb-e724-4f54-b041-06420b00ee93.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: add signal-based binding support to ObserverMap and TemplateElement", + "packageName": "@microsoft/fast-html", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "none" +} From 4d4b78327fb2c55c3870393d22e378cf7f0b4d9e Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:57:20 -0700 Subject: [PATCH 4/5] revert --- packages/fast-html/src/components/template.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/fast-html/src/components/template.ts b/packages/fast-html/src/components/template.ts index 854b7106446..f92329250dd 100644 --- a/packages/fast-html/src/components/template.ts +++ b/packages/fast-html/src/components/template.ts @@ -395,9 +395,12 @@ class TemplateElement extends FASTElement { observerMap ); - const repeatTemplate = this.resolveTemplateOrBehavior(strings, values); - - externalValues.push(repeat((x, c) => binding(x, c), repeatTemplate)); + externalValues.push( + repeat( + (x, c) => binding(x, c), + this.resolveTemplateOrBehavior(strings, values) + ) + ); break; } From 6574b510549826fade211d77c255c73565f2c497 Mon Sep 17 00:00:00 2001 From: John Kreitlow <863023+radium-v@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:59:46 -0700 Subject: [PATCH 5/5] update changefile --- ...osoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json b/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json index 617a0bd8d1e..6c5e9c65af3 100644 --- a/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json +++ b/change/@microsoft-fast-element-cbc19b38-8c85-4701-b1a6-8236b4644f47.json @@ -1,5 +1,5 @@ { - "type": "patch", + "type": "minor", "comment": "feat: export binding abstract class module", "packageName": "@microsoft/fast-element", "email": "863023+radium-v@users.noreply.github.com",