From 7853992f255c31d082e8232f02d223af570cb3d4 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:01:04 -0700 Subject: [PATCH 1/7] test: reproduce observerMap deepMerge array replacement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fixtures/deep-merge/deep-merge.spec.ts | 38 +++++---- .../test/fixtures/deep-merge/main.ts | 77 ++++++++++++++++++- 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts b/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts index 6c98cce5848..6f52a4ff9a6 100644 --- a/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts +++ b/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts @@ -269,27 +269,39 @@ test.describe("Deep Merge Test Fixture", () => { await expect(newUser).toContainText("No orders yet"); }); - test("should preserve object identity for observable arrays when using deepMerge", async ({ - page, - }) => { + test("should replace observable arrays when using deepMerge", async ({ page }) => { const hydrationCompleted = page.waitForFunction( () => (window as any).hydrationCompleted === true, ); await page.goto("/fixtures/deep-merge/"); await hydrationCompleted; - // This test verifies that splice is used internally by checking - // that updates work correctly multiple times (proving the array - // reference is maintained) - await page.click('button:has-text("Update Product Tags")'); + const result = await page + .locator("deep-merge-test-element") + .evaluate((element: any) => element.testArrayReplacement()); - const firstItem = page.locator(".item").first(); - await expect(firstItem).toContainText("Views: 300"); + expect(result.sameOrders).toBe(false); + expect(result.orderCount).toBe(1); + await expect(page.locator(".user-card").first()).toContainText("Order #103"); + }); - // Update again - if array identity wasn't preserved, this might fail - await page.click('button:has-text("Update Product Tags")'); + test("should avoid reentrant observerMap deepMerge array changes during notification", async ({ + page, + }) => { + const hydrationCompleted = page.waitForFunction( + () => (window as any).hydrationCompleted === true, + ); + await page.goto("/fixtures/deep-merge/"); + await hydrationCompleted; - // Should still work correctly - await expect(firstItem).toContainText("Views: 300"); + const result = await page + .locator("deep-merge-test-element") + .evaluate((element: any) => element.testDeepMergeObserverMapReentry()); + + expect(result.maxDepth).toBe(1); + expect(result.firstCalls).toBe(1); + expect(result.sameArray).toBe(false); + expect(result.currentItemCount).toBe(1); + expect(result.oldItemCount).toBe(3); }); }); diff --git a/packages/fast-html/test/fixtures/deep-merge/main.ts b/packages/fast-html/test/fixtures/deep-merge/main.ts index c21f0dd1bc1..c72c1f801f0 100644 --- a/packages/fast-html/test/fixtures/deep-merge/main.ts +++ b/packages/fast-html/test/fixtures/deep-merge/main.ts @@ -1,4 +1,4 @@ -import { FASTElement, observable } from "@microsoft/fast-element"; +import { FASTElement, Observable, observable, Updates } from "@microsoft/fast-element"; import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html"; import { deepMerge } from "@microsoft/fast-html/utilities.js"; @@ -346,6 +346,81 @@ class DeepMergeTestElement extends FASTElement { deepMerge(this.users[0], updates); } + + public testArrayReplacement() { + const previousOrders = this.users[0].orders; + + this.updateUserOrders(); + + return { + sameOrders: previousOrders === this.users[0].orders, + orderCount: this.users[0].orders.length, + }; + } + + public async testDeepMergeObserverMapReentry() { + const items = this.users[0].orders[0].items; + const observer = Observable.getNotifier(items); + let firstCalls = 0; + let depth = 0; + let maxDepth = 0; + + const nestedSubscriber = { + handleChange() {}, + }; + + observer.subscribe({ + handleChange: () => { + firstCalls++; + depth++; + maxDepth = Math.max(maxDepth, depth); + + if (firstCalls === 1) { + deepMerge(this.users[0].orders[0], { + items: [ + { + id: 3001, + name: "Dock", + price: 125.0, + inStock: true, + tags: ["accessories"], + metadata: { + views: 40, + rating: 4.2, + }, + }, + ], + }); + observer.subscribe(nestedSubscriber); + } + + depth--; + }, + }); + + items.push({ + id: 3000, + name: "Cable", + price: 10.0, + inStock: true, + tags: ["accessories"], + metadata: { + views: 10, + rating: 4.0, + }, + }); + + await Updates.next(); + await Updates.next(); + + return { + firstCalls, + maxDepth, + currentItemCount: this.users[0].orders[0].items.length, + oldItemCount: items.length, + sameArray: items === this.users[0].orders[0].items, + }; + } } TemplateElement.options({ From 88a4d7eb6e55afb0f8911905c627ec8cc2554348 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:01:05 -0700 Subject: [PATCH 2/7] fix: replace arrays during observerMap deepMerge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...t-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json | 7 +++++++ packages/fast-html/DESIGN.md | 5 +++++ packages/fast-html/README.md | 4 ++++ packages/fast-html/src/components/utilities.ts | 9 +-------- 4 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json diff --git a/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json b/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json new file mode 100644 index 00000000000..5aef35d44ae --- /dev/null +++ b/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "breaking: replace arrays during observerMap deepMerge", + "packageName": "@microsoft/fast-html", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/fast-html/DESIGN.md b/packages/fast-html/DESIGN.md index a9eb08f4934..ca110b402a4 100644 --- a/packages/fast-html/DESIGN.md +++ b/packages/fast-html/DESIGN.md @@ -119,6 +119,11 @@ 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. + 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. diff --git a/packages/fast-html/README.md b/packages/fast-html/README.md index ec89a3f89f9..3bcbf974451 100644 --- a/packages/fast-html/README.md +++ b/packages/fast-html/README.md @@ -266,6 +266,10 @@ 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. + #### `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. diff --git a/packages/fast-html/src/components/utilities.ts b/packages/fast-html/src/components/utilities.ts index cd49d159af4..89dac657f44 100644 --- a/packages/fast-html/src/components/utilities.ts +++ b/packages/fast-html/src/components/utilities.ts @@ -1938,18 +1938,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; } From 2b056d312b7656d5b34333f7e5eacfcb3400ea8e Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:49:50 -0700 Subject: [PATCH 3/7] chore: update fast-html change type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...icrosoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json b/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json index 5aef35d44ae..9146a0d6e3f 100644 --- a/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json +++ b/change/@microsoft-fast-html-4fbef46f-8bc2-4bd4-a785-599c7b2ed9ea.json @@ -1,5 +1,5 @@ { - "type": "none", + "type": "prerelease", "comment": "breaking: replace arrays during observerMap deepMerge", "packageName": "@microsoft/fast-html", "email": "7559015+janechu@users.noreply.github.com", From ddbf2a8deeeb4585fe4d422fa25108f876280777 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:27:07 -0700 Subject: [PATCH 4/7] test: cover observerMap replacement array observation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fixtures/deep-merge/deep-merge.spec.ts | 67 ++++++++++++++++++ .../test/fixtures/deep-merge/main.ts | 69 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts b/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts index 6f52a4ff9a6..d75ef6b1276 100644 --- a/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts +++ b/packages/fast-html/test/fixtures/deep-merge/deep-merge.spec.ts @@ -285,6 +285,73 @@ test.describe("Deep Merge Test Fixture", () => { await expect(page.locator(".user-card").first()).toContainText("Order #103"); }); + test("should observe nested object properties after replacing arrays with deepMerge", async ({ + page, + }) => { + const hydrationCompleted = page.waitForFunction( + () => (window as any).hydrationCompleted === true, + ); + await page.goto("/fixtures/deep-merge/"); + await hydrationCompleted; + + await page + .locator("deep-merge-test-element") + .evaluate((element: any) => element.replaceOrdersAndMutateNestedData()); + + const firstOrder = page.locator(".order").first(); + await expect(firstOrder).toContainText("Total: $123.45"); + await expect(firstOrder.locator(".item").first()).toContainText("Views: 401"); + }); + + test("should observe nested array mutations after replacing arrays with deepMerge", async ({ + page, + }) => { + const hydrationCompleted = page.waitForFunction( + () => (window as any).hydrationCompleted === true, + ); + await page.goto("/fixtures/deep-merge/"); + await hydrationCompleted; + + await page + .locator("deep-merge-test-element") + .evaluate((element: any) => element.replaceOrdersAndPushNestedItem()); + + const firstOrder = page.locator(".order").first(); + await expect(firstOrder.locator(".item")).toHaveCount(2); + await expect(firstOrder).toContainText("Stand"); + }); + + test("should not duplicate observerMap array item accessors", async ({ page }) => { + const hydrationCompleted = page.waitForFunction( + () => (window as any).hydrationCompleted === true, + ); + await page.goto("/fixtures/deep-merge/"); + await hydrationCompleted; + + const result = await page + .locator("deep-merge-test-element") + .evaluate((element: any) => element.countAccessorsForInitialOrdersPush()); + + expect(result.orderCount).toBe(3); + expect(result.duplicateAccessors).toEqual([]); + }); + + test("should not duplicate observerMap accessors for initial array items", async ({ + page, + }) => { + const hydrationCompleted = page.waitForFunction( + () => (window as any).hydrationCompleted === true, + ); + await page.goto("/fixtures/deep-merge/"); + await hydrationCompleted; + + const result = await page + .locator("deep-merge-test-element") + .evaluate((element: any) => element.countAccessorsForInitialOrder()); + + expect(result.duplicateAccessors).toEqual([]); + }); + test("should avoid reentrant observerMap deepMerge array changes during notification", async ({ page, }) => { diff --git a/packages/fast-html/test/fixtures/deep-merge/main.ts b/packages/fast-html/test/fixtures/deep-merge/main.ts index c72c1f801f0..ee660009c6f 100644 --- a/packages/fast-html/test/fixtures/deep-merge/main.ts +++ b/packages/fast-html/test/fixtures/deep-merge/main.ts @@ -358,6 +358,75 @@ class DeepMergeTestElement extends FASTElement { }; } + public async replaceOrdersAndMutateNestedData() { + this.updateUserOrders(); + + await Updates.next(); + + this.users[0].orders[0].total = 123.45; + this.users[0].orders[0].items[0].metadata.views = 401; + + await Updates.next(); + } + + public async replaceOrdersAndPushNestedItem() { + this.updateUserOrders(); + + await Updates.next(); + + this.users[0].orders[0].items.push({ + id: 1005, + name: "Stand", + price: 25.0, + inStock: true, + tags: ["accessories"], + metadata: { + views: 50, + rating: 4.1, + }, + }); + + await Updates.next(); + } + + public async countAccessorsForInitialOrdersPush() { + const newOrder = { + id: 104, + date: "2024-05-01", + total: 25.0, + items: [], + }; + + this.users[0].orders.push(newOrder); + + await Updates.next(); + + const accessorNames = Observable.getAccessors(newOrder).map( + accessor => accessor.name, + ); + + return { + accessorCount: accessorNames.length, + duplicateAccessors: accessorNames.filter( + (name, index) => accessorNames.indexOf(name) !== index, + ), + orderCount: this.users[0].orders.length, + }; + } + + public countAccessorsForInitialOrder() { + const accessorNames = Observable.getAccessors(this.users[0].orders[0]).map( + accessor => accessor.name, + ); + + return { + accessorCount: accessorNames.length, + duplicateAccessors: accessorNames.filter( + (name, index) => accessorNames.indexOf(name) !== index, + ), + }; + } + public async testDeepMergeObserverMapReentry() { const items = this.users[0].orders[0].items; const observer = Observable.getNotifier(items); From 79516809f7ee9cdd40cc23ed29b76190d530ce17 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:27:13 -0700 Subject: [PATCH 5/7] fix: reobserve observerMap replacement arrays Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/fast-html/DESIGN.md | 4 +- packages/fast-html/README.md | 4 +- .../fast-html/src/components/utilities.ts | 183 ++++++++++-------- 3 files changed, 110 insertions(+), 81 deletions(-) diff --git a/packages/fast-html/DESIGN.md b/packages/fast-html/DESIGN.md index ca110b402a4..a67bbed810c 100644 --- a/packages/fast-html/DESIGN.md +++ b/packages/fast-html/DESIGN.md @@ -122,7 +122,9 @@ When `properties` is omitted (`observerMap: {}` or `observerMap: "all"`), all ro 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. +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. diff --git a/packages/fast-html/README.md b/packages/fast-html/README.md index 3bcbf974451..7949ece81e5 100644 --- a/packages/fast-html/README.md +++ b/packages/fast-html/README.md @@ -268,7 +268,9 @@ When `properties` is omitted, all root properties are observed (backward compati 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. +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` diff --git a/packages/fast-html/src/components/utilities.ts b/packages/fast-html/src/components/utilities.ts index 89dac657f44..cb15b8922d2 100644 --- a/packages/fast-html/src/components/utilities.ts +++ b/packages/fast-html/src/components/utilities.ts @@ -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, @@ -181,7 +181,58 @@ const objectTargetsMap = new WeakMap(); /** * A map of arrays being observered */ -const observedArraysMap = new WeakMap(); +const observedArraysMap = new WeakSet(); + +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 @@ -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; @@ -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 @@ -1375,7 +1421,7 @@ export function assignObservables( switch (dataType) { case "array": { - const context = findDef(schema); + const context = findArrayItemDef(schema); if (context) { proxiedData = assignObservablesToArray( @@ -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 @@ -1446,6 +1475,8 @@ function assignProxyToItemsInArray( originalItem: any, schema: JSONSchema | JSONSchemaDefinition, rootSchema: JSONSchema, + target: any, + rootProperty: string, ): void { const schemaProperties = getSchemaProperties(schema); @@ -1472,7 +1503,14 @@ function assignProxyToItemsInArray( ); // Then make the property observable - Observable.defineProperty(proxiableItem, key); + defineObservableProperty( + proxiableItem, + key, + childSchema, + rootSchema, + target, + rootProperty, + ); }); } @@ -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]; @@ -1561,19 +1599,6 @@ function assignProxyToItemsInObject( target, rootProperty, ); - - if (!observedArraysMap.has(proxiedData)) { - observedArraysMap.set( - proxiedData, - assignObservablesToArray( - proxiedData, - definition as JSONSchemaDefinition, - rootSchema, - target, - rootProperty, - ), - ); - } } } } @@ -1657,7 +1682,7 @@ export function assignProxy( } obj[prop] = assignObservables( - schema, + childSchema ?? schema, rootSchema, value, target, From 1c10a2a6738db193fdc72f4982de70e8c95257ed Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:51:41 -0700 Subject: [PATCH 6/7] test: add complex observerMap nesting coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/fixtures/observer-map/index.html | 170 +++++++++++------- .../test/fixtures/observer-map/main.ts | 109 +++++++++++ .../observer-map/observer-map.spec.ts | 46 +++++ .../test/fixtures/observer-map/templates.html | 86 ++++++--- 4 files changed, 322 insertions(+), 89 deletions(-) diff --git a/packages/fast-html/test/fixtures/observer-map/index.html b/packages/fast-html/test/fixtures/observer-map/index.html index 23305732044..906112ff1eb 100644 --- a/packages/fast-html/test/fixtures/observer-map/index.html +++ b/packages/fast-html/test/fixtures/observer-map/index.html @@ -13,8 +13,8 @@
  • baz
  • - - + +

    Global Stats

    @@ -66,8 +76,8 @@

    Global Stats

    Render: 0.8s
    - - + +
    @@ -111,14 +121,14 @@

    Alice Johnson2
    - +
    @@ -170,12 +180,12 @@

    Posts (2
    - +
    - +
    @@ -215,14 +225,14 @@

    Bob Smith1
    - +
    - +
    + @@ -432,8 +476,8 @@

    Posts ({{user.posts.length}} posts)

  • {{item}}
  • - - + +
    diff --git a/packages/fast-html/test/fixtures/observer-map/main.ts b/packages/fast-html/test/fixtures/observer-map/main.ts index c3c29046b53..e1dd744be85 100644 --- a/packages/fast-html/test/fixtures/observer-map/main.ts +++ b/packages/fast-html/test/fixtures/observer-map/main.ts @@ -320,6 +320,48 @@ class ObserverMapInternalTestElement extends FASTElement { }, ]; + public complex = { + owner: { + profile: { + name: "Owner A", + settings: { + timezone: "UTC", + }, + }, + }, + suites: [ + { + name: "Suite A", + sections: [ + { + title: "Section A", + rows: [ + { + label: "Row A", + cells: [ + { + value: "Cell A", + meta: { + status: "ready", + history: [ + { + note: "created", + flags: { + reviewed: false, + }, + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + ], + }; + public removeAllItems() { this.groups[0].items.shift(); } @@ -368,6 +410,73 @@ class ObserverMapInternalTestElement extends FASTElement { this.groups[0].items[0].actions.trailing[0].label = "action label B"; } + public updateComplexObjectFields() { + const cell = this.complex.suites[0].sections[0].rows[0].cells[0]; + + this.complex.owner.profile.name = "Owner B"; + this.complex.owner.profile.settings.timezone = "PST"; + cell.meta.status = "done"; + cell.meta.history[0].flags.reviewed = true; + } + + public addComplexNestedItems() { + const row = this.complex.suites[0].sections[0].rows[0]; + + row.cells[0].meta.history.push({ + note: "reviewed", + flags: { + reviewed: true, + }, + }); + + row.cells.push({ + value: "Cell B", + meta: { + status: "pending", + history: [ + { + note: "queued", + flags: { + reviewed: false, + }, + }, + ], + }, + }); + } + + public addComplexSectionAndMutate() { + const suite = this.complex.suites[0]; + + suite.sections.push({ + title: "Section B", + rows: [ + { + label: "Row B", + cells: [ + { + value: "Cell C", + meta: { + status: "new", + history: [ + { + note: "draft", + flags: { + reviewed: false, + }, + }, + ], + }, + }, + ], + }, + ], + }); + + suite.sections[1].rows[0].cells[0].meta.history[0].flags.reviewed = true; + suite.sections[1].rows[0].cells[0].meta.status = "approved"; + } + public defineB() { this.a = { b: { diff --git a/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts b/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts index f258d299fa8..600ca45cac9 100644 --- a/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts +++ b/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts @@ -256,6 +256,52 @@ test.describe("ObserverMap", async () => { await expect(page.locator(".action-label")).toHaveText("action label B"); }); + test("should render complex nested arrays and objects", async ({ page }) => { + await expect(page.locator(".complex-owner")).toContainText("Owner: Owner A"); + await expect(page.locator(".complex-owner")).toContainText("Timezone: UTC"); + await expect(page.locator(".complex-suite")).toHaveCount(1); + await expect(page.locator(".complex-section")).toHaveCount(1); + await expect(page.locator(".complex-row")).toHaveCount(1); + await expect(page.locator(".complex-cell")).toHaveCount(1); + await expect(page.locator(".complex-cell")).toContainText("Cell A: ready"); + await expect(page.locator(".complex-history")).toHaveText("created/false"); + }); + + test("should update deep object fields inside nested arrays", async ({ page }) => { + await page.locator("button:has-text('Update complex object fields')").click(); + + await expect(page.locator(".complex-owner")).toContainText("Owner: Owner B"); + await expect(page.locator(".complex-owner")).toContainText("Timezone: PST"); + await expect(page.locator(".complex-cell")).toContainText("Cell A: done"); + await expect(page.locator(".complex-history")).toHaveText("created/true"); + }); + + test("should observe additions at multiple nested array levels", async ({ page }) => { + await page.locator("button:has-text('Add complex nested items')").click(); + + await expect(page.locator(".complex-cell")).toHaveCount(2); + await expect(page.locator(".complex-history")).toHaveCount(3); + await expect(page.locator(".complex-cell").nth(1)).toContainText( + "Cell B: pending", + ); + await expect(page.locator(".complex-history").nth(1)).toHaveText("reviewed/true"); + await expect(page.locator(".complex-history").nth(2)).toHaveText("queued/false"); + }); + + test("should observe new deeply nested objects created in added array branches", async ({ + page, + }) => { + await page.locator("button:has-text('Add complex section')").click(); + + await expect(page.locator(".complex-section")).toHaveCount(2); + await expect(page.locator(".complex-section").nth(1)).toContainText("Section B"); + await expect(page.locator(".complex-row").nth(1)).toContainText("Row B"); + await expect(page.locator(".complex-cell").nth(1)).toContainText( + "Cell C: approved", + ); + await expect(page.locator(".complex-history").nth(1)).toHaveText("draft/true"); + }); + test("should update global stats with nested metrics", async ({ page }) => { // Check initial engagement stats const initialDaily = await page diff --git a/packages/fast-html/test/fixtures/observer-map/templates.html b/packages/fast-html/test/fixtures/observer-map/templates.html index c6c83824a4e..5036191e293 100644 --- a/packages/fast-html/test/fixtures/observer-map/templates.html +++ b/packages/fast-html/test/fixtures/observer-map/templates.html @@ -17,8 +17,8 @@

    Global Stats

    Render: {{stats.metrics.performance.rendertime}}s
    - - + +
    @@ -60,14 +60,14 @@

    {{user.name}} (ID: {{user.id}})
    - - - - - - - - + + + + + + + +
    @@ -92,12 +92,12 @@

    Posts ({{user.posts.length}} posts)

    - +
    - +
    @@ -122,14 +122,14 @@

    Posts ({{user.posts.length}} posts)

    {{a.b.c}} - - + +
    {{x.y.z}} - - + +
    @@ -142,21 +142,55 @@

    Posts ({{user.posts.length}} posts)

    - - - - - - + + + + + + +
    + Owner: {{complex.owner.profile.name}} | + Timezone: {{complex.owner.profile.settings.timezone}} +
    +
    + +
    +

    {{suite.name}}

    + +
    +
    {{section.title}}
    + +
    + {{row.label}} + + + {{cell.value}}: {{cell.meta.status}} + + + {{history.note}}/{{history.flags.reviewed}} + + + + +
    +
    +
    +
    +
    +
    +
    + + + @@ -166,7 +200,7 @@

    Posts ({{user.posts.length}} posts)

  • {{item}}
  • - - + +
    From dd0326e3d132359f6bdac1edf4d836e7d6a4f076 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:41:07 -0700 Subject: [PATCH 7/7] test: cover nested replacement array mutation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/fixtures/observer-map/main.ts | 44 ++++++++++++++++++- .../observer-map/observer-map.spec.ts | 20 +++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/fast-html/test/fixtures/observer-map/main.ts b/packages/fast-html/test/fixtures/observer-map/main.ts index e1dd744be85..aa0d4c38850 100644 --- a/packages/fast-html/test/fixtures/observer-map/main.ts +++ b/packages/fast-html/test/fixtures/observer-map/main.ts @@ -1,5 +1,12 @@ -import { attr, FASTElement, Observable, observable } from "@microsoft/fast-element"; +import { + attr, + FASTElement, + Observable, + observable, + Updates, +} from "@microsoft/fast-element"; import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html"; +import { deepMerge } from "@microsoft/fast-html/utilities.js"; class ObserverMapTestElement extends FASTElement { public users: any[] = [ @@ -477,6 +484,41 @@ class ObserverMapInternalTestElement extends FASTElement { suite.sections[1].rows[0].cells[0].meta.status = "approved"; } + public async replaceComplexRowsAndMutateNestedObject() { + const section = this.complex.suites[0].sections[0]; + + deepMerge(section, { + rows: [ + { + label: "Row Replacement", + cells: [ + { + value: "Cell Replacement", + meta: { + status: "replaced", + history: [ + { + note: "replacement", + flags: { + reviewed: false, + }, + }, + ], + }, + }, + ], + }, + ], + }); + + await Updates.next(); + + section.rows[0].cells[0].meta.status = "mutated"; + section.rows[0].cells[0].meta.history[0].flags.reviewed = true; + + await Updates.next(); + } + public defineB() { this.a = { b: { diff --git a/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts b/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts index 600ca45cac9..f917dd5f6c6 100644 --- a/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts +++ b/packages/fast-html/test/fixtures/observer-map/observer-map.spec.ts @@ -302,6 +302,26 @@ test.describe("ObserverMap", async () => { await expect(page.locator(".complex-history").nth(1)).toHaveText("draft/true"); }); + test("should observe deep mutations after replacing nested arrays", async ({ + page, + }) => { + await page + .locator("observer-map-internal-test-element") + .evaluate((element: any) => + element.replaceComplexRowsAndMutateNestedObject(), + ); + + await expect(page.locator(".complex-row").first()).toContainText( + "Row Replacement", + ); + await expect(page.locator(".complex-cell").first()).toContainText( + "Cell Replacement: mutated", + ); + await expect(page.locator(".complex-history").first()).toHaveText( + "replacement/true", + ); + }); + test("should update global stats with nested metrics", async ({ page }) => { // Check initial engagement stats const initialDaily = await page