Skip to content

Commit 27f65b9

Browse files
authored
feat(fast-html): add AttributeMap class for automatic @attr definitions (#7354)
# Pull Request ## 📖 Description Adds an `AttributeMap` class to `@microsoft/fast-html` that automatically defines `@attr` properties on a custom element's class prototype based on the JSON schema generated by `TemplateElement`. When `attributeMap: "all"` is configured for an element via `TemplateElement.options()`, `AttributeMap` inspects the schema after template processing and creates reactive properties for all **leaf bindings** — simple template expressions like `{{foo}}` or `id="{{foo-bar}}"` that have no nested properties, no array type, and no child element references. Key behaviours: - **No normalization**: 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`) - **Lowercase required**: because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated) - **Bracket notation**: properties containing dashes must be accessed via bracket notation (e.g. `element["foo-bar"]`) - **Skips non-leaf properties**: paths like `{{user.name}}` result in `user` having sub-`properties` in the schema and are excluded - **Skips existing accessors**: properties already decorated with `@attr` or `@observable` are left untouched - **Updates `FASTElementDefinition`**: `attributeLookup` and `propertyLookup` are patched so `attributeChangedCallback` correctly delegates to the new `AttributeDefinition` - **No decorator syntax required**: uses `Observable.defineProperty` with an `AttributeDefinition` instance directly ### Usage ```ts TemplateElement.options({ "my-element": { attributeMap: "all", }, }); ``` ```html <f-template name="my-element"> <template> <p>{{greeting}}</p> <p>{{first-name}}</p> </template> </f-template> ``` This registers `greeting` (attribute `greeting`, property `greeting`) and `first-name` (attribute `first-name`, property `first-name`) as `@attr` properties, enabling `setAttribute("first-name", "Jane")` to trigger a template re-render automatically. This mirrors the existing `ObserverMap` integration pattern. ## 📑 Test Plan Tests were added across two spec files: - `packages/fast-html/src/components/attribute-map.spec.ts` — verifies accessor registration, no-normalization identity mapping, event handler exclusion, and `FASTElementDefinition` lookup updates - `packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts` — end-to-end tests verifying template re-rendering when properties are set via JavaScript or `setAttribute` The attribute-map fixture follows the standard fixture pattern (`entry.html`, `templates.html`, `state.json`) and its `index.html` is generated by `npm run build:fixtures` with SSR-rendered shadow DOM. All existing tests continue to pass. ## ✅ Checklist ### General - [x] I have included a change request file using `$ npm run change` - [x] I have added tests for my changes. - [x] I have tested my changes. - [x] I have updated the project documentation to reflect my changes. - [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/main/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/main/CODE_OF_CONDUCT.md#our-standards) for this project. ## ⏭ Next Steps - Investigate whether `observedAttributes` can be updated after element registration to fully support the DOM attribute → property direction for elements registered before the template is processed
1 parent f3cb104 commit 27f65b9

13 files changed

Lines changed: 560 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "Add AttributeMap class for automatic @attr definitions on leaf template bindings",
4+
"packageName": "@microsoft/fast-html",
5+
"email": "7559015+janechu@users.noreply.github.com",
6+
"dependentChangeType": "none"
7+
}

packages/fast-html/DESIGN.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ An optional layer that uses the `Schema` to automatically:
8989

9090
Enabled via `TemplateElement.options({ "my-element": { observerMap: "all" } })`.
9191

92+
### `AttributeMap` — automatic `@attr` definitions
93+
94+
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.
95+
96+
- 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.
97+
- Because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated).
98+
- Properties already decorated with `@attr` or `@observable` are left untouched.
99+
- `FASTElementDefinition.attributeLookup` and `propertyLookup` are patched so `attributeChangedCallback` correctly delegates to the new `AttributeDefinition`.
100+
101+
Enabled via `TemplateElement.options({ "my-element": { attributeMap: "all" } })`.
102+
92103
### Syntax constants (`syntax.ts`)
93104

