diff --git a/change/@microsoft-fast-element-bc2c7820-c045-444c-ad54-2dbe6df515b5.json b/change/@microsoft-fast-element-bc2c7820-c045-444c-ad54-2dbe6df515b5.json new file mode 100644 index 00000000000..bf45aee766c --- /dev/null +++ b/change/@microsoft-fast-element-bc2c7820-c045-444c-ad54-2dbe6df515b5.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Add documentation on FAST templating", + "packageName": "@microsoft/fast-element", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/fast-element/docs/api-report.api.md b/packages/fast-element/docs/api-report.api.md index 986594dec9c..59c4be4e0b3 100644 --- a/packages/fast-element/docs/api-report.api.md +++ b/packages/fast-element/docs/api-report.api.md @@ -509,15 +509,15 @@ export const html: HTMLTemplateTag; export class HTMLBindingDirective implements HTMLDirective, ViewBehaviorFactory, ViewBehavior, Aspected, BindingDirective { constructor(dataBinding: Binding); aspectType: DOMAspect; - // @internal (undocumented) + // @internal bind(controller: ViewController): void; createBehavior(): ViewBehavior; createHTML(add: AddViewBehaviorFactory): string; // (undocumented) dataBinding: Binding; - // @internal (undocumented) + // @internal handleChange(binding: Expression, observer: ExpressionObserver): void; - // @internal (undocumented) + // @internal handleEvent(event: Event): void; id: string; policy: DOMPolicy; diff --git a/packages/fast-element/src/components/hydration.ts b/packages/fast-element/src/components/hydration.ts index 3a3f3bd90a3..a682643ae84 100644 --- a/packages/fast-element/src/components/hydration.ts +++ b/packages/fast-element/src/components/hydration.ts @@ -12,6 +12,23 @@ import type { import type { HydrationView } from "../templating/view.js"; import { FAST } from "../platform.js"; +/** + * Regex patterns for parsing hydration markers embedded as HTML comments by the SSR renderer. + * Each marker type encodes factory indices so the client can map markers back to ViewBehaviorFactories. + * + * Content binding markers bracket text/template content: + * + * ...content... + * + * + * Repeat markers bracket each repeated item: + * + * + * + * Element boundary markers demarcate nested custom elements so parent walkers can skip them: + * + * + */ const bindingStartMarker = /fe-b\$\$start\$\$(\d+)\$\$(.+)\$\$fe-b/; const bindingEndMarker = /fe-b\$\$end\$\$(\d+)\$\$(.+)\$\$fe-b/; const repeatViewStartMarker = /fe-repeat\$\$start\$\$(\d+)\$\$fe-repeat/; diff --git a/packages/fast-element/src/hydration/target-builder.ts b/packages/fast-element/src/hydration/target-builder.ts index f50eda9f866..312f38082b5 100644 --- a/packages/fast-element/src/hydration/target-builder.ts +++ b/packages/fast-element/src/hydration/target-builder.ts @@ -79,7 +79,23 @@ function isShadowRoot(node: Node): node is ShadowRoot { } /** - * Maps {@link CompiledViewBehaviorFactory} ids to the corresponding node targets for the view. + * Maps compiled ViewBehaviorFactory IDs to their corresponding DOM nodes in the + * server-rendered shadow root. Uses a TreeWalker to scan the existing DOM between + * firstNode and lastNode, parsing hydration markers to build the targets map. + * + * For element nodes: parses `data-fe-b` (or variant) attributes to identify which + * factories target each element, then removes the marker attribute. + * + * For comment nodes: parses content binding markers (`fe-b$$start/end$$`) to find + * the DOM range controlled by each content binding. Single text nodes become the + * direct target; multi-node ranges are stored in boundaries for structural directives. + * Element boundary markers (`fe-eb$$start/end$$`) cause the walker to skip over + * nested custom elements that handle their own hydration. + * + * Host bindings (targetNodeId='h') appear at the start of the factories array but + * have no SSR markers — getHydrationIndexOffset() computes how many to skip so that + * marker indices align with the correct non-host factories. + * * @param firstNode - The first node of the view. * @param lastNode - The last node of the view. * @param factories - The Compiled View Behavior Factories that belong to the view. @@ -270,6 +286,11 @@ function skipToElementBoundaryEndMarker(node: Comment, walker: TreeWalker) { } } +/** + * Counts how many factories at the start of the array are host bindings (targetNodeId='h'). + * Host bindings target the custom element itself and are not represented by SSR markers, + * so the marker indices must be offset by this count to align with the correct factory. + */ function getHydrationIndexOffset(factories: CompiledViewBehaviorFactory[]): number { let offset = 0; diff --git a/packages/fast-element/src/templating/TEMPLATE-BINDINGS.md b/packages/fast-element/src/templating/TEMPLATE-BINDINGS.md new file mode 100644 index 00000000000..7bff0b8eb44 --- /dev/null +++ b/packages/fast-element/src/templating/TEMPLATE-BINDINGS.md @@ -0,0 +1,524 @@ +# FAST Element Template Bindings Architecture + +This document explains how the FAST Element `html` tagged template system stores bindings, applies them to DOM elements, triggers updates on data changes, and handles HTML-specific behaviors like event listeners. + +## Overview + +The template binding pipeline has five major stages: + +1. **Template Authoring** – The `html` tagged template collects binding expressions and builds placeholder-marked HTML. +2. **Compilation** – The compiler parses the placeholder HTML into a `DocumentFragment`, walks the DOM tree, and records where each binding targets. +3. **View Creation** – The compiled result clones the fragment and creates a *targets* object that maps each binding to its DOM node. +4. **Binding (first render)** – Each factory creates a behavior that attaches to its target node: adding event listeners, setting attributes, or observing expressions. +5. **Reactive Updates** – When observed data changes, one-way binding observers notify their directive, which re-evaluates the expression and pushes the new value to the DOM through a "sink" function. + +--- + +## Mermaid Diagrams + +### 1. Template Creation & Storage + +This diagram shows how the `html` tagged template literal processes interpolated values and stores them as factories keyed by unique IDs. + +```mermaid +flowchart TD + A["html`<div class='${expr}'>${text}</div>`"] --> B["ViewTemplate.create(strings, values)"] + B --> C{"For each interpolated value"} + C -->|Function| D["Wrap in oneWay() → HTMLBindingDirective"] + C -->|Binding instance| E["Wrap in HTMLBindingDirective"] + C -->|HTMLDirective| F["Use directly"] + C -->|Static value| G["Wrap in oneTime() → HTMLBindingDirective"] + D --> H["directive.createHTML(add)"] + E --> H + F --> H + G --> H + H --> I["add(factory) assigns unique ID"] + I --> J["factories Record<string, ViewBehaviorFactory>"] + I --> K["Returns placeholder marker in HTML string"] + K --> L["Aspect Detection via lastAttributeNameRegex"] + L -->|In attribute context| M["HTMLDirective.assignAspect(directive, attrName)"] + L -->|In text content| N["aspectType = DOMAspect.content"] + M --> O{"Attribute prefix"} + O -->|No prefix| P["DOMAspect.attribute"] + O -->|: prefix| Q["DOMAspect.property"] + O -->|? prefix| R["DOMAspect.booleanAttribute"] + O -->|"@" prefix| S["DOMAspect.event"] + O -->|:classList| T["DOMAspect.tokenList"] + J --> U["new ViewTemplate(html, factories)"] +``` + +### 2. Compilation – Walking the DOM & Storing Target Locations + +The compiler transforms the placeholder-laden HTML into a `DocumentFragment` and builds a prototype with lazy property descriptors that navigate to target nodes by child index. + +```mermaid +flowchart TD + A["ViewTemplate.compile()"] --> B["Compiler.compile(html, factories, policy)"] + B --> C["Create HTMLTemplateElement"] + C --> D["template.innerHTML = policy.createHTML(html)"] + D --> E["document.adoptNode(template.content) → fragment"] + E --> F["new CompilationContext(fragment, factories, policy)"] + F --> G["compileAttributes(context, template, 'h') — host bindings"] + G --> H["compileChildren(context, fragment, 'r') — root"] + + H --> I{"For each child node"} + I -->|Element node type=1| J["compileAttributes: parse each attribute value"] + J --> K{"Parser.parse finds placeholders?"} + K -->|Yes| L["Remove attribute from DOM"] + L --> M["context.addFactory(factory, parentId, nodeId, index, tagName)"] + I -->|Element node| N["compileChildren recursively"] + I -->|Text node type=3| O["compileContent: Parser.parse text"] + O --> P{"Placeholders found?"} + P -->|Yes| Q["Split text node, insert new text nodes"] + Q --> R["context.addFactory for each directive part"] + I -->|Comment node type=8| S["Parser.parse comment data"] + S --> T["context.addFactory for structural directives"] + + M --> U["addTargetDescriptor(parentId, nodeId, childIndex)"] + U --> V["Lazy getter: this[parentId].childNodes[childIndex]"] + V --> W["Stored in descriptors → prototype via Object.create"] + + subgraph "Target Node ID Scheme" + X["Root: 'r'"] + Y["Host: 'h'"] + Z["Children: 'r.0', 'r.1', 'r.0.2'"] + AA["Encodes DOM tree path via dot-separated child indices"] + end + + W --> AB["context.freeze() → compilation result"] +``` + +### 3. View Creation – Cloning the Fragment & Resolving Targets + +```mermaid +flowchart TD + A["compilationResult.createView(hostBindingTarget?)"] --> B["fragment.cloneNode(true) → new DocumentFragment"] + B --> C["targets = Object.create(proto)"] + C --> D["targets.r = fragment (root)"] + D --> E["targets.h = hostBindingTarget (host element)"] + E --> F{"For each registered nodeId"} + F --> G["Access targets[nodeId] — triggers lazy getter chain"] + G --> H["Getter resolves: this[parentId].childNodes[childIndex]"] + H --> I["Caches result in targets._nodeId field"] + I --> J["new HTMLView(fragment, factories, targets)"] + + subgraph "HTMLView Structure" + K["fragment: DocumentFragment with cloned DOM"] + L["factories: CompiledViewBehaviorFactory[]"] + M["targets: ViewBehaviorTargets {nodeId → Node}"] + N["firstChild / lastChild: boundary nodes"] + O["source: null (until bound)"] + P["behaviors: null (created on first bind)"] + end + J --> K + J --> L + J --> M +``` + +### 4. Binding – Attaching Behaviors to DOM Nodes + +```mermaid +flowchart TD + A["view.bind(source)"] --> B{"First bind?"} + B -->|Yes| C["Set view.source = source"] + C --> D["Create behaviors array"] + D --> E{"For each factory"} + E --> F["factory.createBehavior()"] + F --> G["Select sink from sinkLookup based on aspectType"] + G --> H["Wrap sink through policy.protect()"] + H --> I["behavior.bind(controller/view)"] + + B -->|Subsequent| J["Unbind previous, set new source"] + J --> K["Re-bind existing behaviors"] + + I --> L{"aspectType?"} + + L -->|DOMAspect.event| M["Store controller ref on target node"] + M --> N["target.addEventListener(targetAspect, directive, options)"] + N --> O["directive IS the EventListener via handleEvent()"] + + L -->|DOMAspect.content| P["Register unbind handler"] + P --> Q["Create observer via dataBinding.createObserver()"] + + L -->|attribute / property / etc.| Q + + Q --> R["observer.bind(controller) → evaluates expression"] + R --> S["updateTarget(target, aspect, value, controller)"] + + subgraph "Sink Functions (sinkLookup)" + S1["attribute → DOM.setAttribute(target, name, value)"] + S2["booleanAttribute → DOM.setBooleanAttribute(target, name, value)"] + S3["property → target[name] = value"] + S4["content → updateContent() — manages text or composed views"] + S5["tokenList → updateTokenList() — classList with versioning"] + S6["event → no-op (handled via addEventListener)"] + end +``` + +### 5. Reactive Update Cycle – Change Detection & DOM Updates + +```mermaid +sequenceDiagram + participant Source as Data Source + participant Observable as Observable System + participant Observer as ExpressionObserver (OneWayBinding) + participant Directive as HTMLBindingDirective + participant Sink as UpdateTarget (sink fn) + participant DOM as Target DOM Node + + Note over Source,DOM: Initial Bind + Directive->>Observer: dataBinding.createObserver(directive, directive) + Observer->>Source: Evaluate expression (tracks property access) + Source-->>Observer: Return value + record dependencies + Directive->>Sink: updateTarget(target, aspect, value, controller) + Sink->>DOM: Apply value (setAttribute, textContent, etc.) + + Note over Source,DOM: Data Change + Source->>Observable: Property setter triggered + Observable->>Observer: Notify subscriber of change + Observer->>Directive: handleChange(binding, observer) + Directive->>Observer: observer.bind(controller) — re-evaluate + Observer->>Source: Re-evaluate expression + Source-->>Observer: Return new value + Directive->>Sink: updateTarget(target, aspect, newValue, controller) + Sink->>DOM: Update DOM with new value +``` + +### 6. Event Handling – Click Events and Other DOM Events + +```mermaid +sequenceDiagram + participant User as User Interaction + participant DOM as Target DOM Element + participant Directive as HTMLBindingDirective (EventListener) + participant Context as ExecutionContext + participant Binding as dataBinding.evaluate() + participant Source as Component Source + + Note over DOM: During bind: target.addEventListener("click", directive) + Note over DOM: directive stores controller ref on target[data] + + User->>DOM: Click event fires + DOM->>Directive: handleEvent(event) + Directive->>DOM: Read controller from event.currentTarget[data] + Directive->>Directive: Check controller.isBound + Directive->>Context: ExecutionContext.setEvent(event) + Directive->>Binding: evaluate(controller.source, controller.context) + Binding->>Source: Execute handler: (s, c) => s.handleClick(c.event) + Source-->>Binding: Return result + Binding-->>Directive: Return result + alt result !== true + Directive->>DOM: event.preventDefault() + end + Directive->>Context: ExecutionContext.setEvent(null) +``` + +### 7. Content Binding – Template Composition + +When a binding expression returns a `ContentTemplate` (e.g., another `ViewTemplate`), the content update sink composes a child view into the DOM. + +```mermaid +flowchart TD + A["updateContent(target, aspect, value, controller)"] --> B{"value is null/undefined?"} + B -->|Yes| C["Treat as empty string"] + B -->|No| D{"value.create exists? (ContentTemplate)"} + + D -->|Yes — Template| E["Clear target.textContent"] + E --> F{"Existing view on target.$fastView?"} + F -->|No| G["view = value.create()"] + F -->|Yes, same template| H["Reuse existing view"] + F -->|Yes, different template| I["Remove old view, create new"] + G --> J["view.bind(source, context)"] + I --> J + J --> K["view.insertBefore(target)"] + K --> L["Cache: target.$fastView = view, target.$fastTemplate = value"] + + D -->|No — Primitive| M{"Existing composed view?"} + M -->|Yes| N["view.remove(), view.unbind()"] + N --> O["target.textContent = value"] + M -->|No| O +``` + +--- + +## Key Data Structures + +| Structure | Location | Purpose | +|---|---|---| +| `ViewTemplate` | template.ts | Holds raw HTML + factories record. Entry point for compilation. | +| `factories: Record` | template.ts | Maps unique IDs to binding factories (directives). | +| `CompilationContext` | compiler.ts | Accumulates factories and builds the target prototype during compilation. | +| `targets: ViewBehaviorTargets` | view.ts / html-directive.ts | Maps node IDs (e.g., `"r.0.2"`) to actual DOM nodes in a cloned fragment. | +| `HTMLView` | view.ts | The live view instance: holds the fragment, factories, targets, behaviors, and source. | +| `HTMLBindingDirective` | html-binding-directive.ts | The core binding: acts as factory, behavior, and event listener. | +| `sinkLookup` | html-binding-directive.ts | Maps `DOMAspect` types to DOM update functions. | +| `Binding` (abstract) | binding/binding.ts | Wraps an expression with policy, volatility, and observer creation. | +| `ExpressionObserver` | observation/observable.ts | Tracks dependencies during expression evaluation and notifies on change. | + +## Binding Type Summary + +| Markup Syntax | Aspect Type | Sink Function | Example | +|---|---|---|---| +| `attr="${x => x.val}"` | `attribute` | `DOM.setAttribute` | `class="${x => x.cls}"` | +| `?attr="${x => x.val}"` | `booleanAttribute` | `DOM.setBooleanAttribute` | `?disabled="${x => x.off}"` | +| `:prop="${x => x.val}"` | `property` | `target[prop] = value` | `:value="${x => x.name}"` | +| `:classList="${x => x.val}"` | `tokenList` | `updateTokenList` | `:classList="${x => x.classes}"` | +| `@event="${x => x.handler}"` | `event` | addEventListener | `@click="${(x,c) => x.onClick(c.event)}"` | +| `${x => x.val}` (in text) | `content` | `updateContent` | `

