Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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"
}
21 changes: 21 additions & 0 deletions packages/fast-html/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/fast-html/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
janechu marked this conversation as resolved.
TemplateElement.options({
"my-element": {
attributeMap: "all",
},
}).define({ name: "f-template" });
```

With the template:

```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 on the element prototype, enabling `setAttribute("first-name", "Jane")` to trigger a template re-render automatically.

### Syntax

All bindings use a handlebars-like syntax.
Expand Down
143 changes: 143 additions & 0 deletions packages/fast-html/src/components/attribute-map.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
102 changes: 102 additions & 0 deletions packages/fast-html/src/components/attribute-map.ts
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
janechu marked this conversation as resolved.
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<string, AttributeDefinition>)[
propertyName
] = attrDef;
(this.definition.propertyLookup as Record<string, AttributeDefinition>)[
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);
}
}
}
}
}
6 changes: 4 additions & 2 deletions packages/fast-html/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading