Skip to content

Commit 23ca1fa

Browse files
authored
refactor: modularize Schema, ObserverMap, and AttributeMap in fast-html (#7478)
# Pull Request ## 📖 Description Modularize Schema, ObserverMap, and AttributeMap in `@microsoft/fast-html` to reduce tight coupling with TemplateElement. This is a **breaking change**. **Key changes:** - Moved `ObserverMapConfig`, `ObserverMapPathEntry`, `ObserverMapPathNode`, and `ObserverMapOption` from `template.ts` to `observer-map.ts` - Moved `AttributeMapConfig` and `AttributeMapOption` from `template.ts` to `attribute-map.ts` - Replaced `Schema.jsonSchemaMap` (static property) with an instance-level `schemaMap` and a module-level `schemaRegistry` export for cross-element `$ref` resolution - Added `Schema`, `AttributeMap`, `schemaRegistry`, `JSONSchema`, and `CachedPathMap` to the public API exports - Reversed the dependency direction: `template.ts` now imports from `observer-map.ts` and `attribute-map.ts`, instead of the other way around ## 👩‍💻 Reviewer Notes The module dependency direction is now: ``` template.ts ──imports──▶ observer-map.ts (config types) template.ts ──imports──▶ attribute-map.ts (config types) template.ts ──imports──▶ schema.ts observer-map.ts ──imports──▶ schema.ts (types only) attribute-map.ts ──imports──▶ schema.ts (types only) utilities.ts ──imports──▶ schema.ts (schemaRegistry for cross-element $ref) ``` ## 📑 Test Plan All 873 existing tests pass across Chromium, Firefox, and WebKit. No new tests were needed as the refactor preserves all existing behavior — only the internal module boundaries and export surface changed. ## ✅ Checklist ### General - [x] I have included a change request file using `$ npm run change` - [ ] 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.
1 parent 3896eb7 commit 23ca1fa

13 files changed

Lines changed: 332 additions & 196 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "none",
3+
"comment": "refactor: modularize Schema, ObserverMap, and AttributeMap in fast-html",
4+
"packageName": "@microsoft/fast-html",
5+
"email": "7559015+janechu@users.noreply.github.com",
6+
"dependentChangeType": "none"
7+
}

packages/fast-html/DESIGN.md

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ Built during template parsing, one `Schema` instance per `<f-template>`. It reco
7373

7474
- Describes the shape of each root property referenced in the template.
7575
- Tracks repeat context chains (parent/child array relationships).
76-
- Is stored statically per element name so it can be shared across instances.
76+
- Uses an instance-level `schemaMap` for its own property schemas.
77+
- Registers itself in the module-level `schemaRegistry` (keyed by custom element name) for cross-element `$ref` resolution.
7778

7879
### `ObserverMap` — automatic observable setup
7980

@@ -164,8 +165,9 @@ packages/fast-html/
164165
│ ├── element.ts # Element utilities
165166
│ ├── template.ts # TemplateElement (<f-template>), lifecycle orchestration, options
166167
│ ├── template-parser.ts # TemplateParser — converts declarative HTML to ViewTemplate strings/values
167-
│ ├── schema.ts # Schema class — JSON schema builder
168-
│ ├── observer-map.ts # ObserverMap class — auto observable/proxy setup
168+
│ ├── schema.ts # Schema class — JSON schema builder + schemaRegistry
169+
│ ├── observer-map.ts # ObserverMap class + config types (ObserverMapConfig, ObserverMapPathEntry, etc.)
170+
│ ├── attribute-map.ts # AttributeMap class + config types (AttributeMapConfig, AttributeMapOption)
169171
│ ├── utilities.ts # Parsing engine, binding resolvers, proxy system
170172
│ └── syntax.ts # Syntax delimiter constants
171173
├── rules/ # ast-grep YAML rules for converting html`` → declarative HTML
@@ -176,6 +178,19 @@ packages/fast-html/
176178
└── fixtures/ # One directory per feature, each with spec + index.html + main.ts
177179
```
178180

181+
### Module dependency direction
182+
183+
Each module owns its configuration types and can be used independently:
184+
185+
```
186+
template.ts ──imports──▶ observer-map.ts (ObserverMapConfig, ObserverMapOption)
187+
template.ts ──imports──▶ attribute-map.ts (AttributeMapConfig, AttributeMapOption)
188+
template.ts ──imports──▶ schema.ts (Schema)
189+
observer-map.ts ──imports──▶ schema.ts (Schema types)
190+
attribute-map.ts ──imports──▶ schema.ts (Schema types)
191+
utilities.ts ──imports──▶ schema.ts (schemaRegistry for cross-element $ref resolution)
192+
```
193+
179194
---
180195

181196
## Exports and Public API
@@ -184,28 +199,40 @@ packages/fast-html/
184199
import {
185200
TemplateElement,
186201
TemplateParser,
202+
Schema,
203+
schemaRegistry,
187204
ObserverMap,
205+
AttributeMap,
188206
type ObserverMapConfig,
189207
type ObserverMapPathEntry,
190208
type ObserverMapPathNode,
209+
type AttributeMapConfig,
210+
type JSONSchema,
211+
type CachedPathMap,
191212
} from "@microsoft/fast-html";
192213
```
193214

194-
Three primary exports are intended for application code:
215+
Primary exports intended for application code:
195216

196217
| Export | Purpose |
197218
|---|---|
198219
| `TemplateElement` | Define the `<f-template>` element; configure callbacks and per-element options. |
199220
| `TemplateParser` | Standalone parser that converts declarative HTML into `ViewTemplate` strings/values. Can be used independently of `<f-template>` for programmatic template compilation. |
200-
| `ObserverMap` | Advanced: access the observer-map class directly if building tooling. |
221+
| `Schema` | JSON schema builder that records binding paths discovered during template parsing. Each instance owns its own schema map and registers itself in the `schemaRegistry` for cross-element `$ref` resolution. |
222+
| `schemaRegistry` | Module-level `Map<string, Map<string, JSONSchema>>` that indexes schemas by custom element name. Used for cross-element lookups (e.g. nested component `$ref` resolution). |
223+
| `ObserverMap` | Automatic observable setup using the schema; defines observable properties and installs proxy-based deep change tracking. Configuration types (`ObserverMapConfig`, `ObserverMapPathEntry`, `ObserverMapPathNode`) are co-located in this module. |
224+
| `AttributeMap` | Automatic `@attr` property registration for leaf bindings in the template. Configuration type (`AttributeMapConfig`) is co-located in this module. |
201225

202-
Additionally, the following types are exported for use in `observerMap` configuration:
226+
Additionally, the following types are exported:
203227

204-
| Type | Purpose |
205-
|---|---|
206-
| `ObserverMapConfig` | Configuration object for the `observerMap` option; accepts optional `properties` key. |
207-
| `ObserverMapPathEntry` | `boolean \| ObserverMapPathNode` — a node in the observation path tree. |
208-
| `ObserverMapPathNode` | Object node with optional `$observe` and child property overrides. |
228+
| Type | Source Module | Purpose |
229+
|---|---|---|
230+
| `ObserverMapConfig` | `observer-map.ts` | Configuration object for the `observerMap` option; accepts optional `properties` key. |
231+
| `ObserverMapPathEntry` | `observer-map.ts` | `boolean \| ObserverMapPathNode` — a node in the observation path tree. |
232+
| `ObserverMapPathNode` | `observer-map.ts` | Object node with optional `$observe` and child property overrides. |
233+
| `AttributeMapConfig` | `attribute-map.ts` | Configuration object for the `attributeMap` option; accepts `attribute-name-strategy`. |
234+
| `JSONSchema` | `schema.ts` | JSON Schema interface used by `Schema` for property structure. |
235+
| `CachedPathMap` | `schema.ts` | `Map<string, Map<string, JSONSchema>>` — the shape of the schema registry. |
209236

210237
---
211238

@@ -377,13 +404,14 @@ innerHTML token
377404

378405
## Schema and Observer Map
379406

380-
The `Schema` class accumulates all binding paths discovered during parsing into a static JSON Schema map indexed by `customElementName → rootPropertyName → JSONSchema`.
407+
The `Schema` class accumulates all binding paths discovered during parsing into an instance-level JSON Schema map (`schemaMap`) indexed by `rootPropertyName → JSONSchema`. Each `Schema` instance also registers itself in the module-level `schemaRegistry` (keyed by custom element name) for cross-element `$ref` resolution.
381408

