Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: export binding abstract class module",
"packageName": "@microsoft/fast-element",
"email": "863023+radium-v@users.noreply.github.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 4 additions & 0 deletions packages/fast-element/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 15 additions & 1 deletion packages/fast-html/src/components/observer-map.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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 =
Expand All @@ -99,6 +108,11 @@ export class ObserverMap {
this[propertyName] = next;
}

if (!isReentrant) {
signaling.delete(this);
Signal.send(getElementSignalName(this));
}

existingChangedMethod?.call(this, prev, next);
}

Expand Down
40 changes: 33 additions & 7 deletions packages/fast-html/src/components/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
AttributeDirective,
bindingResolver,
ChainedExpression,
createSignalBinding,
DataBindingBehaviorConfig,
getExpressionChain,
getNextBehavior,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -486,8 +497,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),
Expand Down Expand Up @@ -581,8 +600,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(
Expand Down
212 changes: 202 additions & 10 deletions packages/fast-html/src/components/utilities.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<object, string>();

/**
* 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<TSource = any, TReturn = any, TParent = any>
implements ExpressionObserver<TSource, TReturn, TParent>
{
private isNotBound = true;
private signals: string[] = [];
private innerObserver: ExpressionNotifier<TSource, TReturn>;

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<TSource, TParent>): 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<TSource, TParent>) {
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<TSource = any, TReturn = any, TParent = any> extends Binding<
TSource,
TReturn,
TParent
> {
signalsFn: (controller: ExpressionController<TSource, TParent>) => string[];

constructor(
evaluate: FASTExpression<TSource, TReturn, TParent>,
signalsFn: (controller: ExpressionController<TSource, TParent>) => string[]
) {
super(evaluate);
this.signalsFn = signalsFn;
}

getSignals(controller: ExpressionController<TSource, TParent>): string[] {
return this.signalsFn(controller);
}

createObserver(
subscriber: Subscriber
): ExpressionObserver<TSource, TReturn, TParent> {
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
*/
Expand Down Expand Up @@ -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);
}
},
});

Expand Down Expand Up @@ -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<any>) {
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);
}
}

/**
Expand Down
Loading