Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: allow declarativeTemplate callbacks to resolve templates from f-template strings",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
12 changes: 11 additions & 1 deletion packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ the imperative `html` API:
- The internal `<f-template>` publisher parses HTML and returns concrete
`ViewTemplate` instances through the registry-aware declarative template
bridge.
- `declarativeTemplate({ callback })` registers a transient string publisher for
the definition's name. The callback receives the element definition and a
`templateStringResolver()` function, so it can load template markup with
async flows such as `import()` or `fetch()`. The resolver accepts either a
string or `Promise<string>`, validates that the resolved string contains
exactly one `<f-template>`, preserves attributes on that element, and then
routes it through the same publisher path as a connected `<f-template>`. The
callback must return or await the resolver promise; if it completes
successfully without calling `templateStringResolver()`, template resolution
rejects with a diagnostic error instead of waiting indefinitely.
- `TemplateParser` lowers declarative syntax to the same `strings` / `values`
shape used by `ViewTemplate.create()`.
- `attributeMap()` and `observerMap()` are `FASTElementExtension` factories
Expand Down Expand Up @@ -577,7 +587,7 @@ src/
β”‚ └── di.ts # DI container, decorators, resolvers, Registration
β”œβ”€β”€ context.ts # Context, FASTContext, Context protocol
β”œβ”€β”€ declarative/
β”‚ β”œβ”€β”€ template.ts # declarativeTemplate() and internal f-template publisher
β”‚ β”œβ”€β”€ template.ts # declarativeTemplate(), string callback, internal f-template publisher
β”‚ β”œβ”€β”€ template-parser.ts # Declarative HTML parser β†’ ViewTemplate strings/values
β”‚ β”œβ”€β”€ schema.ts # Compatibility re-export for Schema
β”‚ β”œβ”€β”€ definition-options.ts # Compatibility re-export for schema transforms
Expand Down
34 changes: 34 additions & 0 deletions packages/fast-element/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,40 @@ MyElement.define({
before `define()` resolves. Consumers should not import or define the
`<f-template>` implementation directly.

When template markup needs to be loaded or generated by JavaScript, pass a
`callback` to `declarativeTemplate()`. The callback receives
`templateStringResolver`, can perform async work such as dynamic imports or
`fetch()`, and must return or await the resolver promise. The resolver accepts a
string or `Promise<string>` that contains exactly one `<f-template>` element. If
the callback completes successfully without calling the resolver, template
resolution rejects with a diagnostic error. The `<f-template>` can include
attributes such as `name`, and it must contain exactly one child `<template>`.

```ts
import { FASTElement } from "@microsoft/fast-element";
import { declarativeTemplate } from "@microsoft/fast-element/declarative.js";

class LazyElement extends FASTElement {}

LazyElement.define({
name: "lazy-element",
template: declarativeTemplate({
async callback({ templateStringResolver }) {
const response = await fetch("/templates/lazy-element.html");
await templateStringResolver(response.text());
},
}),
});
```

```html
<f-template name="lazy-element" data-source="fetch">
<template>
<p>{{message}}</p>
</template>
</f-template>
```

Declarative schema behavior is enabled with define extensions:

```ts
Expand Down
2 changes: 1 addition & 1 deletion packages/fast-element/SIZES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Bundle sizes for `@microsoft/fast-element` exports.
| repeat (@microsoft/fast-element/repeat.js) | 29.57 KB | 9.41 KB | 8.48 KB |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 43.27 KB | 13.19 KB | 11.88 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 58.77 KB | 18.45 KB | 16.46 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 60.33 KB | 18.92 KB | 16.84 KB |
| ArrayObserver (@microsoft/fast-element/array-observer.js) | 12.51 KB | 4.45 KB | 4.01 KB |
| observerMap (@microsoft/fast-element/observer-map.js) | 20.41 KB | 7.24 KB | 6.52 KB |
| attributeMap (@microsoft/fast-element/attribute-map.js) | 15.78 KB | 5.58 KB | 5.04 KB |
45 changes: 42 additions & 3 deletions packages/fast-element/docs/declarative-html.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ The declarative entrypoint in `@microsoft/fast-element` interprets FAST
declarative HTML syntax as a template for a FAST web component.

This document focuses on declarative-runtime implementation details:
template structure, prerendered markup requirements, lifecycle callbacks,
binding configuration, syntax, and integration testing.
template structure, async string template loading, prerendered markup
requirements, lifecycle callbacks, binding configuration, syntax, and
integration testing.

For package installation, using `declarativeTemplate()`, extension setup, and
the package-level hydration overview, see the
Expand Down Expand Up @@ -53,6 +54,44 @@ Example:
The legacy `defer-hydration` and `needs-hydration` attributes are no longer
required.

## Async Template Strings

Use `declarativeTemplate({ callback })` when the `<f-template>` markup should be
loaded by JavaScript rather than provided as a connected DOM element. The
callback receives the current element definition and a `templateStringResolver`.
It may return a promise, so template strings can come from dynamic imports,
`fetch()`, or another async source. The callback must return or await the
resolver promise. The resolver accepts a string or `Promise<string>`, and
template resolution rejects with a diagnostic error if the callback completes
successfully without calling it.

```typescript
import { FASTElement } from "@microsoft/fast-element";
import { declarativeTemplate } from "@microsoft/fast-element/declarative.js";

class LazyElement extends FASTElement {}

LazyElement.define({
name: "lazy-element",
template: declarativeTemplate({
async callback({ templateStringResolver }) {
const response = await fetch("/templates/lazy-element.html");
await templateStringResolver(response.text());
},
}),
});
```

The resolved string must contain exactly one `<f-template>` element, and that
`<f-template>` must contain exactly one child `<template>`. Attributes on the
`<f-template>`, including `name`, are preserved before the template is parsed.

```html
<f-template name="lazy-element" data-source="fetch">
<template>{{message}}</template>
</f-template>
```

## Non-browser HTML Rendering

One of the benefits of FAST declarative HTML templates is that the server can
Expand All @@ -69,7 +108,7 @@ hook into template processing and hydration. The callbacks are split by scope:

| Scope | API | Callbacks |
|---|---|---|
| Per element | `declarativeTemplate(callbacks)` | `elementDidRegister`, `templateWillUpdate`, `templateDidUpdate`, `elementDidDefine`, `elementWillHydrate`, `elementDidHydrate` |
| Per element | `declarativeTemplate(options)` | `callback`, `elementDidRegister`, `templateWillUpdate`, `templateDidUpdate`, `elementDidDefine`, `elementWillHydrate`, `elementDidHydrate` |
| Global hydration | `enableHydration(options)` | `hydrationStarted`, `hydrationComplete` |

Hydration is opt-in. Call `enableHydration()` before FAST elements connect when
Expand Down
20 changes: 20 additions & 0 deletions packages/fast-element/docs/declarative/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ export type ConstructibleStyleStrategy = {
// @public
export function declarativeTemplate(callbacks?: TemplateLifecycleCallbacks): FASTElementTemplateResolver;

// @public
export function declarativeTemplate(options?: DeclarativeTemplateOptions): FASTElementTemplateResolver;

// @public
export type DeclarativeTemplateCallback = (context: DeclarativeTemplateCallbackContext) => void | Promise<void>;

// @public
export interface DeclarativeTemplateCallbackContext {
readonly definition: FASTElementDefinition;
readonly templateStringResolver: DeclarativeTemplateStringResolver;
}

// @public
export interface DeclarativeTemplateOptions extends TemplateLifecycleCallbacks {
readonly callback?: DeclarativeTemplateCallback;
}

// @public
export type DeclarativeTemplateStringResolver = (templateString: string | Promise<string>) => Promise<void>;

// @public
export interface DefaultCachedPath extends CachedPathCommon {
// (undocumented)
Expand Down
4 changes: 4 additions & 0 deletions packages/fast-element/src/declarative/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export const debugMessages = {
"There can only be one <template> inside the <f-template>; remove any extra <template> elements and keep exactly one for ${name}.",
[2002 /* moreThanOneMatchingTemplateProvided */]:
'There can only be one connected <f-template name="${name}"> in a registry while resolving a declarative template; remove duplicate matches for ${name}.',
[2003 /* invalidTemplateString */]:
"Declarative template strings for ${name} must contain exactly one <f-template> element.",
[2004 /* templateStringResolverNotCalled */]:
"The declarative template callback for ${name} completed without calling templateStringResolver(). Return or await templateStringResolver(...) with an <f-template> string.",
};
8 changes: 7 additions & 1 deletion packages/fast-element/src/declarative/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,11 @@ export type {
ViewTemplate,
} from "../templating/template.js";
export type { ElementView, HTMLView } from "../templating/view.js";
export { declarativeTemplate } from "./template.js";
export {
type DeclarativeTemplateCallback,
type DeclarativeTemplateCallbackContext,
type DeclarativeTemplateOptions,
type DeclarativeTemplateStringResolver,
declarativeTemplate,
} from "./template.js";
export { type ResolvedStringsAndValues, TemplateParser } from "./template-parser.js";
2 changes: 2 additions & 0 deletions packages/fast-element/src/declarative/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ export const enum Message {
noTemplateProvided = 2000,
moreThanOneTemplateProvided = 2001,
moreThanOneMatchingTemplateProvided = 2002,
invalidTemplateString = 2003,
templateStringResolverNotCalled = 2004,
}
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,70 @@ test.describe("declarativeTemplate", () => {
expect(result.shadowText).toContain("reconnected");
});

test("rejects when callback completes without calling templateStringResolver", async ({
page,
}) => {
await page.goto("/");

const message = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const { FASTElement, declarativeTemplate, uniqueElementName } = await import(
"/declarative-main.js"
);

const elementName = uniqueElementName();

class TestElement extends FASTElement {}

try {
await TestElement.define({
name: elementName,
template: declarativeTemplate({
callback() {},
}),
});
return "";
} catch (error) {
return (error as Error).message;
}
});

expect(message).toContain("completed without calling templateStringResolver()");
});

test("propagates promise rejection from templateStringResolver", async ({ page }) => {
await page.goto("/");

const message = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const { FASTElement, declarativeTemplate, uniqueElementName } = await import(
"/declarative-main.js"
);

const elementName = uniqueElementName();

class TestElement extends FASTElement {}

try {
await TestElement.define({
name: elementName,
template: declarativeTemplate({
callback({ templateStringResolver }) {
return templateStringResolver(
Promise.reject(new Error("template load failed")),
);
},
}),
});
return "";
} catch (error) {
return (error as Error).message;
}
});

expect(message).toBe("template load failed");
});

test("rejects duplicate matching templates with a clear error", async ({ page }) => {
await page.goto("/");

Expand Down
Loading