382409
```mermaid
383410
flowchart LR
384411
A["Template binding\nuser.details.age"] --> B[bindingResolver]
385412
B --> C[schema.addPath\ntype:'access'\npath:'user.details.age'\nrootProperty:'user']
386-
C --> D["Schema.jsonSchemaMap\n{'my-el' => {'user' => JSONSchema}}"]
413+
C --> D["schema.schemaMap\n{'user' => JSONSchema}"]
414+
C --> D2["schemaRegistry\n{'my-el' => schemaMap}"]
387415
D --> E[ObserverMap.defineProperties]
388416
E --> E1["applyConfigToSchema\nstamps $observe: false on excluded schema nodes"]
389417
E1 --> F[Observable.defineProperty on prototype\nfor 'user']

packages/fast-html/MIGRATION.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,39 @@
8484
});
8585
}
8686
```
87+
88+
## Modularize Schema, ObserverMap, and AttributeMap
89+
90+
### Import changes
91+
92+
Configuration types have moved from `template.ts` to their owning modules. If you import types directly from internal paths, update your imports:
93+
94+
| Before | After |
95+
|---|---|
96+
| `import type { ObserverMapConfig } from "./template.js"` | `import type { ObserverMapConfig } from "./observer-map.js"` |
97+
| `import type { AttributeMapConfig } from "./template.js"` | `import type { AttributeMapConfig } from "./attribute-map.js"` |
98+
99+
Imports from the package barrel (`@microsoft/fast-html`) are unaffected.
100+
101+
### Schema changes
102+
103+
`Schema.jsonSchemaMap` (static property) has been replaced by:
104+
- An instance-level `schemaMap` on each `Schema` instance (private)
105+
- A module-level `schemaRegistry` export for cross-element lookups
106+
107+
| Before | After |
108+
|---|---|
109+
| `Schema.jsonSchemaMap.get('my-element')` | `import { schemaRegistry } from "@microsoft/fast-html"; schemaRegistry.get('my-element')` |
110+
111+
### New public exports
112+
113+
The following are now part of the public API:
114+
115+
| Export | Purpose |
116+
|---|---|
117+
| `Schema` | JSON schema builder class |
118+
| `schemaRegistry` | Module-level registry for cross-element schema lookups |
119+
| `AttributeMap` | Automatic `@attr` property registration |
120+
| `AttributeMapOption` | Constant for the `"all"` option value |
121+
| `JSONSchema` | JSON Schema type interface |
122+
| `CachedPathMap` | Schema registry map type |

packages/fast-html/SCHEMA_OBSERVER_MAP.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ The `Schema` class is responsible for building JSON Schema definitions that map
5858
```typescript
5959
constructor(name: string)
6060
```
61-
Creates a new schema instance for a specific custom element name and initializes an entry in the static `jsonSchemaMap`.
61+
Creates a new schema instance for a specific custom element name and initializes an instance-level `schemaMap`. The instance also registers itself in the module-level `schemaRegistry` for cross-element `$ref` resolution.
6262

6363
#### addPath
6464
```typescript
@@ -131,7 +131,7 @@ Creates an observer map instance that will configure the provided class prototyp
131131
public defineProperties(): void
132132
```
133133
The main method that:
134-
1. Iterates through all root properties defined in the schema (each custom element in the jsonSchemaMap contains multiple schemas, one for each root property)
134+
1. Iterates through all root properties defined in the schema (each schema instance contains multiple root property schemas)
135135
2. Defines observable properties using FAST Element's `Observable.defineProperty` (an alternative to the `@observable` decorator syntax used in custom element classes)
136136
3. Sets up property change handlers that create proxies for nested objects
137137

@@ -375,16 +375,18 @@ This creates nested context definitions where the `post` context understands its
375375

376376
## Technical Details
377377

378-
### Static Schema Map
378+
### Schema Registry
379379

380-
The `Schema` class maintains a static `CachedPathMap`:
380+
The `Schema` module exports a module-level `schemaRegistry`:
381381
```typescript
382-
public static jsonSchemaMap: CachedPathMap = new Map();
382+
export const schemaRegistry: CachedPathMap = new Map();
383383
```
384384

385-
This map structure is: `Map<customElementName, Map<rootPropertyName, JSONSchema>>`
385+
Each `Schema` instance owns an instance-level `schemaMap: Map<string, JSONSchema>` and registers itself in `schemaRegistry` on construction.
386386