${x => x.msg}

` | + +--- + +## Hydration: Attaching Bindings to Server-Rendered DOM + +When a page is server-side rendered (SSR) with Declarative Shadow DOM, the HTML arrives in the browser fully formed. Instead of creating new DOM nodes, FAST's hydration system **reuses the existing DOM** and attaches reactive bindings to it. This section explains how hydration markers in the SSR output guide the client-side binding process. + +### Enabling Hydration + +Hydration is an opt-in, tree-shakeable feature. Importing `install-hydratable-view-templates.ts` patches `ViewTemplate.prototype` with: +1. A `Hydratable` symbol — marks the template as hydration-capable (checked via `isHydratable()`). +2. A `hydrate(firstChild, lastChild, hostBindingTarget?)` method — creates a `HydrationView` instead of an `HTMLView`. + +```typescript +// This import enables hydration for all ViewTemplate instances +import "@microsoft/fast-element/install-hydratable-view-templates"; +``` + +### Hydration Marker Format + +The SSR renderer embeds comment nodes and data attributes into the HTML to mark where bindings should attach. These markers encode the **factory index** (position in the compiled factories array) so the client can map each marker back to its corresponding `ViewBehaviorFactory`. + +#### Attribute Binding Markers + +Elements with attribute/property/event bindings receive a `data-fe-b` attribute listing the factory indices: + +```html + +
server-rendered content
+``` + +Three attribute marker formats are supported: + +| Format | Example | Description | +|---|---|---| +| Space-separated | `data-fe-b="0 1 2"` | Default: factory indices in one attribute value | +| Enumerated | `data-fe-b-0 data-fe-b-1` | Separate attributes per index | +| Compact | `data-fe-c-0-3` | Start index and count (indices 0, 1, 2) | + +#### Content Binding Markers + +Text/template content bindings are wrapped in paired comment nodes with a unique ID: + +```html + +Hello, World! + +``` + +#### Repeat Directive Markers + +Each repeated item is bracketed by repeat markers encoding the item index: + +```html + +
  • First item
  • + + +
  • Second item
  • + +``` + +#### Element Boundary Markers + +Nested custom elements that also need hydration are demarcated so the parent's walker can skip over them: + +```html + + + + + +``` + +### Hydration Binding Flow + +```mermaid +flowchart TD + A["Server renders HTML with Declarative Shadow DOM"] --> B["Browser parses HTML, creates DOM + shadow roots"] + B --> C["Custom element connects, HydratableElementController activates"] + C --> D{"Has 'defer-hydration' attribute?"} + D -->|Yes| E["Wait until attribute is removed"] + D -->|No| F["template.hydrate(firstChild, lastChild, hostBindingTarget)"] + E -->|Attribute removed| F + F --> G["new HydrationView(firstChild, lastChild, sourceTemplate)"] + G --> H["HydrationView.bind(source)"] + H --> I["Stage: unhydrated → hydrating"] + I --> J["buildViewBindingTargets(firstChild, lastChild, factories)"] + + J --> K["Create TreeWalker over existing DOM range"] + K --> L{"Walk each node"} + + L -->|Element node| M["Parse data-fe-b attribute"] + M --> N["Map factory indices to this element via targetFactory()"] + N --> O["Remove data-fe-b marker attribute"] + + L -->|Comment: content marker| P["Parse fe-b$$start$$ marker"] + P --> Q["Walk siblings to find matching fe-b$$end$$ marker"] + Q --> R{"Content between markers?"} + R -->|Single text node| S["Target factory to text node directly"] + R -->|Multiple nodes / template| T["Store boundaries in ViewBehaviorBoundaries"] + T --> U["Insert dummy text node as target for future string updates"] + R -->|Empty null/false binding| U + L -->|Comment: element boundary| V["Skip to matching fe-eb$$end$$ marker"] + L -->|Other| W["Continue walking"] + J --> X["Return { targets, boundaries }"] + X --> Y["Create behaviors from factories"] + Y --> Z["behavior.bind(hydrationView)"] + Z --> AA["Stage: hydrating → hydrated"] + + subgraph "Host Binding Offset" + BB["Host bindings (targetNodeId='h') are at the start of the factories array"] + CC["getHydrationIndexOffset() counts host bindings to skip"] + DD["SSR markers use indices relative to non-host factories"] + end +``` + +### HydrationView vs HTMLView + +```mermaid +flowchart LR + subgraph "HTMLView (client-rendered)" + A1["Compiler produces DocumentFragment"] --> A2["fragment.cloneNode(true)"] + A2 --> A3["Resolve targets via prototype getter chain"] + A3 --> A4["Create behaviors & bind"] + end + + subgraph "HydrationView (server-rendered)" + B1["DOM already exists in shadow root"] --> B2["TreeWalker scans for markers"] + B2 --> B3["buildViewBindingTargets maps markers → nodes"] + B3 --> B4["Create behaviors & bind to existing nodes"] + B4 --> B5["Marker comments cleared (data set to empty string)"] + end +``` + +| Aspect | HTMLView | HydrationView | +|---|---|---| +| **DOM source** | Clones compiled DocumentFragment | Reuses server-rendered DOM in place | +| **Target resolution** | Prototype getters via childNodes indices | TreeWalker + hydration marker parsing | +| **Node creation** | Creates all DOM nodes from scratch | No node creation (reuses existing) | +| **Fragment** | Holds cloned fragment, moved into host | No fragment initially (created only on remove) | +| **Lifecycle** | Ready immediately after creation | Transitions through unhydrated → hydrating → hydrated | +| **Validation** | Compilation guarantees structure | Must validate markers match factories (throws HydrationBindingError) | +| **Boundaries** | Not needed (compiler tracks structure) | `bindingViewBoundaries` stores first/last node pairs for structural directives | + +### Content Binding Hydration + +When `HTMLBindingDirective.bind()` runs during hydration and the binding returns a `ContentTemplate`, the `updateContent` sink checks for pre-rendered boundaries: + +```mermaid +sequenceDiagram + participant Directive as HTMLBindingDirective + participant Controller as HydrationView (controller) + participant Sink as updateContent() + participant Template as ContentTemplate + + Directive->>Controller: bind(controller) + Note over Directive: aspectType = DOMAspect.content + Directive->>Sink: updateTarget(target, aspect, value, controller) + Sink->>Sink: value is ContentTemplate? + + alt Hydrating & boundaries exist + Sink->>Controller: Check bindingViewBoundaries[targetNodeId] + Controller-->>Sink: { first: Node, last: Node } + Sink->>Template: value.hydrate(first, last) + Template-->>Sink: HydrationView (reuses existing nodes) + Sink->>Sink: view.bind(source, context) — attaches to pre-rendered DOM + else Already hydrated or no boundaries + Sink->>Template: value.create() + Template-->>Sink: HTMLView (creates new nodes) + Sink->>Sink: view.bind + view.insertBefore + end +``` + +### Repeat Directive Hydration + +The repeat directive hydrates by walking **backwards** from its location marker, finding paired repeat markers for each array item: + +```mermaid +flowchart TD + A["RepeatBehavior.bind(controller)"] --> B{"isHydratable(template) && hydrating?"} + B -->|Yes| C["hydrateViews(template)"] + B -->|No| D["refreshAllViews() — normal client path"] + + C --> E["Allocate views array sized to items.length"] + E --> F["Start at location.previousSibling, walk backwards"] + + F --> G{"Current node is comment?"} + G -->|No| H["Skip, move to previousSibling"] + G -->|Yes| I{"parseRepeatEndMarker?"} + I -->|No| H + I -->|Yes, index N| J["Clear end marker comment data"] + + J --> K["end = previous sibling of end marker"] + K --> L["Walk backwards to find matching start marker"] + L --> M{"Handle nested repeats via unmatchedEndMarkers counter"} + M --> N["Found start marker with same index N"] + N --> O["Clear start marker, start = startMarker.nextSibling"] + O --> P["template.hydrate(start, end) → HydrationView"] + P --> Q["views[N] = view"] + Q --> R["bindView(view, items, N, controller)"] + R --> F + + subgraph "Result" + S["Each array item mapped to a HydrationView"] + T["Views reuse server-rendered DOM between markers"] + U["Markers cleared to empty strings (invisible in DOM)"] + end +``` + +### Hydration Stage Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> unhydrated: HydrationView created + + unhydrated --> hydrating: bind() called + note right of hydrating + buildViewBindingTargets() scans DOM + Markers parsed, targets resolved + Behaviors created and bound + Attribute bindings skip DOM update + (server already set correct values) + end note + + hydrating --> hydrated: All behaviors bound + note right of hydrated + Subsequent bind() calls + behave like normal HTMLView rebind + (re-evaluate expressions, update DOM) + end note + + hydrated --> hydrated: rebind with new source +``` + +During the `hydrating` stage, attribute and boolean-attribute bindings **skip their initial DOM update** (the server already rendered the correct value). This avoids unnecessary DOM writes during hydration: + +```typescript +// In HTMLBindingDirective.bind(), during hydration: +if (isHydrating && (this.aspectType === DOMAspect.attribute || + this.aspectType === DOMAspect.booleanAttribute)) { + observer.bind(controller); // Set up observation only + break; // Skip updateTarget — server value is current +} +``` + +### Error Handling + +When the server-rendered DOM doesn't match the client template, hydration throws descriptive errors: + +| Error | Cause | Contains | +|---|---|---| +| `HydrationTargetElementError` | `data-fe-b` references a factory index that doesn't exist | Factory list, element node, template string | +| `HydrationBindingError` | A factory's `targetNodeId` has no matching entry in targets | Factory, cloned fragment, template string, available target IDs | +| `HydrationRepeatError` | Repeat markers are mismatched or missing | Hydration stage, items length, view states | + +These errors typically indicate a mismatch between the server-rendered HTML and the client-side template definition. diff --git a/packages/fast-element/src/templating/compiler.ts b/packages/fast-element/src/templating/compiler.ts index 019fa36ec6e..0d49965112c 100644 --- a/packages/fast-element/src/templating/compiler.ts +++ b/packages/fast-element/src/templating/compiler.ts @@ -15,6 +15,12 @@ import { nextId, Parser } from "./markup.js"; import type { HTMLTemplateCompilationResult as TemplateCompilationResult } from "./template.js"; import { HTMLView } from "./view.js"; +/** + * Builds a hierarchical node ID by appending the child index to the parent's ID. + * For example, the third child of root is "r.2", and its first child is "r.2.0". + * These IDs are used as property names on the targets prototype so that each + * binding's target DOM node can be lazily resolved via a chain of childNodes lookups. + */ const targetIdFrom = (parentId: string, nodeIndex: number): string => `${parentId}.${nodeIndex}`; const descriptorCache: PropertyDescriptorMap = {}; @@ -88,6 +94,13 @@ class CompilationContext return this; } + /** + * Registers a lazy getter on the targets prototype that resolves a DOM node + * by navigating from its parent's childNodes at the given index. Getters are + * chained: accessing targets["r.0.2"] first resolves targets["r.0"] (which + * resolves targets["r"]), then returns childNodes[2]. Results are cached so + * each node is resolved at most once per view instance. + */ private addTargetDescriptor( parentId: string, targetId: string, @@ -128,15 +141,22 @@ class CompilationContext descriptors[targetId] = descriptor; } + /** + * Creates a new HTMLView by cloning the compiled DocumentFragment and building + * a targets object. The targets prototype contains lazy getters that resolve + * each binding's target DOM node via childNodes traversal. Accessing every + * registered nodeId eagerly triggers the getter chain so all nodes are resolved + * before behaviors are bound. + */ public createView(hostBindingTarget?: Element): HTMLView { const fragment = this.fragment.cloneNode(true) as DocumentFragment; const targets = Object.create(this.proto); - targets.r = fragment; - targets.h = hostBindingTarget ?? warningHost; + targets.r = fragment; // root — the cloned DocumentFragment + targets.h = hostBindingTarget ?? warningHost; // host — the custom element for (const id of this.nodeIds) { - targets[id]; // trigger locator + targets[id]; // trigger lazy getter to resolve and cache the DOM node } return new HTMLView(fragment, this.factories, targets); diff --git a/packages/fast-element/src/templating/html-binding-directive.ts b/packages/fast-element/src/templating/html-binding-directive.ts index 48d466624db..409104b9e7a 100644 --- a/packages/fast-element/src/templating/html-binding-directive.ts +++ b/packages/fast-element/src/templating/html-binding-directive.ts @@ -91,6 +91,14 @@ function isContentTemplate(value: any): value is ContentTemplate { return value.create !== undefined; } +/** + * Sink function for DOMAspect.content bindings (text content interpolation). + * Handles two cases: + * - If the value is a ContentTemplate (has a create() method), it composes a child + * view into the DOM, managing view lifecycle (create/reuse/remove/bind). + * - If the value is a primitive, it sets target.textContent directly, first removing + * any previously composed view. + */ function updateContent( this: HTMLBindingDirective, target: ContentTarget, @@ -175,6 +183,12 @@ interface TokenListState { v: number; } +/** + * Sink function for DOMAspect.tokenList bindings (e.g., :classList). + * Uses a versioning scheme to efficiently track which CSS classes were added + * in the current update vs. the previous one. Classes from the previous version + * that aren't present in the new value are automatically removed. + */ function updateTokenList( this: HTMLBindingDirective, target: Element, @@ -221,6 +235,12 @@ function updateTokenList( } } +/** + * Maps each DOMAspect type to its corresponding DOM update ("sink") function. + * When a binding value changes, the sink function for the binding's aspect type + * is called to push the new value into the DOM. Events are handled separately + * via addEventListener in bind(), so the event sink is a no-op. + */ const sinkLookup: Record = { [DOMAspect.attribute]: DOM.setAttribute, [DOMAspect.booleanAttribute]: DOM.setBooleanAttribute, @@ -231,7 +251,18 @@ const sinkLookup: Record = { }; /** - * A directive that applies bindings. + * The central binding directive that bridges data expressions and DOM updates. + * + * HTMLBindingDirective fulfills three roles simultaneously: + * - **HTMLDirective**: Produces placeholder HTML via createHTML() during template authoring. + * - **ViewBehaviorFactory**: Creates behaviors (returns itself) during view creation. + * - **ViewBehavior / EventListener**: Attaches to a DOM node during bind, manages + * expression observers for reactive updates, and handles DOM events directly. + * + * The aspectType (set by HTMLDirective.assignAspect during template processing) + * determines which DOM "sink" function is used to apply values — e.g., + * setAttribute for attributes, addEventListener for events, textContent for content. + * * @public */ export class HTMLBindingDirective @@ -318,7 +349,18 @@ export class HTMLBindingDirective return this; } - /** @internal */ + /** + * Attaches this binding to its target DOM node. + * - For events: stores the controller reference on the target element and registers + * this directive as the EventListener via addEventListener. The directive's + * handleEvent() method will be called when the event fires. + * - For content bindings: registers an unbind handler, then falls through to the + * default path. + * - For all non-event bindings: creates (or reuses) an ExpressionObserver, evaluates + * the binding expression, and applies the result to the DOM via the updateTarget + * sink function. The observer will call handleChange() on future data changes. + * @internal + */ bind(controller: ViewController): void { const target = controller.targets[this.targetNodeId]; const isHydrating = @@ -377,7 +419,14 @@ export class HTMLBindingDirective } } - /** @internal */ + /** + * Implements the EventListener interface. When a DOM event fires on the target + * element, this method retrieves the ViewController stored on the element, + * sets the event on the ExecutionContext so `c.event` is available to the + * binding expression, and evaluates the expression. If the expression returns + * anything other than `true`, the event's default action is prevented. + * @internal + */ handleEvent(event: Event): void { const controller = event.currentTarget![this.data] as ViewController; @@ -395,7 +444,13 @@ export class HTMLBindingDirective } } - /** @internal */ + /** + * Called by the ExpressionObserver when a tracked dependency changes. + * Re-evaluates the binding expression via observer.bind() and pushes + * the new value to the DOM through the updateTarget sink function. + * This is the reactive update path that keeps the DOM in sync with data. + * @internal + */ handleChange(binding: Expression, observer: ExpressionObserver): void { const target = (observer as any).target; const controller = (observer as any).controller; diff --git a/packages/fast-element/src/templating/html-directive.ts b/packages/fast-element/src/templating/html-directive.ts index 3114083008e..774879d1544 100644 --- a/packages/fast-element/src/templating/html-directive.ts +++ b/packages/fast-element/src/templating/html-directive.ts @@ -179,7 +179,13 @@ export const HTMLDirective = Object.freeze({ }, /** - * + * Determines the DOM aspect type for a directive based on attribute name prefix. + * The prefix convention maps to aspect types as follows: + * - No prefix (e.g. "class") → DOMAspect.attribute + * - ":" prefix (e.g. ":value") → DOMAspect.property (":classList" → DOMAspect.tokenList) + * - "?" prefix (e.g. "?disabled") → DOMAspect.booleanAttribute + * - "\@" prefix (e.g. "\@click") → DOMAspect.event + * - No value (text content) → DOMAspect.content * @param directive - The directive to assign the aspect to. * @param value - The value to base the aspect determination on. * @remarks diff --git a/packages/fast-element/src/templating/install-hydratable-view-templates.ts b/packages/fast-element/src/templating/install-hydratable-view-templates.ts index ba5e74222bc..1e77bf662d8 100644 --- a/packages/fast-element/src/templating/install-hydratable-view-templates.ts +++ b/packages/fast-element/src/templating/install-hydratable-view-templates.ts @@ -6,6 +6,12 @@ import { HydrationView } from "./view.js"; // and a hydrate method. Augmenting the hydration features is done by // property assignment instead of class extension to better allow the // hydration feature to be tree-shaken. +// +// When hydrate() is called, it creates a HydrationView that wraps the +// pre-rendered DOM range (firstChild → lastChild) instead of cloning a +// compiled DocumentFragment. The HydrationView will then use +// buildViewBindingTargets() to scan for hydration markers and attach +// reactive bindings to the existing DOM nodes. Object.defineProperties(ViewTemplate.prototype, { [Hydratable]: { value: Hydratable, enumerable: false, configurable: false }, hydrate: { diff --git a/packages/fast-element/src/templating/markup.ts b/packages/fast-element/src/templating/markup.ts index 34385a9c041..62f56be7135 100644 --- a/packages/fast-element/src/templating/markup.ts +++ b/packages/fast-element/src/templating/markup.ts @@ -1,5 +1,11 @@ import type { ViewBehaviorFactory } from "./html-directive.js"; +/** + * A unique per-session random marker string used to create placeholder tokens in HTML. + * Bindings embedded in template literals are replaced with interpolation markers + * of the form `fast-xxxxxx{id}xxxxxx` so the compiler can later locate them in the + * parsed DOM and associate each marker with its ViewBehaviorFactory. + */ const marker = `fast-${Math.random().toString(36).substring(2, 8)}`; const interpolationStart = `${marker}{`; const interpolationEnd = `}${marker}`; @@ -61,6 +67,8 @@ export const Parser = Object.freeze({ value: string, factories: Record ): (string | ViewBehaviorFactory)[] | null { + // Split on the interpolation start marker. If there's only one part, + // no placeholders exist and we return null to signal "no directives here." const parts = value.split(interpolationStart); if (parts.length === 1) { diff --git a/packages/fast-element/src/templating/template.ts b/packages/fast-element/src/templating/template.ts index aaa3509e8e0..77547d31355 100644 --- a/packages/fast-element/src/templating/template.ts +++ b/packages/fast-element/src/templating/template.ts @@ -273,7 +273,25 @@ export class ViewTemplate } /** - * Creates a template based on a set of static strings and dynamic values. + * Processes the tagged template literal's static strings and interpolated values and + * creates a ViewTemplate. + * + * For each interpolated value: + * 1. Arrow functions (e.g., `x => x.name`) → wrapped in a one-way HTMLBindingDirective + * 2. Binding instances → wrapped in an HTMLBindingDirective + * 3. HTMLDirective instances → used as-is + * 4. Static values (strings, numbers) → wrapped in a one-time HTMLBindingDirective + * + * Each directive's createHTML() is called with an `add` callback that registers + * the factory in the factories record under a unique ID and returns that ID. + * The directive inserts a placeholder marker (e.g., `fast-abc123{id}abc123`) into + * the HTML string so the compiler can later find and associate it with the factory. + * + * Aspect detection happens here too: the `lastAttributeNameRegex` checks whether + * the placeholder appears inside an attribute value, and if so, assignAspect() + * sets the correct DOMAspect (attribute, property, event, etc.) based on the + * attribute name prefix. + * * @param strings - The static strings to create the template with. * @param values - The dynamic values to create the template with. * @param policy - The DOMPolicy to associated with the template. diff --git a/packages/fast-element/src/templating/view.ts b/packages/fast-element/src/templating/view.ts index 484e6f9a381..2a648effa9d 100644 --- a/packages/fast-element/src/templating/view.ts +++ b/packages/fast-element/src/templating/view.ts @@ -339,6 +339,17 @@ export class HTMLView /** * Binds a view's behaviors to its binding source. + * + * On the first call, this iterates through all compiled factories, calling + * createBehavior() on each to produce a ViewBehavior instance (e.g., an + * HTMLBindingDirective), and then immediately binds it. This is where event + * listeners are registered, expression observers are created, and initial + * DOM values are set. + * + * On subsequent calls with a new source, existing behaviors are re-bound + * to the new data source, which re-evaluates all binding expressions and + * updates the DOM accordingly. + * * @param source - The binding source for the view's binding behaviors. * @param context - The execution context to run the behaviors within. */ @@ -350,6 +361,8 @@ export class HTMLView let behaviors = this.behaviors; if (behaviors === null) { + // First bind: create behaviors from factories and bind each one. + // The view (this) acts as the ViewController, providing targets and source. this.source = source; this.context = context; this.behaviors = behaviors = new Array(this.factories.length); diff --git a/sites/website/src/docs/2.x/api/fast-element.htmlbindingdirective.md b/sites/website/src/docs/2.x/api/fast-element.htmlbindingdirective.md index 28674dd8653..52489d989b6 100644 --- a/sites/website/src/docs/2.x/api/fast-element.htmlbindingdirective.md +++ b/sites/website/src/docs/2.x/api/fast-element.htmlbindingdirective.md @@ -15,7 +15,11 @@ navigationOptions: ## HTMLBindingDirective class -A directive that applies bindings. +The central binding directive that bridges data expressions and DOM updates. + +HTMLBindingDirective fulfills three roles simultaneously: - \*\*HTMLDirective\*\*: Produces placeholder HTML via createHTML() during template authoring. - \*\*ViewBehaviorFactory\*\*: Creates behaviors (returns itself) during view creation. - \*\*ViewBehavior / EventListener\*\*: Attaches to a DOM node during bind, manages expression observers for reactive updates, and handles DOM events directly. + +The aspectType (set by HTMLDirective.assignAspect during template processing) determines which DOM "sink" function is used to apply values — e.g., setAttribute for attributes, addEventListener for events, textContent for content. **Signature:** diff --git a/sites/website/src/docs/2.x/api/fast-element.htmlview.bind.md b/sites/website/src/docs/2.x/api/fast-element.htmlview.bind.md index dbbb69a9ce4..99e6d00887c 100644 --- a/sites/website/src/docs/2.x/api/fast-element.htmlview.bind.md +++ b/sites/website/src/docs/2.x/api/fast-element.htmlview.bind.md @@ -17,6 +17,10 @@ navigationOptions: Binds a view's behaviors to its binding source. +On the first call, this iterates through all compiled factories, calling createBehavior() on each to produce a ViewBehavior instance (e.g., an HTMLBindingDirective), and then immediately binds it. This is where event listeners are registered, expression observers are created, and initial DOM values are set. + +On subsequent calls with a new source, existing behaviors are re-bound to the new data source, which re-evaluates all binding expressions and updates the DOM accordingly. + **Signature:** ```typescript diff --git a/sites/website/src/docs/2.x/api/fast-element.htmlview.md b/sites/website/src/docs/2.x/api/fast-element.htmlview.md index 9a66e9f49cd..d6ccfc71ff4 100644 --- a/sites/website/src/docs/2.x/api/fast-element.htmlview.md +++ b/sites/website/src/docs/2.x/api/fast-element.htmlview.md @@ -264,6 +264,10 @@ Appends the view's DOM nodes to the referenced node. Binds a view's behaviors to its binding source. +On the first call, this iterates through all compiled factories, calling createBehavior() on each to produce a ViewBehavior instance (e.g., an HTMLBindingDirective), and then immediately binds it. This is where event listeners are registered, expression observers are created, and initial DOM values are set. + +On subsequent calls with a new source, existing behaviors are re-bound to the new data source, which re-evaluates all binding expressions and updates the DOM accordingly. + diff --git a/sites/website/src/docs/2.x/api/fast-element.md b/sites/website/src/docs/2.x/api/fast-element.md index f7797469b88..75e8bb377bc 100644 --- a/sites/website/src/docs/2.x/api/fast-element.md +++ b/sites/website/src/docs/2.x/api/fast-element.md @@ -100,7 +100,11 @@ Defines metadata for a FASTElement. -A directive that applies bindings. +The central binding directive that bridges data expressions and DOM updates. + +HTMLBindingDirective fulfills three roles simultaneously: - \*\*HTMLDirective\*\*: Produces placeholder HTML via createHTML() during template authoring. - \*\*ViewBehaviorFactory\*\*: Creates behaviors (returns itself) during view creation. - \*\*ViewBehavior / EventListener\*\*: Attaches to a DOM node during bind, manages expression observers for reactive updates, and handles DOM events directly. + +The aspectType (set by HTMLDirective.assignAspect during template processing) determines which DOM "sink" function is used to apply values — e.g., setAttribute for attributes, addEventListener for events, textContent for content. diff --git a/sites/website/src/docs/2.x/api/fast-element.viewtemplate.create.md b/sites/website/src/docs/2.x/api/fast-element.viewtemplate.create.md index 5b9408a69b9..ed747fb6a18 100644 --- a/sites/website/src/docs/2.x/api/fast-element.viewtemplate.create.md +++ b/sites/website/src/docs/2.x/api/fast-element.viewtemplate.create.md @@ -15,7 +15,13 @@ navigationOptions: ## ViewTemplate.create() method -Creates a template based on a set of static strings and dynamic values. +Processes the tagged template literal's static strings and interpolated values and creates a ViewTemplate. + +For each interpolated value: 1. Arrow functions (e.g., `x => x.name`) → wrapped in a one-way HTMLBindingDirective 2. Binding instances → wrapped in an HTMLBindingDirective 3. HTMLDirective instances → used as-is 4. Static values (strings, numbers) → wrapped in a one-time HTMLBindingDirective + +Each directive's createHTML() is called with an `add` callback that registers the factory in the factories record under a unique ID and returns that ID. The directive inserts a placeholder marker (e.g., `fast-abc123{id}abc123`) into the HTML string so the compiler can later find and associate it with the factory. + +Aspect detection happens here too: the `lastAttributeNameRegex` checks whether the placeholder appears inside an attribute value, and if so, assignAspect() sets the correct DOMAspect (attribute, property, event, etc.) based on the attribute name prefix. **Signature:** diff --git a/sites/website/src/docs/2.x/api/fast-element.viewtemplate.md b/sites/website/src/docs/2.x/api/fast-element.viewtemplate.md index 0361cc8b179..e4a8b6275ed 100644 --- a/sites/website/src/docs/2.x/api/fast-element.viewtemplate.md +++ b/sites/website/src/docs/2.x/api/fast-element.viewtemplate.md @@ -169,7 +169,13 @@ Creates an HTMLView instance based on this template definition. -Creates a template based on a set of static strings and dynamic values. +Processes the tagged template literal's static strings and interpolated values and creates a ViewTemplate. + +For each interpolated value: 1. Arrow functions (e.g., `x => x.name`) → wrapped in a one-way HTMLBindingDirective 2. Binding instances → wrapped in an HTMLBindingDirective 3. HTMLDirective instances → used as-is 4. Static values (strings, numbers) → wrapped in a one-time HTMLBindingDirective + +Each directive's createHTML() is called with an `add` callback that registers the factory in the factories record under a unique ID and returns that ID. The directive inserts a placeholder marker (e.g., `fast-abc123{id}abc123`) into the HTML string so the compiler can later find and associate it with the factory. + +Aspect detection happens here too: the `lastAttributeNameRegex` checks whether the placeholder appears inside an attribute value, and if so, assignAspect() sets the correct DOMAspect (attribute, property, event, etc.) based on the attribute name prefix.