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": "prerelease",
"comment": "breaking: replace arrays during observerMap deepMerge",
"packageName": "@microsoft/fast-html",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
7 changes: 7 additions & 0 deletions packages/fast-html/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ Each path entry can be:

When `properties` is omitted (`observerMap: {}` or `observerMap: "all"`), all root properties are observed. When `properties` is present but empty (`{ properties: {} }`), no root properties are observed.

Observer-map-managed array updates replace the array reference when `deepMerge`
receives a new array. This avoids mutating an existing observed array while it
may be notifying subscribers and lets repeat bindings observe the new array
reference. Replaced arrays are wrapped with the schema for the assigned
property, and array observer subscriptions are installed once per array so
reprocessing does not duplicate accessors or notification work.

The resolution algorithm walks the schema and configuration tree in parallel:
1. If `properties` is present and a root property is not listed, it is skipped.
2. `true`/`false` booleans apply to the entire subtree.
Expand Down
6 changes: 6 additions & 0 deletions packages/fast-html/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,12 @@ observerMap: {

When `properties` is omitted, all root properties are observed (backward compatible). When `properties` is present but empty (`{ properties: {} }`), no root properties are observed.

When observer-map data is updated with `deepMerge`, array properties are replaced
rather than updated in place. This avoids synchronous reentrant array work and
allows repeat bindings to observe the new array reference. Replacement arrays
are processed through observerMap so nested item properties and subsequent array
mutations remain observable.

#### `attributeMap`

When `attributeMap: "all"` (or `attributeMap: {}`) is configured for an element, `@microsoft/fast-html` automatically creates reactive `@attr` properties for every **leaf binding** in the template — simple expressions like `{{foo}}` or `id="{{fooBar}}"` that have no nested properties. Both `"all"` and `{}` are equivalent and use the default `"camelCase"` attribute name strategy.
Expand Down
192 changes: 105 additions & 87 deletions packages/fast-html/src/components/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Observable } from "@microsoft/fast-element/observable.js";
import { type Accessor, Observable } from "@microsoft/fast-element/observable.js";
import {
defsPropertyName,
fastContextMetaData,
Expand Down Expand Up @@ -181,7 +181,58 @@ const objectTargetsMap = new WeakMap<object, ObservedTargetsAndProperties[]>();
/**
* A map of arrays being observered
*/
const observedArraysMap = new WeakMap<object, void>();
const observedArraysMap = new WeakSet<object>();

function defineObservableProperty(
targetObject: any,
key: string,
schema: JSONSchema | JSONSchemaDefinition,
rootSchema: JSONSchema,
target: any,
rootProperty: string,
): void {
if (!Observable.getAccessors(targetObject).some(accessor => accessor.name === key)) {
const field = `_${key}`;
const callback = `${key}Changed`;
const accessor: Accessor = {
name: key,
getValue(source: any): any {
Observable.track(source, key);
return source[field];
},
setValue(source: any, value: any): void {
const oldValue = source[field];
const newValue = assignObservables(
schema,
rootSchema,
value,
target,
rootProperty,
);

if (oldValue !== newValue) {
source[field] = newValue;

const changeCallback = source[callback];

if (typeof changeCallback === "function") {
changeCallback.call(source, oldValue, newValue);
}

Observable.notify(source, key);
}
},
};

Observable.defineProperty(targetObject, accessor);
}
}

function findArrayItemDef(schema: JSONSchema | JSONSchemaDefinition): string | null {
const items = (schema as JSONSchema).items;

return findDef(schema) ?? (items === undefined ? null : findDef(items));
}

/**
* Get the index of the next matching tag
Expand Down Expand Up @@ -1228,38 +1279,51 @@ function assignObservablesToArray(
? proxiedData.map((item: any) => {
const originalItem = Object.assign({}, item);

assignProxyToItemsInArray(item, originalItem, schema, rootSchema);
assignProxyToItemsInArray(
item,
originalItem,
schema,
rootSchema,
target,
rootProperty,
);

return Object.assign(item, originalItem);
})
: proxiedData;

Observable.getNotifier(data).subscribe({
handleChange(subject, args) {
args.forEach((arg: any) => {
if (arg.addedCount > 0) {
if (schemaProperties) {
for (let i = arg.addedCount - 1; i >= 0; i--) {
const item = subject[arg.index + i];
const originalItem = Object.assign({}, item);

assignProxyToItemsInArray(
item,
originalItem,
schema,
rootSchema,
);

Object.assign(item, originalItem);
if (!observedArraysMap.has(data)) {
observedArraysMap.add(data);

Observable.getNotifier(data).subscribe({
handleChange(subject, args) {
args.forEach((arg: any) => {
if (arg.addedCount > 0) {
if (schemaProperties) {
for (let i = arg.addedCount - 1; i >= 0; i--) {
const item = subject[arg.index + i];
const originalItem = Object.assign({}, item);

assignProxyToItemsInArray(
item,
originalItem,
schema,
rootSchema,
target,
rootProperty,
);

Object.assign(item, originalItem);
}
}
}

// Notify observers of the target object's root property
Observable.notify(target, rootProperty);
}
});
},
});
// Notify observers of the target object's root property
Observable.notify(target, rootProperty);
}
});
},
});
}

if (schemaProperties !== null) {
return data;
Expand Down Expand Up @@ -1336,24 +1400,6 @@ export function findDef(schema: JSONSchema | JSONSchemaDefinition): string | nul
return null;
}

/**
* Subscribe to a notifier on data that is an observed array
* @param data - The array being observed
* @param updateArrayObservables - The function to call to update the array item
*/
function assignSubscribeToObservableArray(
data: any,
updateArrayObservables: () => void,
): void {
Observable.getNotifier(data).subscribe({
handleChange(subject, args) {
args.forEach((arg: any) => {
updateArrayObservables();
});
},
});
}

/**
* Assign observables to data
* @param schema - The schema
Expand All @@ -1375,7 +1421,7 @@ export function assignObservables(

switch (dataType) {
case "array": {
const context = findDef(schema);
const context = findArrayItemDef(schema);

if (context) {
proxiedData = assignObservablesToArray(
Expand All @@ -1387,23 +1433,6 @@ export function assignObservables(
target,
rootProperty,
);

if (!observedArraysMap.has(proxiedData)) {
observedArraysMap.set(
proxiedData,
assignSubscribeToObservableArray(proxiedData, () =>
assignObservablesToArray(
proxiedData,
(rootSchema as JSONSchema)[defsPropertyName]?.[
context
] as JSONSchemaDefinition,
rootSchema,
target,
rootProperty,
),
),
);
}
} else {
// Primitive array (items have no schema $ref): wrap in a proxy so that
// direct index assignments (e.g. arr[0] = value) use FAST's splice-based
Expand Down Expand Up @@ -1446,6 +1475,8 @@ function assignProxyToItemsInArray(
originalItem: any,
schema: JSONSchema | JSONSchemaDefinition,
rootSchema: JSONSchema,
target: any,
rootProperty: string,
): void {
const schemaProperties = getSchemaProperties(schema);

Expand All @@ -1472,7 +1503,14 @@ function assignProxyToItemsInArray(
);

// Then make the property observable
Observable.defineProperty(proxiableItem, key);
defineObservableProperty(
proxiableItem,
key,
childSchema,
rootSchema,
target,
rootProperty,
);
});
}

Expand Down Expand Up @@ -1548,7 +1586,7 @@ function assignProxyToItemsInObject(
addTargetToObject(proxiedData, target, rootProperty);
}
} else if (type === "array") {
const context = findDef((schema as JSONSchema).items);
const context = findArrayItemDef(schema);

if (context) {
const definition = (rootSchema as JSONSchema)[defsPropertyName]?.[context];
Expand All @@ -1561,19 +1599,6 @@ function assignProxyToItemsInObject(
target,
rootProperty,
);

if (!observedArraysMap.has(proxiedData)) {
observedArraysMap.set(
proxiedData,
assignObservablesToArray(
proxiedData,
definition as JSONSchemaDefinition,
rootSchema,
target,
rootProperty,
),
);
}
}
}
}
Expand Down Expand Up @@ -1657,7 +1682,7 @@ export function assignProxy(
}

obj[prop] = assignObservables(
schema,
childSchema ?? schema,
rootSchema,
value,
target,
Expand Down Expand Up @@ -1938,18 +1963,11 @@ export function deepMerge(target: any, source: any): boolean {
hasChanges = true;

if (Array.isArray(sourceValue)) {
const isTargetArray = Array.isArray(targetValue);
const clonedItems = sourceValue.map((item: unknown) =>
isPlainObject(item) ? { ...item } : item,
);

if (isTargetArray) {
// Use splice to maintain observable array tracking
targetValue.splice(0, targetValue.length, ...clonedItems);
} else {
// Target isn't an array, replace it
target[key] = clonedItems;
}
target[key] = clonedItems;
continue;
}

Expand Down
Loading
Loading