94105
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
284295

285296
For a deep dive into the schema structure, context tracking, and proxy system see [SCHEMA_OBSERVER_MAP.md](./SCHEMA_OBSERVER_MAP.md).
286297

298+
### AttributeMap and leaf bindings
299+
300+
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:
301+
302+
1. The schema key (e.g. `foo-bar`) is used as both the **attribute name** and the **JS property name** — no conversion is applied.
303+
2. A new `AttributeDefinition` is registered via `Observable.defineProperty`.
304+
3. `FASTElementDefinition.attributeLookup` and `propertyLookup` are updated so `attributeChangedCallback` can route attribute changes to the correct property.
305+
306+
Because property names may contain dashes, they must be accessed via bracket notation (e.g. `element["foo-bar"]`).
307+
287308
---
288309

289310
## Lifecycle

packages/fast-html/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,35 @@ if (process.env.NODE_ENV === 'development') {
217217
}
218218
```
219219

220+
#### `attributeMap`
221+
222+
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.
223+
224+
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"]`).
225+
226+
Properties already decorated with `@attr` or `@observable` on the class are left untouched.
227+
228+
```typescript
229+
TemplateElement.options({
230+
"my-element": {
231+
attributeMap: "all",
232+
},
233+
}).define({ name: "f-template" });
234+
```
235+
236+
With the template:
237+
238+
```html
239+
<f-template name="my-element">
240+
<template>
241+
<p>{{greeting}}</p>
242+
<p>{{first-name}}</p>
243+
</template>
244+
</f-template>
245+
```
246+
247+
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.
248+
220249
### Syntax
221250

222251
All bindings use a handlebars-like syntax.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test.describe("AttributeMap", () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto("/fixtures/attribute-map/");
6+
await page.waitForSelector("attribute-map-test-element");
7+
});
8+
9+
test("should define @attr for a simple leaf property", async ({ page }) => {
10+
const element = page.locator("attribute-map-test-element");
11+
12+
const hasFooAccessor = await element.evaluate(node => {
13+
const desc = Object.getOwnPropertyDescriptor(
14+
Object.getPrototypeOf(node),
15+
"foo",
16+
);
17+
return typeof desc?.get === "function";
18+
});
19+
20+
expect(hasFooAccessor).toBeTruthy();
21+
});
22+
23+
test("should define @attr for a dash-case property", async ({ page }) => {
24+
const element = page.locator("attribute-map-test-element");
25+
26+
const hasFooBarAccessor = await element.evaluate(node => {
27+
const desc = Object.getOwnPropertyDescriptor(
28+
Object.getPrototypeOf(node),
29+
"foo-bar",
30+
);
31+
return typeof desc?.get === "function";
32+
});
33+
34+
expect(hasFooBarAccessor).toBeTruthy();
35+
});
36+
37+
test("should use binding key as-is for both attribute and property name", async ({
38+
page,
39+
}) => {
40+
const element = page.locator("attribute-map-test-element");
41+
42+
// Setting the foo-bar attribute should update the foo-bar property (no conversion)
43+
await element.evaluate(node => node.setAttribute("foo-bar", "dash-case-test"));
44+
const propValue = await element.evaluate(node => (node as any)["foo-bar"]);
45+
46+
expect(propValue).toBe("dash-case-test");
47+
});
48+
49+
test("should not define @attr for event handler methods", async ({ page }) => {
50+
const element = page.locator("attribute-map-test-element");
51+
52+
// @click="{setFoo()}" etc. produce "event" type bindings — excluded from schema.
53+
// Regular methods have a value descriptor, not a getter/setter.
54+
const results = await element.evaluate(node => {
55+
const proto = Object.getPrototypeOf(node);
56+
const isAccessor = (name: string) => {
57+
const desc = Object.getOwnPropertyDescriptor(proto, name);
58+
return typeof desc?.get === "function";
59+
};
60+
return {
61+
setFoo: isAccessor("setFoo"),
62+
setFooBar: isAccessor("setFooBar"),
63+
setMultiple: isAccessor("setMultiple"),
64+
};
65+
});
66+
67+
expect(results.setFoo).toBe(false);
68+
expect(results.setFooBar).toBe(false);
69+
expect(results.setMultiple).toBe(false);
70+
});
71+
72+
test("should update template when attribute is set via setAttribute", async ({
73+
page,
74+
}) => {
75+
const element = page.locator("attribute-map-test-element");
76+
77+
await element.evaluate(node => node.setAttribute("foo", "attr-value"));
78+
79+
await expect(page.locator(".foo-value")).toHaveText("attr-value");
80+
});
81+
82+
test("should update template when dash-case attribute is set via setAttribute", async ({
83+
page,
84+
}) => {
85+
const element = page.locator("attribute-map-test-element");
86+
87+
await element.evaluate(node => node.setAttribute("foo-bar", "bar-attr-value"));
88+
89+
await expect(page.locator(".foo-bar-value")).toHaveText("bar-attr-value");
90+
});
91+
92+
test("should reflect property value back to attribute", async ({ page }) => {
93+
const element = page.locator("attribute-map-test-element");
94+
95+
await element.evaluate(node => {
96+
(node as any).foo = "reflected";
97+
});
98+
99+
// FAST reflects attributes asynchronously via Updates.enqueue
100+
await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));
101+
102+
const attrValue = await element.evaluate(node => node.getAttribute("foo"));
103+
expect(attrValue).toBe("reflected");
104+
});
105+
106+
test("should update definition attributeLookup for simple properties", async ({
107+
page,
108+
}) => {
109+
const element = page.locator("attribute-map-test-element");
110+
111+
// setAttribute triggers attributeChangedCallback via attributeLookup
112+
await element.evaluate(node => node.setAttribute("foo", "lookup-test"));
113+
const propValue = await element.evaluate(node => (node as any).foo);
114+
115+
expect(propValue).toBe("lookup-test");
116+
});
117+
118+
test("should update definition attributeLookup for dash-case properties", async ({
119+
page,
120+
}) => {
121+
const element = page.locator("attribute-map-test-element");
122+
123+
// setAttribute with foo-bar triggers attributeChangedCallback for the foo-bar property
124+
await element.evaluate(node => node.setAttribute("foo-bar", "lookup-bar-test"));
125+
const propValue = await element.evaluate(node => (node as any)["foo-bar"]);
126+
127+
expect(propValue).toBe("lookup-bar-test");
128+
});
129+
130+
test("should not overwrite an existing @attr accessor", async ({ page }) => {
131+
await page.waitForSelector("attribute-map-existing-attr-test-element");
132+
const element = page.locator("attribute-map-existing-attr-test-element");
133+
134+
// The @attr default value must survive AttributeMap processing
135+
const defaultValue = await element.evaluate(node => (node as any).foo);
136+
expect(defaultValue).toBe("original");
137+
138+
// setAttribute must still work via the original @attr definition
139+
await element.evaluate(node => node.setAttribute("foo", "updated"));
140+
const updatedValue = await element.evaluate(node => (node as any).foo);
141+
expect(updatedValue).toBe("updated");
142+
});
143+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { FASTElementDefinition } from "@microsoft/fast-element";
2+
import { AttributeDefinition, Observable } from "@microsoft/fast-element";
3+
import type { Schema } from "./schema.js";
4+
5+
/**
6+
* AttributeMap provides functionality for detecting simple (leaf) properties in
7+
* a generated JSON schema and defining them as @attr properties on a class prototype.
8+
*
9+
* A property is a candidate for @attr when its schema entry has no nested `properties`,
10+
* no `type`, and no `anyOf` — i.e. it is a plain binding like {{foo}} or id="{{foo-bar}}".
11+
*
12+
* Attribute names are **not** normalized — the binding key as written in the template
13+
* is used as both the attribute name and the property name. Because HTML attributes are
14+
* case-insensitive, binding keys should be lowercase (optionally dash-separated).
15+
* For example, {{foo-bar}} results in attribute `foo-bar` and property `foo-bar`.
16+
*/
17+
export class AttributeMap {
18+
private schema: Schema;
19+
private classPrototype: any;
20+
private definition: FASTElementDefinition | undefined;
21+
22+
constructor(classPrototype: any, schema: Schema, definition?: FASTElementDefinition) {
23+
this.classPrototype = classPrototype;
24+
this.schema = schema;
25+
this.definition = definition;
26+
}
27+
28+
public defineProperties(): void {
29+
const propertyNames = this.schema.getRootProperties();
30+
const existingAccessorNames = new Set(
31+
Observable.getAccessors(this.classPrototype).map(a => a.name),
32+
);
33+
34+
for (const propertyName of propertyNames) {
35+
const propertySchema = this.schema.getSchema(propertyName);
36+
37+
// Only create @attr for leaf properties:
38+
// - no nested properties (not a dot-syntax path)
39+
// - no type (not an explicitly typed value like an array)
40+
// - no anyOf (not a child element reference)
41+
if (
42+
!propertySchema ||
43+
propertySchema.properties ||
44+
propertySchema.type ||
45+
propertySchema.anyOf
46+
) {
47+
continue;
48+
}
49+
50+
// Skip if the property already has an accessor (from @attr or @observable)
51+
if (existingAccessorNames.has(propertyName)) {
52+
continue;
53+
}
54+
55+
const attrDef = new AttributeDefinition(
56+
this.classPrototype.constructor,
57+
propertyName,
58+
propertyName,
59+
);
60+
61+
Observable.defineProperty(this.classPrototype, attrDef);
62+
63+
// Mutate the existing observedAttributes array on the class.
64+
// FAST's FASTElementDefinition sets observedAttributes via
65+
// Reflect.defineProperty with a concrete array value (non-configurable,
66+
// non-writable), so the descriptor cannot be replaced. However, the
67+
// array itself is mutable, and pushing into it works because
68+
// registry.define() — which causes the browser to snapshot
69+
// observedAttributes — is called AFTER this method runs.
70+
const existingObservedAttrs: string[] | undefined = (
71+
this.classPrototype.constructor as any
72+
).observedAttributes;
73+
if (
74+
Array.isArray(existingObservedAttrs) &&
75+
!existingObservedAttrs.includes(propertyName)
76+
) {
77+
existingObservedAttrs.push(propertyName);
78+
}
79+
80+
if (this.definition) {
81+
(this.definition.attributeLookup as Record<string, AttributeDefinition>)[
82+
propertyName
83+
] = attrDef;
84+
(this.definition.propertyLookup as Record<string, AttributeDefinition>)[
85+
propertyName
86+
] = attrDef;
87+
88+
const attrs = (this.definition as any).attributes;
89+
if (
90+
Array.isArray(attrs) &&
91+
!attrs.some(
92+
(existing: AttributeDefinition) =>
93+
existing.name === attrDef.name ||
94+
existing.attribute === attrDef.attribute,
95+
)
96+
) {
97+
attrs.push(attrDef);
98+
}
99+
}
100+
}
101+
}
102+
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
export { AttributeMap } from "./attribute-map.js";
12
export { RenderableFASTElement } from "./element.js";
23
export { ObserverMap } from "./observer-map.js";
34
export {
4-
ObserverMapOption,
5-
TemplateElement,
5+
AttributeMapOption,
66
type ElementOptions,
77
type ElementOptionsDictionary,
88
type HydrationLifecycleCallbacks,
9+
ObserverMapOption,
10+
TemplateElement,
911
} from "./template.js";

0 commit comments

Comments
 (0)