From c9a097b0f48e4091490c02a0d76e29606117b174 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:50:16 -0700 Subject: [PATCH 1/8] feat(fast-html): add AttributeMap class for automatic @attr definitions AttributeMap inspects the JSON schema generated by TemplateElement for a custom element and defines @attr properties on the class prototype for all leaf-level bindings (simple {{foo}} and attribute {{bar}} paths that have no nested properties, no type, and no anyOf). - Add AttributeMap class in packages/fast-html/src/components/attribute-map.ts - Reads root properties from the Schema - Skips properties with nested 'properties', 'type', or 'anyOf' (not leaves) - Skips properties that already have an @attr or @observable accessor - Converts camelCase property names to dash-case (fooBar -> foo-bar) - Creates AttributeDefinition instances via Observable.defineProperty - Updates FASTElementDefinition.attributeLookup and propertyLookup - Integrate AttributeMap into TemplateElement (template.ts / index.ts) - Add AttributeMapOption constant and type - Add attributeMap option to ElementOptions interface - TemplateElement.options() stores attributeMap option - connectedCallback instantiates AttributeMap when attributeMap === 'all' - defineProperties() called after schema is fully populated - Add tests in attribute-map.spec.ts (browser E2E tests) - Add fixture in test/fixtures/attribute-map/ Usage: TemplateElement.options({ 'my-element': { attributeMap: 'all' }, }); Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/attribute-map.spec.ts | 89 +++++++++++++++++++ .../fast-html/src/components/attribute-map.ts | 77 ++++++++++++++++ packages/fast-html/src/components/index.ts | 6 +- packages/fast-html/src/components/template.ts | 33 +++++++ .../attribute-map/attribute-map.spec.ts | 86 ++++++++++++++++++ .../test/fixtures/attribute-map/index.html | 20 +++++ .../test/fixtures/attribute-map/main.ts | 28 ++++++ 7 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 packages/fast-html/src/components/attribute-map.spec.ts create mode 100644 packages/fast-html/src/components/attribute-map.ts create mode 100644 packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts create mode 100644 packages/fast-html/test/fixtures/attribute-map/index.html create mode 100644 packages/fast-html/test/fixtures/attribute-map/main.ts diff --git a/packages/fast-html/src/components/attribute-map.spec.ts b/packages/fast-html/src/components/attribute-map.spec.ts new file mode 100644 index 00000000000..42665d1d447 --- /dev/null +++ b/packages/fast-html/src/components/attribute-map.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from "@playwright/test"; + +test.describe("AttributeMap", async () => { + test.beforeEach(async ({ page }) => { + await page.goto("/fixtures/attribute-map/"); + await page.waitForSelector("attribute-map-test-element"); + }); + + test("should define @attr for a simple leaf property", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + const hasFoo = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).some((a: any) => a.name === "foo"); + }); + + expect(hasFoo).toBeTruthy(); + }); + + test("should define @attr for a camelCase property", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + const hasFooBar = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).some((a: any) => a.name === "fooBar"); + }); + + expect(hasFooBar).toBeTruthy(); + }); + + test("should convert camelCase property name to dash-case attribute name", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + const attribute = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).find((a: any) => a.name === "fooBar")?.attribute; + }); + + expect(attribute).toBe("foo-bar"); + }); + + test("should not define @attr for event handler methods", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + const accessorNames = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).map((a: any) => a.name); + }); + + // setFoo, setFooBar, setMultiple are methods (event handlers) - not in schema + expect(accessorNames).not.toContain("setFoo"); + expect(accessorNames).not.toContain("setFooBar"); + expect(accessorNames).not.toContain("setMultiple"); + }); + + test("should update definition attributeLookup for simple properties", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + const fooAttrLookup = await element.evaluate(node => { + const { fastElementRegistry } = (window as any).__FAST__; + const definition = fastElementRegistry.getForInstance(node); + return definition?.attributeLookup["foo"]?.name ?? null; + }); + + expect(fooAttrLookup).toBe("foo"); + }); + + test("should update definition attributeLookup with dash-case for camelCase properties", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + const fooBarAttrLookup = await element.evaluate(node => { + const { fastElementRegistry } = (window as any).__FAST__; + const definition = fastElementRegistry.getForInstance(node); + return definition?.attributeLookup["foo-bar"]?.name ?? null; + }); + + expect(fooBarAttrLookup).toBe("fooBar"); + }); +}); diff --git a/packages/fast-html/src/components/attribute-map.ts b/packages/fast-html/src/components/attribute-map.ts new file mode 100644 index 00000000000..b1493d7a0c0 --- /dev/null +++ b/packages/fast-html/src/components/attribute-map.ts @@ -0,0 +1,77 @@ +import type { FASTElementDefinition } from "@microsoft/fast-element"; +import { AttributeDefinition, Observable } from "@microsoft/fast-element"; +import type { Schema } from "./schema.js"; + +/** + * AttributeMap provides functionality for detecting simple (leaf) properties in + * a generated JSON schema and defining them as @attr properties on a class prototype. + * + * A property is a candidate for @attr when its schema entry has no nested `properties`, + * no `type`, and no `anyOf` — i.e. it is a plain binding like {{foo}} or id="{{bar}}". + * + * camelCase property names are converted to dash-case attribute names (e.g. fooBar → foo-bar). + */ +export class AttributeMap { + private schema: Schema; + private classPrototype: any; + private definition: FASTElementDefinition | undefined; + + constructor(classPrototype: any, schema: Schema, definition?: FASTElementDefinition) { + this.classPrototype = classPrototype; + this.schema = schema; + this.definition = definition; + } + + public defineProperties(): void { + const propertyNames = this.schema.getRootProperties(); + const existingAccessors = Observable.getAccessors(this.classPrototype); + + for (const propertyName of propertyNames) { + const propertySchema = this.schema.getSchema(propertyName); + + // Only create @attr for leaf properties: + // - no nested properties (not a dot-syntax path) + // - no type (not an explicitly typed value like an array) + // - no anyOf (not a child element reference) + if ( + !propertySchema || + propertySchema.properties || + propertySchema.type || + propertySchema.anyOf + ) { + continue; + } + + // Skip if the property already has an accessor (from @attr or @observable) + if (existingAccessors.some(accessor => accessor.name === propertyName)) { + continue; + } + + const attributeName = this.camelCaseToDashCase(propertyName); + const attrDef = new AttributeDefinition( + this.classPrototype.constructor, + propertyName, + attributeName, + ); + + Observable.defineProperty(this.classPrototype, attrDef); + + if (this.definition) { + (this.definition.attributeLookup as Record)[ + attributeName + ] = attrDef; + (this.definition.propertyLookup as Record)[ + propertyName + ] = attrDef; + } + } + } + + /** + * Converts a camelCase string to dash-case. + * e.g. fooBar → foo-bar + */ + private camelCaseToDashCase(str: string): string { + return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); + } +} diff --git a/packages/fast-html/src/components/index.ts b/packages/fast-html/src/components/index.ts index 37bd831adfc..585a5f06f7a 100644 --- a/packages/fast-html/src/components/index.ts +++ b/packages/fast-html/src/components/index.ts @@ -1,9 +1,11 @@ +export { AttributeMap } from "./attribute-map.js"; export { RenderableFASTElement } from "./element.js"; export { ObserverMap } from "./observer-map.js"; export { - ObserverMapOption, - TemplateElement, + AttributeMapOption, type ElementOptions, type ElementOptionsDictionary, type HydrationLifecycleCallbacks, + ObserverMapOption, + TemplateElement, } from "./template.js"; diff --git a/packages/fast-html/src/components/template.ts b/packages/fast-html/src/components/template.ts index 819db7b5c97..b7c59e808f2 100644 --- a/packages/fast-html/src/components/template.ts +++ b/packages/fast-html/src/components/template.ts @@ -17,6 +17,7 @@ import { } from "@microsoft/fast-element"; import "@microsoft/fast-element/install-hydratable-view-templates.js"; import { Message } from "../interfaces.js"; +import { AttributeMap } from "./attribute-map.js"; import { ObserverMap } from "./observer-map.js"; import { Schema } from "./schema.js"; import { @@ -56,11 +57,25 @@ export const ObserverMapOption = { export type ObserverMapOption = (typeof ObserverMapOption)[keyof typeof ObserverMapOption]; +/** + * Values for the attributeMap element option. + */ +export const AttributeMapOption = { + all: "all", +} as const; + +/** + * Type for the attributeMap element option. + */ +export type AttributeMapOption = + (typeof AttributeMapOption)[keyof typeof AttributeMapOption]; + /** * Element options the TemplateElement will use to update the registered element */ export interface ElementOptions { observerMap?: ObserverMapOption; + attributeMap?: AttributeMapOption; } /** @@ -108,6 +123,11 @@ class TemplateElement extends FASTElement { */ private observerMap?: ObserverMap; + /** + * AttributeMap instance for defining @attr properties + */ + private attributeMap?: AttributeMap; + /** * Default element options */ @@ -151,6 +171,7 @@ class TemplateElement extends FASTElement { const value = elementOptions[key]; result[key] = { observerMap: value.observerMap, + attributeMap: value.attributeMap, }; } @@ -209,6 +230,15 @@ class TemplateElement extends FASTElement { const registeredFastElement: FASTElementDefinition | undefined = fastElementRegistry.getByType(value); + + if (TemplateElement.elementOptions[name]?.attributeMap === "all") { + this.attributeMap = new AttributeMap( + value.prototype, + this.schema as Schema, + registeredFastElement, + ); + } + const template = this.getElementsByTagName("template").item(0); if (template) { @@ -231,6 +261,9 @@ class TemplateElement extends FASTElement { // Define the root properties cached in the observer map as observable (only if observerMap exists) this.observerMap?.defineProperties(); + // Define the leaf properties as @attr (only if attributeMap exists) + this.attributeMap?.defineProperties(); + if (registeredFastElement) { // Attach lifecycle callbacks to the definition before assigning template // This allows the Observable notification to trigger the callbacks diff --git a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts new file mode 100644 index 00000000000..2ac79b49cd2 --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test"; + +test.describe("AttributeMap", async () => { + test.beforeEach(async ({ page }) => { + await page.goto("/fixtures/attribute-map/"); + await page.waitForSelector("attribute-map-test-element"); + }); + + test("should define @attr accessors for leaf properties from the template", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + const accessors = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).map((a: any) => ({ name: a.name, attribute: a.attribute })); + }); + + expect(accessors.some((a: any) => a.name === "foo")).toBeTruthy(); + expect(accessors.some((a: any) => a.name === "fooBar")).toBeTruthy(); + }); + + test("should use dash-case attribute name for camelCase property", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + const fooBarAccessor = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).find((a: any) => a.name === "fooBar"); + }); + + expect(fooBarAccessor?.attribute).toBe("foo-bar"); + }); + + test("should use the same name for non-camelCase property", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + const fooAccessor = await element.evaluate(node => { + return (window as any).Observable.getAccessors( + Object.getPrototypeOf(node), + ).find((a: any) => a.name === "foo"); + }); + + expect(fooAccessor?.attribute).toBe("foo"); + }); + + test("should render updated value when foo property is set", async ({ page }) => { + const fooDiv = page.locator(".foo-value"); + + await page.locator("button:text-is('Set Foo')").click(); + + await expect(fooDiv).toHaveText("hello"); + }); + + test("should render updated value when fooBar property is set", async ({ page }) => { + const fooBarDiv = page.locator(".foo-bar-value"); + + await page.locator("button:text-is('Set FooBar')").click(); + + await expect(fooBarDiv).toHaveText("world"); + }); + + test("should update both properties independently", async ({ page }) => { + const fooDiv = page.locator(".foo-value"); + const fooBarDiv = page.locator(".foo-bar-value"); + + await page.locator("button:text-is('Set Foo')").click(); + await expect(fooDiv).toHaveText("hello"); + + await page.locator("button:text-is('Set FooBar')").click(); + await expect(fooBarDiv).toHaveText("world"); + }); + + test("should update both properties when setting multiple", async ({ page }) => { + const fooDiv = page.locator(".foo-value"); + const fooBarDiv = page.locator(".foo-bar-value"); + + await page.locator("button:text-is('Set Multiple')").click(); + + await expect(fooDiv).toHaveText("updated"); + await expect(fooBarDiv).toHaveText("also-updated"); + }); +}); diff --git a/packages/fast-html/test/fixtures/attribute-map/index.html b/packages/fast-html/test/fixtures/attribute-map/index.html new file mode 100644 index 00000000000..307dc0d1c88 --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/packages/fast-html/test/fixtures/attribute-map/main.ts b/packages/fast-html/test/fixtures/attribute-map/main.ts new file mode 100644 index 00000000000..5159b389c66 --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/main.ts @@ -0,0 +1,28 @@ +import { FASTElement, fastElementRegistry, Observable } from "@microsoft/fast-element"; +import { TemplateElement } from "@microsoft/fast-html"; + +class AttributeMapTestElement extends FASTElement { + public setFoo() { + (this as any).foo = "hello"; + } + + public setFooBar() { + (this as any).fooBar = "world"; + } + + public setMultiple() { + (this as any).foo = "updated"; + (this as any).fooBar = "also-updated"; + } +} + +AttributeMapTestElement.defineAsync({ name: "attribute-map-test-element" }); + +TemplateElement.options({ + "attribute-map-test-element": { + attributeMap: "all", + }, +}).define({ name: "f-template" }); + +(window as any).Observable = Observable; +(window as any).__FAST__ = { fastElementRegistry }; From fc9f1deb3225fe50975e3afa83bf32f57fdb3c38 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:59:35 -0700 Subject: [PATCH 2/8] Change files --- ...oft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json diff --git a/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json new file mode 100644 index 00000000000..b9b0f4ffc28 --- /dev/null +++ b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Add AttributeMap class for automatic @attr definitions on leaf template bindings", + "packageName": "@microsoft/fast-html", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} From 8ede50da7d5b3dbfe0abd30bee8e95dfd55ca4c6 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:06:48 -0700 Subject: [PATCH 3/8] fix(fast-html): push to observedAttributes so setAttribute triggers template updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update AttributeMap.defineProperties() to also push each newly created attribute name to the existing observedAttributes array on the class. For all f-template-registered elements, registry.define() (which causes the browser to cache observedAttributes) is called AFTER defineProperties() runs, because composeAsync() waits for definition.template to be set before resolving. This creates a reliable window to mutate the array so the browser observes the dynamically-added attributes. Update tests to use element.setAttribute() inside page.evaluate instead of button clicks, testing both directions: - setAttribute() → attributeChangedCallback() → property → template re-render - property assignment → attribute reflection via DOM Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/attribute-map.spec.ts | 36 +++++++++- .../fast-html/src/components/attribute-map.ts | 15 +++++ .../attribute-map/attribute-map.spec.ts | 66 +++++++++++++------ 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/packages/fast-html/src/components/attribute-map.spec.ts b/packages/fast-html/src/components/attribute-map.spec.ts index 42665d1d447..2d546ab0a34 100644 --- a/packages/fast-html/src/components/attribute-map.spec.ts +++ b/packages/fast-html/src/components/attribute-map.spec.ts @@ -53,12 +53,46 @@ test.describe("AttributeMap", async () => { ).map((a: any) => a.name); }); - // setFoo, setFooBar, setMultiple are methods (event handlers) - not in schema + // @click="{setFoo()}" etc. produce "event" type bindings — excluded from schema expect(accessorNames).not.toContain("setFoo"); expect(accessorNames).not.toContain("setFooBar"); expect(accessorNames).not.toContain("setMultiple"); }); + test("should update template when attribute is set via setAttribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => node.setAttribute("foo", "attr-value")); + + await expect(page.locator(".foo-value")).toHaveText("attr-value"); + }); + + test("should update template when dash-case attribute is set via setAttribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => node.setAttribute("foo-bar", "bar-attr-value")); + + await expect(page.locator(".foo-bar-value")).toHaveText("bar-attr-value"); + }); + + test("should reflect property value back to attribute", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => { + (node as any).foo = "reflected"; + }); + + // FAST reflects attributes asynchronously via Updates.enqueue + await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); + + const attrValue = await element.evaluate(node => node.getAttribute("foo")); + expect(attrValue).toBe("reflected"); + }); + test("should update definition attributeLookup for simple properties", async ({ page, }) => { diff --git a/packages/fast-html/src/components/attribute-map.ts b/packages/fast-html/src/components/attribute-map.ts index b1493d7a0c0..01242c40c8a 100644 --- a/packages/fast-html/src/components/attribute-map.ts +++ b/packages/fast-html/src/components/attribute-map.ts @@ -56,6 +56,21 @@ export class AttributeMap { Observable.defineProperty(this.classPrototype, attrDef); + // Push to the existing observedAttributes array on the class. + // For all f-template-registered elements, registry.define() (which causes + // the browser to cache observedAttributes) is called AFTER this method runs. + // Mutating the existing array reference ensures the browser observes these + // attributes, enabling setAttribute() → attributeChangedCallback() → template update. + const existingObservedAttrs: string[] | undefined = ( + this.classPrototype.constructor as any + ).observedAttributes; + if ( + Array.isArray(existingObservedAttrs) && + !existingObservedAttrs.includes(attributeName) + ) { + existingObservedAttrs.push(attributeName); + } + if (this.definition) { (this.definition.attributeLookup as Record)[ attributeName diff --git a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts index 2ac79b49cd2..ef55173ddb2 100644 --- a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts +++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts @@ -47,40 +47,64 @@ test.describe("AttributeMap", async () => { expect(fooAccessor?.attribute).toBe("foo"); }); - test("should render updated value when foo property is set", async ({ page }) => { - const fooDiv = page.locator(".foo-value"); + test("should update template when foo attribute is set via setAttribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => node.setAttribute("foo", "hello-via-attr")); + + await expect(page.locator(".foo-value")).toHaveText("hello-via-attr"); + }); + + test("should update template when foo-bar attribute is set via setAttribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); - await page.locator("button:text-is('Set Foo')").click(); + await element.evaluate(node => node.setAttribute("foo-bar", "world-via-attr")); - await expect(fooDiv).toHaveText("hello"); + await expect(page.locator(".foo-bar-value")).toHaveText("world-via-attr"); }); - test("should render updated value when fooBar property is set", async ({ page }) => { - const fooBarDiv = page.locator(".foo-bar-value"); + test("should update both properties when set via setAttribute", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); - await page.locator("button:text-is('Set FooBar')").click(); + await element.evaluate(node => { + node.setAttribute("foo", "multi-foo"); + node.setAttribute("foo-bar", "multi-bar"); + }); - await expect(fooBarDiv).toHaveText("world"); + await expect(page.locator(".foo-value")).toHaveText("multi-foo"); + await expect(page.locator(".foo-bar-value")).toHaveText("multi-bar"); }); - test("should update both properties independently", async ({ page }) => { - const fooDiv = page.locator(".foo-value"); - const fooBarDiv = page.locator(".foo-bar-value"); + test("should reflect foo property value back to foo attribute", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => { + (node as any).foo = "reflected-value"; + }); - await page.locator("button:text-is('Set Foo')").click(); - await expect(fooDiv).toHaveText("hello"); + // FAST reflects attributes asynchronously via Updates.enqueue + await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); - await page.locator("button:text-is('Set FooBar')").click(); - await expect(fooBarDiv).toHaveText("world"); + const attrValue = await element.evaluate(node => node.getAttribute("foo")); + expect(attrValue).toBe("reflected-value"); }); - test("should update both properties when setting multiple", async ({ page }) => { - const fooDiv = page.locator(".foo-value"); - const fooBarDiv = page.locator(".foo-bar-value"); + test("should reflect fooBar property value back to foo-bar attribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => { + (node as any).fooBar = "bar-reflected"; + }); - await page.locator("button:text-is('Set Multiple')").click(); + await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); - await expect(fooDiv).toHaveText("updated"); - await expect(fooBarDiv).toHaveText("also-updated"); + const attrValue = await element.evaluate(node => node.getAttribute("foo-bar")); + expect(attrValue).toBe("bar-reflected"); }); }); From b8d84073bb5e5e5319417857ffb00f8f17cb2309 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:37:05 -0700 Subject: [PATCH 4/8] refactor(fast-html): remove Observable/fastElementRegistry globals from attribute-map fixture Tests no longer need window.Observable or window.__FAST__ to verify AttributeMap behaviour. Use Object.getOwnPropertyDescriptor to check that accessor get/set was added to the prototype, and verify attribute lookup via setAttribute behaviour instead of inspecting internal registry state. Also update the beachball change type to prerelease. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json | 2 +- .../src/components/attribute-map.spec.ts | 74 ++++++++++--------- .../attribute-map/attribute-map.spec.ts | 31 ++++---- .../test/fixtures/attribute-map/main.ts | 5 +- 4 files changed, 56 insertions(+), 56 deletions(-) diff --git a/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json index b9b0f4ffc28..d10372a542e 100644 --- a/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json +++ b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json @@ -1,5 +1,5 @@ { - "type": "none", + "type": "prerelease", "comment": "Add AttributeMap class for automatic @attr definitions on leaf template bindings", "packageName": "@microsoft/fast-html", "email": "7559015+janechu@users.noreply.github.com", diff --git a/packages/fast-html/src/components/attribute-map.spec.ts b/packages/fast-html/src/components/attribute-map.spec.ts index 2d546ab0a34..149741d5ec7 100644 --- a/packages/fast-html/src/components/attribute-map.spec.ts +++ b/packages/fast-html/src/components/attribute-map.spec.ts @@ -9,25 +9,29 @@ test.describe("AttributeMap", async () => { test("should define @attr for a simple leaf property", async ({ page }) => { const element = page.locator("attribute-map-test-element"); - const hasFoo = await element.evaluate(node => { - return (window as any).Observable.getAccessors( + const hasFooAccessor = await element.evaluate(node => { + const desc = Object.getOwnPropertyDescriptor( Object.getPrototypeOf(node), - ).some((a: any) => a.name === "foo"); + "foo", + ); + return typeof desc?.get === "function"; }); - expect(hasFoo).toBeTruthy(); + expect(hasFooAccessor).toBeTruthy(); }); test("should define @attr for a camelCase property", async ({ page }) => { const element = page.locator("attribute-map-test-element"); - const hasFooBar = await element.evaluate(node => { - return (window as any).Observable.getAccessors( + const hasFooBarAccessor = await element.evaluate(node => { + const desc = Object.getOwnPropertyDescriptor( Object.getPrototypeOf(node), - ).some((a: any) => a.name === "fooBar"); + "fooBar", + ); + return typeof desc?.get === "function"; }); - expect(hasFooBar).toBeTruthy(); + expect(hasFooBarAccessor).toBeTruthy(); }); test("should convert camelCase property name to dash-case attribute name", async ({ @@ -35,28 +39,34 @@ test.describe("AttributeMap", async () => { }) => { const element = page.locator("attribute-map-test-element"); - const attribute = await element.evaluate(node => { - return (window as any).Observable.getAccessors( - Object.getPrototypeOf(node), - ).find((a: any) => a.name === "fooBar")?.attribute; - }); + // Setting the dash-case attribute should update the camelCase property + await element.evaluate(node => node.setAttribute("foo-bar", "dash-case-test")); + const propValue = await element.evaluate(node => (node as any).fooBar); - expect(attribute).toBe("foo-bar"); + expect(propValue).toBe("dash-case-test"); }); test("should not define @attr for event handler methods", async ({ page }) => { const element = page.locator("attribute-map-test-element"); - const accessorNames = await element.evaluate(node => { - return (window as any).Observable.getAccessors( - Object.getPrototypeOf(node), - ).map((a: any) => a.name); + // @click="{setFoo()}" etc. produce "event" type bindings — excluded from schema. + // Regular methods have a value descriptor, not a getter/setter. + const results = await element.evaluate(node => { + const proto = Object.getPrototypeOf(node); + const isAccessor = (name: string) => { + const desc = Object.getOwnPropertyDescriptor(proto, name); + return typeof desc?.get === "function"; + }; + return { + setFoo: isAccessor("setFoo"), + setFooBar: isAccessor("setFooBar"), + setMultiple: isAccessor("setMultiple"), + }; }); - // @click="{setFoo()}" etc. produce "event" type bindings — excluded from schema - expect(accessorNames).not.toContain("setFoo"); - expect(accessorNames).not.toContain("setFooBar"); - expect(accessorNames).not.toContain("setMultiple"); + expect(results.setFoo).toBe(false); + expect(results.setFooBar).toBe(false); + expect(results.setMultiple).toBe(false); }); test("should update template when attribute is set via setAttribute", async ({ @@ -98,13 +108,11 @@ test.describe("AttributeMap", async () => { }) => { const element = page.locator("attribute-map-test-element"); - const fooAttrLookup = await element.evaluate(node => { - const { fastElementRegistry } = (window as any).__FAST__; - const definition = fastElementRegistry.getForInstance(node); - return definition?.attributeLookup["foo"]?.name ?? null; - }); + // setAttribute triggers attributeChangedCallback via attributeLookup + await element.evaluate(node => node.setAttribute("foo", "lookup-test")); + const propValue = await element.evaluate(node => (node as any).foo); - expect(fooAttrLookup).toBe("foo"); + expect(propValue).toBe("lookup-test"); }); test("should update definition attributeLookup with dash-case for camelCase properties", async ({ @@ -112,12 +120,10 @@ test.describe("AttributeMap", async () => { }) => { const element = page.locator("attribute-map-test-element"); - const fooBarAttrLookup = await element.evaluate(node => { - const { fastElementRegistry } = (window as any).__FAST__; - const definition = fastElementRegistry.getForInstance(node); - return definition?.attributeLookup["foo-bar"]?.name ?? null; - }); + // setAttribute with dash-case triggers attributeChangedCallback for the camelCase property + await element.evaluate(node => node.setAttribute("foo-bar", "lookup-bar-test")); + const propValue = await element.evaluate(node => (node as any).fooBar); - expect(fooBarAttrLookup).toBe("fooBar"); + expect(propValue).toBe("lookup-bar-test"); }); }); diff --git a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts index ef55173ddb2..67171ba2590 100644 --- a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts +++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts @@ -12,13 +12,14 @@ test.describe("AttributeMap", async () => { const element = page.locator("attribute-map-test-element"); const accessors = await element.evaluate(node => { - return (window as any).Observable.getAccessors( - Object.getPrototypeOf(node), - ).map((a: any) => ({ name: a.name, attribute: a.attribute })); + const proto = Object.getPrototypeOf(node); + const isAccessor = (name: string) => + typeof Object.getOwnPropertyDescriptor(proto, name)?.get === "function"; + return { foo: isAccessor("foo"), fooBar: isAccessor("fooBar") }; }); - expect(accessors.some((a: any) => a.name === "foo")).toBeTruthy(); - expect(accessors.some((a: any) => a.name === "fooBar")).toBeTruthy(); + expect(accessors.foo).toBeTruthy(); + expect(accessors.fooBar).toBeTruthy(); }); test("should use dash-case attribute name for camelCase property", async ({ @@ -26,25 +27,21 @@ test.describe("AttributeMap", async () => { }) => { const element = page.locator("attribute-map-test-element"); - const fooBarAccessor = await element.evaluate(node => { - return (window as any).Observable.getAccessors( - Object.getPrototypeOf(node), - ).find((a: any) => a.name === "fooBar"); - }); + // Setting foo-bar attribute should update the fooBar property + await element.evaluate(node => node.setAttribute("foo-bar", "dash-test")); + const propValue = await element.evaluate(node => (node as any).fooBar); - expect(fooBarAccessor?.attribute).toBe("foo-bar"); + expect(propValue).toBe("dash-test"); }); test("should use the same name for non-camelCase property", async ({ page }) => { const element = page.locator("attribute-map-test-element"); - const fooAccessor = await element.evaluate(node => { - return (window as any).Observable.getAccessors( - Object.getPrototypeOf(node), - ).find((a: any) => a.name === "foo"); - }); + // Setting foo attribute should update the foo property + await element.evaluate(node => node.setAttribute("foo", "same-name-test")); + const propValue = await element.evaluate(node => (node as any).foo); - expect(fooAccessor?.attribute).toBe("foo"); + expect(propValue).toBe("same-name-test"); }); test("should update template when foo attribute is set via setAttribute", async ({ diff --git a/packages/fast-html/test/fixtures/attribute-map/main.ts b/packages/fast-html/test/fixtures/attribute-map/main.ts index 5159b389c66..286fd657a27 100644 --- a/packages/fast-html/test/fixtures/attribute-map/main.ts +++ b/packages/fast-html/test/fixtures/attribute-map/main.ts @@ -1,4 +1,4 @@ -import { FASTElement, fastElementRegistry, Observable } from "@microsoft/fast-element"; +import { FASTElement } from "@microsoft/fast-element"; import { TemplateElement } from "@microsoft/fast-html"; class AttributeMapTestElement extends FASTElement { @@ -23,6 +23,3 @@ TemplateElement.options({ attributeMap: "all", }, }).define({ name: "f-template" }); - -(window as any).Observable = Observable; -(window as any).__FAST__ = { fastElementRegistry }; From a91d111e038b091be2560a6be3977118e7974fdb Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:45:26 -0700 Subject: [PATCH 5/8] test(fast-html): verify existing @attr accessors are not overwritten by AttributeMap Add an AttributeMapWithExistingAttrElement fixture element with a pre-defined @attr foo property (default value 'original'). After f-template processes with attributeMap: 'all', tests confirm that: - the @attr default value is preserved (accessor was not re-defined) - setAttribute() still routes through the original @attr definition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fast-html/src/components/attribute-map.spec.ts | 14 ++++++++++++++ .../fixtures/attribute-map/attribute-map.spec.ts | 14 ++++++++++++++ .../test/fixtures/attribute-map/index.html | 8 ++++++++ .../fast-html/test/fixtures/attribute-map/main.ts | 14 +++++++++++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/fast-html/src/components/attribute-map.spec.ts b/packages/fast-html/src/components/attribute-map.spec.ts index 149741d5ec7..475a9ba45fa 100644 --- a/packages/fast-html/src/components/attribute-map.spec.ts +++ b/packages/fast-html/src/components/attribute-map.spec.ts @@ -126,4 +126,18 @@ test.describe("AttributeMap", async () => { expect(propValue).toBe("lookup-bar-test"); }); + + test("should not overwrite an existing @attr accessor", async ({ page }) => { + await page.waitForSelector("attribute-map-existing-attr-test-element"); + const element = page.locator("attribute-map-existing-attr-test-element"); + + // The @attr default value must survive AttributeMap processing + const defaultValue = await element.evaluate(node => (node as any).foo); + expect(defaultValue).toBe("original"); + + // setAttribute must still work via the original @attr definition + await element.evaluate(node => node.setAttribute("foo", "updated")); + const updatedValue = await element.evaluate(node => (node as any).foo); + expect(updatedValue).toBe("updated"); + }); }); diff --git a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts index 67171ba2590..07b62a481b5 100644 --- a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts +++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts @@ -104,4 +104,18 @@ test.describe("AttributeMap", async () => { const attrValue = await element.evaluate(node => node.getAttribute("foo-bar")); expect(attrValue).toBe("bar-reflected"); }); + + test("should not overwrite an existing @attr accessor", async ({ page }) => { + await page.waitForSelector("attribute-map-existing-attr-test-element"); + const element = page.locator("attribute-map-existing-attr-test-element"); + + // The @attr default value must survive AttributeMap processing + const defaultValue = await element.evaluate(node => (node as any).foo); + expect(defaultValue).toBe("original"); + + // setAttribute must still work via the original @attr definition + await element.evaluate(node => node.setAttribute("foo", "updated")); + const updatedValue = await element.evaluate(node => (node as any).foo); + expect(updatedValue).toBe("updated"); + }); }); diff --git a/packages/fast-html/test/fixtures/attribute-map/index.html b/packages/fast-html/test/fixtures/attribute-map/index.html index 307dc0d1c88..89eba373672 100644 --- a/packages/fast-html/test/fixtures/attribute-map/index.html +++ b/packages/fast-html/test/fixtures/attribute-map/index.html @@ -16,5 +16,13 @@ + + + + diff --git a/packages/fast-html/test/fixtures/attribute-map/main.ts b/packages/fast-html/test/fixtures/attribute-map/main.ts index 286fd657a27..2b2af764339 100644 --- a/packages/fast-html/test/fixtures/attribute-map/main.ts +++ b/packages/fast-html/test/fixtures/attribute-map/main.ts @@ -1,4 +1,4 @@ -import { FASTElement } from "@microsoft/fast-element"; +import { attr, FASTElement } from "@microsoft/fast-element"; import { TemplateElement } from "@microsoft/fast-html"; class AttributeMapTestElement extends FASTElement { @@ -18,8 +18,20 @@ class AttributeMapTestElement extends FASTElement { AttributeMapTestElement.defineAsync({ name: "attribute-map-test-element" }); +class AttributeMapWithExistingAttrElement extends FASTElement { + @attr + foo: string = "original"; +} + +AttributeMapWithExistingAttrElement.defineAsync({ + name: "attribute-map-existing-attr-test-element", +}); + TemplateElement.options({ "attribute-map-test-element": { attributeMap: "all", }, + "attribute-map-existing-attr-test-element": { + attributeMap: "all", + }, }).define({ name: "f-template" }); From 6762d864a36abc7e6d35cc1262b1da16acf8585b Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:22:06 -0700 Subject: [PATCH 6/8] refactor(fast-html): remove attribute name normalization in AttributeMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attribute names are no longer converted from camelCase to dash-case. The binding key as written in the template is used as-is for both the attribute name and the property name (e.g. {{foo-bar}} → attribute foo-bar, property foo-bar). Because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated). Properties containing dashes must be accessed via bracket notation (element["foo-bar"]). - Remove camelCaseToDashCase method from AttributeMap - Update fixture template to use {{foo-bar}} instead of {{fooBar}} - Update all tests to use bracket notation for dash-case properties - Add AttributeMap documentation to DESIGN.md and README.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/fast-html/DESIGN.md | 21 ++++++++++++++ packages/fast-html/README.md | 29 +++++++++++++++++++ .../src/components/attribute-map.spec.ts | 16 +++++----- .../fast-html/src/components/attribute-map.ts | 24 ++++++--------- .../attribute-map/attribute-map.spec.ts | 14 ++++----- .../test/fixtures/attribute-map/index.html | 10 +++---- .../test/fixtures/attribute-map/main.ts | 4 +-- 7 files changed, 80 insertions(+), 38 deletions(-) diff --git a/packages/fast-html/DESIGN.md b/packages/fast-html/DESIGN.md index e14fdf780c4..5e482aebef0 100644 --- a/packages/fast-html/DESIGN.md +++ b/packages/fast-html/DESIGN.md @@ -89,6 +89,17 @@ An optional layer that uses the `Schema` to automatically: Enabled via `TemplateElement.options({ "my-element": { observerMap: "all" } })`. +### `AttributeMap` — automatic `@attr` definitions + +An optional layer that uses the `Schema` to automatically register `@attr`-style reactive properties for every **leaf binding** in the template — i.e. simple expressions like `{{foo}}` or `id="{{foo-bar}}"` that have no nested properties, no explicit type, and no child element references. + +- The **attribute name** and **property name** are both the binding key exactly as written in the template (e.g. `{{foo-bar}}` → attribute `foo-bar`, property `foo-bar`). No normalization is applied. +- Because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated). +- Properties already decorated with `@attr` or `@observable` are left untouched. +- `FASTElementDefinition.attributeLookup` and `propertyLookup` are patched so `attributeChangedCallback` correctly delegates to the new `AttributeDefinition`. + +Enabled via `TemplateElement.options({ "my-element": { attributeMap: "all" } })`. + ### Syntax constants (`syntax.ts`) All delimiters used by the parser are defined in a single `Syntax` interface and exported as named constants from `syntax.ts`. This makes the syntax pluggable and easy to audit. @@ -284,6 +295,16 @@ flowchart LR For a deep dive into the schema structure, context tracking, and proxy system see [SCHEMA_OBSERVER_MAP.md](./SCHEMA_OBSERVER_MAP.md). +### AttributeMap and leaf bindings + +When `attributeMap: "all"` is set, `AttributeMap.defineProperties()` is called after parsing. It iterates `Schema.getRootProperties()` and skips any property whose schema entry contains `properties`, `type`, or `anyOf` — keeping only plain leaf bindings. For each leaf: + +1. The schema key (e.g. `foo-bar`) is used as both the **attribute name** and the **JS property name** — no conversion is applied. +2. A new `AttributeDefinition` is registered via `Observable.defineProperty`. +3. `FASTElementDefinition.attributeLookup` and `propertyLookup` are updated so `attributeChangedCallback` can route attribute changes to the correct property. + +Because property names may contain dashes, they must be accessed via bracket notation (e.g. `element["foo-bar"]`). + --- ## Lifecycle diff --git a/packages/fast-html/README.md b/packages/fast-html/README.md index ba9d2826e26..a3839b73b4d 100644 --- a/packages/fast-html/README.md +++ b/packages/fast-html/README.md @@ -217,6 +217,35 @@ if (process.env.NODE_ENV === 'development') { } ``` +#### `attributeMap` + +When `attributeMap: "all"` 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="{{foo-bar}}"` that have no nested properties. + +The **attribute name** and **property name** are both the binding key exactly as written in the template — no normalization is applied. Because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated). Properties with dashes must be accessed via bracket notation (e.g. `element["foo-bar"]`). + +Properties already decorated with `@attr` or `@observable` on the class are left untouched. + +```typescript +TemplateElement.options({ + "my-element": { + attributeMap: "all", + }, +}); +``` + +With the template: + +```html + + + +``` + +This registers `greeting` (attribute `greeting`, property `greeting`) and `first-name` (attribute `first-name`, property `first-name`) as `@attr` properties on the element prototype, enabling `setAttribute("first-name", "Jane")` to trigger a template re-render automatically. + ### Syntax All bindings use a handlebars-like syntax. diff --git a/packages/fast-html/src/components/attribute-map.spec.ts b/packages/fast-html/src/components/attribute-map.spec.ts index 475a9ba45fa..cc95e1ed410 100644 --- a/packages/fast-html/src/components/attribute-map.spec.ts +++ b/packages/fast-html/src/components/attribute-map.spec.ts @@ -20,13 +20,13 @@ test.describe("AttributeMap", async () => { expect(hasFooAccessor).toBeTruthy(); }); - test("should define @attr for a camelCase property", async ({ page }) => { + test("should define @attr for a dash-case property", async ({ page }) => { const element = page.locator("attribute-map-test-element"); const hasFooBarAccessor = await element.evaluate(node => { const desc = Object.getOwnPropertyDescriptor( Object.getPrototypeOf(node), - "fooBar", + "foo-bar", ); return typeof desc?.get === "function"; }); @@ -34,14 +34,14 @@ test.describe("AttributeMap", async () => { expect(hasFooBarAccessor).toBeTruthy(); }); - test("should convert camelCase property name to dash-case attribute name", async ({ + test("should use binding key as-is for both attribute and property name", async ({ page, }) => { const element = page.locator("attribute-map-test-element"); - // Setting the dash-case attribute should update the camelCase property + // Setting the foo-bar attribute should update the foo-bar property (no conversion) await element.evaluate(node => node.setAttribute("foo-bar", "dash-case-test")); - const propValue = await element.evaluate(node => (node as any).fooBar); + const propValue = await element.evaluate(node => (node as any)["foo-bar"]); expect(propValue).toBe("dash-case-test"); }); @@ -115,14 +115,14 @@ test.describe("AttributeMap", async () => { expect(propValue).toBe("lookup-test"); }); - test("should update definition attributeLookup with dash-case for camelCase properties", async ({ + test("should update definition attributeLookup for dash-case properties", async ({ page, }) => { const element = page.locator("attribute-map-test-element"); - // setAttribute with dash-case triggers attributeChangedCallback for the camelCase property + // setAttribute with foo-bar triggers attributeChangedCallback for the foo-bar property await element.evaluate(node => node.setAttribute("foo-bar", "lookup-bar-test")); - const propValue = await element.evaluate(node => (node as any).fooBar); + const propValue = await element.evaluate(node => (node as any)["foo-bar"]); expect(propValue).toBe("lookup-bar-test"); }); diff --git a/packages/fast-html/src/components/attribute-map.ts b/packages/fast-html/src/components/attribute-map.ts index 01242c40c8a..f982143129a 100644 --- a/packages/fast-html/src/components/attribute-map.ts +++ b/packages/fast-html/src/components/attribute-map.ts @@ -7,9 +7,12 @@ import type { Schema } from "./schema.js"; * a generated JSON schema and defining them as @attr properties on a class prototype. * * A property is a candidate for @attr when its schema entry has no nested `properties`, - * no `type`, and no `anyOf` — i.e. it is a plain binding like {{foo}} or id="{{bar}}". + * no `type`, and no `anyOf` — i.e. it is a plain binding like {{foo}} or id="{{foo-bar}}". * - * camelCase property names are converted to dash-case attribute names (e.g. fooBar → foo-bar). + * Attribute names are **not** normalized — the binding key as written in the template + * is used as both the attribute name and the property name. Because HTML attributes are + * case-insensitive, binding keys should be lowercase (optionally dash-separated). + * For example, {{foo-bar}} results in attribute `foo-bar` and property `foo-bar`. */ export class AttributeMap { private schema: Schema; @@ -47,11 +50,10 @@ export class AttributeMap { continue; } - const attributeName = this.camelCaseToDashCase(propertyName); const attrDef = new AttributeDefinition( this.classPrototype.constructor, propertyName, - attributeName, + propertyName, ); Observable.defineProperty(this.classPrototype, attrDef); @@ -66,14 +68,14 @@ export class AttributeMap { ).observedAttributes; if ( Array.isArray(existingObservedAttrs) && - !existingObservedAttrs.includes(attributeName) + !existingObservedAttrs.includes(propertyName) ) { - existingObservedAttrs.push(attributeName); + existingObservedAttrs.push(propertyName); } if (this.definition) { (this.definition.attributeLookup as Record)[ - attributeName + propertyName ] = attrDef; (this.definition.propertyLookup as Record)[ propertyName @@ -81,12 +83,4 @@ export class AttributeMap { } } } - - /** - * Converts a camelCase string to dash-case. - * e.g. fooBar → foo-bar - */ - private camelCaseToDashCase(str: string): string { - return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); - } } diff --git a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts index 07b62a481b5..e05ad9ee4c0 100644 --- a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts +++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts @@ -15,21 +15,21 @@ test.describe("AttributeMap", async () => { const proto = Object.getPrototypeOf(node); const isAccessor = (name: string) => typeof Object.getOwnPropertyDescriptor(proto, name)?.get === "function"; - return { foo: isAccessor("foo"), fooBar: isAccessor("fooBar") }; + return { foo: isAccessor("foo"), "foo-bar": isAccessor("foo-bar") }; }); expect(accessors.foo).toBeTruthy(); - expect(accessors.fooBar).toBeTruthy(); + expect(accessors["foo-bar"]).toBeTruthy(); }); - test("should use dash-case attribute name for camelCase property", async ({ + test("should use binding key as-is for attribute and property name", async ({ page, }) => { const element = page.locator("attribute-map-test-element"); - // Setting foo-bar attribute should update the fooBar property + // Setting foo-bar attribute should update the foo-bar property (no conversion) await element.evaluate(node => node.setAttribute("foo-bar", "dash-test")); - const propValue = await element.evaluate(node => (node as any).fooBar); + const propValue = await element.evaluate(node => (node as any)["foo-bar"]); expect(propValue).toBe("dash-test"); }); @@ -90,13 +90,13 @@ test.describe("AttributeMap", async () => { expect(attrValue).toBe("reflected-value"); }); - test("should reflect fooBar property value back to foo-bar attribute", async ({ + test("should reflect foo-bar property value back to foo-bar attribute", async ({ page, }) => { const element = page.locator("attribute-map-test-element"); await element.evaluate(node => { - (node as any).fooBar = "bar-reflected"; + (node as any)["foo-bar"] = "bar-reflected"; }); await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); diff --git a/packages/fast-html/test/fixtures/attribute-map/index.html b/packages/fast-html/test/fixtures/attribute-map/index.html index 89eba373672..d3ec77dbaae 100644 --- a/packages/fast-html/test/fixtures/attribute-map/index.html +++ b/packages/fast-html/test/fixtures/attribute-map/index.html @@ -1,7 +1,7 @@ - + @@ -9,8 +9,8 @@