387-
**Rationale for Static Property**: The static nature of this map is essential for handling nested components inside f-templates. When an object or array is passed to another custom element within an f-template, that nested component needs to observe the entire root property's structure based on the binding paths within that nested component. The static map allows all components to access and contribute to the same schema definitions, ensuring consistent observation behavior across component boundaries.
387+
The registry structure is: `Map<customElementName, Map<rootPropertyName, JSONSchema>>`
388+
389+
**Rationale for Module-level Registry**: The registry allows cross-element `$ref` resolution for nested components inside f-templates. When an object or array is passed to another custom element within an f-template, that nested component needs to observe the entire root property's structure based on the binding paths within that nested component. The registry allows all components to access and contribute to the same schema definitions, ensuring consistent observation behavior across component boundaries.
388390

389391
### Context Tracking
390392

@@ -408,15 +410,13 @@ The schema system tracks binding contexts using special metadata:
408410

409411
### Schema Inspection
410412

411-
You can inspect generated schemas from any f-template custom element in the browser using the console:
413+
You can inspect generated schemas using the module-level `schemaRegistry` import:
412414

413415
```typescript
414-
// First, select an f-template element in the browser's developer tools
415-
// Then access the static jsonSchemaMap from the console:
416-
$0.schema.__proto__.constructor.jsonSchemaMap
416+
import { schemaRegistry } from "@microsoft/fast-html";
417417
418-
// To get a specific schema for an element and property:
419-
const elementSchemas = $0.schema.__proto__.constructor.jsonSchemaMap.get('my-element');
418+
// Get all schemas for an element:
419+
const elementSchemas = schemaRegistry.get('my-element');
420420
const userSchema = elementSchemas?.get('users');
421421
console.log(JSON.stringify(userSchema, null, 2));
422422
```

