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..d10372a542e
--- /dev/null
+++ b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json
@@ -0,0 +1,7 @@
+{
+ "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",
+ "dependentChangeType": "none"
+}
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..5201d06e395 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",
+ },
+}).define({ name: "f-template" });
+```
+
+With the template:
+
+```html
+
+
+
{{greeting}}
+
{{first-name}}
+
+
+```
+
+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
new file mode 100644
index 00000000000..61ab6385653
--- /dev/null
+++ b/packages/fast-html/src/components/attribute-map.spec.ts
@@ -0,0 +1,143 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("AttributeMap", () => {
+ 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 hasFooAccessor = await element.evaluate(node => {
+ const desc = Object.getOwnPropertyDescriptor(
+ Object.getPrototypeOf(node),
+ "foo",
+ );
+ return typeof desc?.get === "function";
+ });
+
+ expect(hasFooAccessor).toBeTruthy();
+ });
+
+ 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),
+ "foo-bar",
+ );
+ return typeof desc?.get === "function";
+ });
+
+ expect(hasFooBarAccessor).toBeTruthy();
+ });
+
+ 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 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)["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");
+
+ // @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"),
+ };
+ });
+
+ 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 ({
+ 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,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ // 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(propValue).toBe("lookup-test");
+ });
+
+ test("should update definition attributeLookup for dash-case properties", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ // 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)["foo-bar"]);
+
+ 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/src/components/attribute-map.ts b/packages/fast-html/src/components/attribute-map.ts
new file mode 100644
index 00000000000..40a6a5fa3a4
--- /dev/null
+++ b/packages/fast-html/src/components/attribute-map.ts
@@ -0,0 +1,102 @@
+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="{{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;
+ 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 existingAccessorNames = new Set(
+ Observable.getAccessors(this.classPrototype).map(a => a.name),
+ );
+
+ 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 (existingAccessorNames.has(propertyName)) {
+ continue;
+ }
+
+ const attrDef = new AttributeDefinition(
+ this.classPrototype.constructor,
+ propertyName,
+ propertyName,
+ );
+
+ Observable.defineProperty(this.classPrototype, attrDef);
+
+ // Mutate the existing observedAttributes array on the class.
+ // FAST's FASTElementDefinition sets observedAttributes via
+ // Reflect.defineProperty with a concrete array value (non-configurable,
+ // non-writable), so the descriptor cannot be replaced. However, the
+ // array itself is mutable, and pushing into it works because
+ // registry.define() — which causes the browser to snapshot
+ // observedAttributes — is called AFTER this method runs.
+ const existingObservedAttrs: string[] | undefined = (
+ this.classPrototype.constructor as any
+ ).observedAttributes;
+ if (
+ Array.isArray(existingObservedAttrs) &&
+ !existingObservedAttrs.includes(propertyName)
+ ) {
+ existingObservedAttrs.push(propertyName);
+ }
+
+ if (this.definition) {
+ (this.definition.attributeLookup as Record)[
+ propertyName
+ ] = attrDef;
+ (this.definition.propertyLookup as Record)[
+ propertyName
+ ] = attrDef;
+
+ const attrs = (this.definition as any).attributes;
+ if (
+ Array.isArray(attrs) &&
+ !attrs.some(
+ (existing: AttributeDefinition) =>
+ existing.name === attrDef.name ||
+ existing.attribute === attrDef.attribute,
+ )
+ ) {
+ attrs.push(attrDef);
+ }
+ }
+ }
+ }
+}
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..5d695401646
--- /dev/null
+++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts
@@ -0,0 +1,121 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("AttributeMap", () => {
+ 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 => {
+ const proto = Object.getPrototypeOf(node);
+ const isAccessor = (name: string) =>
+ typeof Object.getOwnPropertyDescriptor(proto, name)?.get === "function";
+ return { foo: isAccessor("foo"), "foo-bar": isAccessor("foo-bar") };
+ });
+
+ expect(accessors.foo).toBeTruthy();
+ expect(accessors["foo-bar"]).toBeTruthy();
+ });
+
+ 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 foo-bar property (no conversion)
+ await element.evaluate(node => node.setAttribute("foo-bar", "dash-test"));
+ const propValue = await element.evaluate(node => (node as any)["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");
+
+ // 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(propValue).toBe("same-name-test");
+ });
+
+ 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 element.evaluate(node => node.setAttribute("foo-bar", "world-via-attr"));
+
+ await expect(page.locator(".foo-bar-value")).toHaveText("world-via-attr");
+ });
+
+ test("should update both properties when set via setAttribute", async ({ page }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => {
+ node.setAttribute("foo", "multi-foo");
+ node.setAttribute("foo-bar", "multi-bar");
+ });
+
+ await expect(page.locator(".foo-value")).toHaveText("multi-foo");
+ await expect(page.locator(".foo-bar-value")).toHaveText("multi-bar");
+ });
+
+ 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";
+ });
+
+ // 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-value");
+ });
+
+ 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)["foo-bar"] = "bar-reflected";
+ });
+
+ await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));
+
+ 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/entry.html b/packages/fast-html/test/fixtures/attribute-map/entry.html
new file mode 100644
index 00000000000..7fdcce650ae
--- /dev/null
+++ b/packages/fast-html/test/fixtures/attribute-map/entry.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
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..aec0d892b22
--- /dev/null
+++ b/packages/fast-html/test/fixtures/attribute-map/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+