packages/fast-html/docs/api-report.api.md

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,75 @@
55
```ts
66

77
import { FASTElement } from '@microsoft/fast-element';
8+
import type { FASTElementDefinition } from '@microsoft/fast-element';
89
import { TemplateLifecycleCallbacks } from '@microsoft/fast-element';
910
import { ViewTemplate } from '@microsoft/fast-element';
1011

12+
// @public
13+
export class AttributeMap {
14+
constructor(classPrototype: any, schema: Schema, definition?: FASTElementDefinition, config?: AttributeMapConfig);
15+
// (undocumented)
16+
defineProperties(): void;
17+
}
18+
1119
// @public
1220
export interface AttributeMapConfig {
1321
"attribute-name-strategy"?: "none" | "camelCase";
1422
}
1523

24+
// @public
25+
export const AttributeMapOption: {
26+
readonly all: "all";
27+
};
28+
29+
// @public
30+
export type AttributeMapOption = (typeof AttributeMapOption)[keyof typeof AttributeMapOption] | AttributeMapConfig;
31+
32+
// @public (undocumented)
33+
export type CachedPathMap = Map<string, Map<string, JSONSchema>>;
34+
35+
// @public
36+
export interface ElementOptions {
37+
// (undocumented)
38+
attributeMap?: AttributeMapOption;
39+
// Warning: (ae-forgotten-export) The symbol "ObserverMapOption" needs to be exported by the entry point index.d.ts
40+
//
41+
// (undocumented)
42+
observerMap?: ObserverMapOption;
43+
}
44+
45+
// @public
46+
export interface ElementOptionsDictionary<ElementOptionsType = ElementOptions> {
47+
// (undocumented)
48+
[key: string]: ElementOptionsType;
49+
}
50+
51+
// @public
52+
export interface HydrationLifecycleCallbacks extends TemplateLifecycleCallbacks {
53+
elementDidHydrate?(source: HTMLElement): void;
54+
elementDidRegister?(name: string): void;
55+
elementWillHydrate?(source: HTMLElement): void;
56+
hydrationComplete?(): void;
57+
hydrationStarted?(): void;
58+
templateWillUpdate?(name: string): void;
59+
}
60+
61+
// Warning: (ae-forgotten-export) The symbol "JSONSchemaCommon" needs to be exported by the entry point index.d.ts
62+
//
63+
// @public (undocumented)
64+
export interface JSONSchema extends JSONSchemaCommon {
65+
// Warning: (ae-forgotten-export) The symbol "JSONSchemaDefinition" needs to be exported by the entry point index.d.ts
66+
//
67+
// (undocumented)
68+
$defs?: Record<string, JSONSchemaDefinition>;
69+
// (undocumented)
70+
$id: string;
71+
// (undocumented)
72+
$schema: string;
73+
}
74+
1675
// @public
1776
export class ObserverMap {
18-
// Warning: (ae-forgotten-export) The symbol "Schema" needs to be exported by the entry point index.d.ts
1977
constructor(classPrototype: any, schema: Schema, config?: ObserverMapConfig);
2078
// (undocumented)
2179
defineProperties(): void;
@@ -47,14 +105,24 @@ export interface ResolvedStringsAndValues {
47105
values: Array<any>;
48106
}
49107

108+
// @public
109+
export class Schema {
110+
constructor(name: string);
111+
// Warning: (ae-forgotten-export) The symbol "RegisterPathConfig" needs to be exported by the entry point index.d.ts
112+
addPath(config: RegisterPathConfig): void;
113+
getRootProperties(): IterableIterator<string>;
114+
getSchema(rootPropertyName: string): JSONSchema | null;
115+
}
116+
117+
// @public
118+
export const schemaRegistry: CachedPathMap;
119+
50120
// @public
51121
export class TemplateElement extends FASTElement {
52122
constructor();
53-
// Warning: (ae-forgotten-export) The symbol "HydrationLifecycleCallbacks" needs to be exported by the entry point index.d.ts
54123
static config(callbacks: HydrationLifecycleCallbacks): typeof TemplateElement;
55124
// (undocumented)
56125
connectedCallback(): void;
57-
// Warning: (ae-forgotten-export) The symbol "ElementOptionsDictionary" needs to be exported by the entry point index.d.ts
58126
static elementOptions: ElementOptionsDictionary;
59127
name?: string;
60128
static options(elementOptions?: ElementOptionsDictionary): typeof TemplateElement;

packages/fast-html/src/components/attribute-map.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,41 @@
11
import type { FASTElementDefinition } from "@microsoft/fast-element";
22
import { AttributeDefinition, Observable } from "@microsoft/fast-element";
33
import type { Schema } from "./schema.js";
4-
import type { AttributeMapConfig } from "./template.js";
4+
5+
/**
6+
* Values for the attributeMap element option.
7+
*/
8+
export const AttributeMapOption = {
9+
all: "all",
10+
} as const;
11+
12+
/**
13+
* Configuration object for the attributeMap element option.
14+
* Passing an empty object (`{}`) is equivalent to `"all"`.
15+
*/
16+
export interface AttributeMapConfig {
17+
/**
18+
* Strategy for mapping template binding keys to HTML attribute names.
19+
*
20+
* - `"none"` (default): the binding key is used as-is for both the
21+
* property name and the attribute name (e.g. `{{foo-bar}}` →
22+
* property `foo-bar`, attribute `foo-bar`).
23+
* - `"camelCase"`: the binding key is treated as a camelCase property
24+
* name and the attribute name is derived by converting it to
25+
* kebab-case (e.g. `{{fooBar}}` → property `fooBar`, attribute
26+
* `foo-bar`). This matches the build-time `attribute-name-strategy`
27+
* option in `@microsoft/fast-build`.
28+
*/
29+
"attribute-name-strategy"?: "none" | "camelCase";
30+
}
31+
32+
/**
33+
* Type for the attributeMap element option.
34+
* Accepts `"all"` or a configuration object.
35+
*/
36+
export type AttributeMapOption =
37+
| (typeof AttributeMapOption)[keyof typeof AttributeMapOption]
38+
| AttributeMapConfig;
539

640
/**
741
* Converts a camelCase string to kebab-case.
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
export { AttributeMap } from "./attribute-map.js";
2-
export { ObserverMap } from "./observer-map.js";
31
export {
2+
AttributeMap,
43
type AttributeMapConfig,
54
AttributeMapOption,
6-
type ElementOptions,
7-
type ElementOptionsDictionary,
8-
type HydrationLifecycleCallbacks,
5+
} from "./attribute-map.js";
6+
export {
7+
ObserverMap,
98
type ObserverMapConfig,
109
ObserverMapOption,
1110
type ObserverMapPathEntry,
1211
type ObserverMapPathNode,
12+
} from "./observer-map.js";
13+
export {
14+
type CachedPathMap,
15+
type JSONSchema,
16+
Schema,
17+
schemaRegistry,
18+
} from "./schema.js";
19+
export {
20+
type ElementOptions,
21+
type ElementOptionsDictionary,
22+
type HydrationLifecycleCallbacks,
1323
TemplateElement,
1424
} from "./template.js";
1525
export { type ResolvedStringsAndValues, TemplateParser } from "./template-parser.js";

0 commit comments

Comments
 (0)