Skip to content
Open
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
10 changes: 10 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
"!package-lock.json"
]
},
"overrides": [
{
"includes": ["**/test/declarative/fixtures/**/*.html"],
"html": {
"formatter": {
"enabled": false
}
}
}
],
"html": {
"formatter": {
"enabled": true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "Allow duplicate f-template names to keep the first template assignment",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
4 changes: 3 additions & 1 deletion packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,9 @@ the imperative `html` API:

- The internal `<f-template>` publisher parses HTML and returns concrete
`ViewTemplate` instances through the registry-aware declarative template
bridge.
bridge. If duplicate connected publishers share a name, the first connected
publisher supplies the definition template and later duplicates do not
reassign it.
- `TemplateParser` lowers declarative syntax to the same `strings` / `values`
shape used by `ViewTemplate.create()`.
- `attributeMap()` and `observerMap()` are `FASTElementExtension` factories
Expand Down
6 changes: 4 additions & 2 deletions packages/fast-element/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,10 @@ MyElement.define({
`declarativeTemplate()` automatically defines FAST's internal native
`<f-template>` publisher in the relevant registry, resolves the matching
`<f-template name="my-element">`, and keeps the definition template concrete
before `define()` resolves. Consumers should not import or define the
`<f-template>` implementation directly.
before `define()` resolves. If multiple matching `<f-template>` elements are
connected, the first connected element supplies the template and later duplicates
do not reassign it. Consumers should not import or define the `<f-template>`
implementation directly.

Declarative schema behavior is enabled with define extensions:

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) | 58.39 KB | 18.35 KB | 16.38 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 |
5 changes: 5 additions & 0 deletions packages/fast-element/docs/declarative-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ When connected to the DOM it:
4. Runs definition-scoped schema transforms, such as `attributeMap()` and
`observerMap()`, before returning the concrete `ViewTemplate`.

When multiple connected `<f-template>` publishers share the same `name`, the
bridge resolves pending definitions from the first connected publisher and keeps
the resolved template stable. Later duplicate publishers do not reassign the
definition template.

### `TemplateParser` β€” declarative HTML parser

A standalone class that converts declarative HTML template markup into the
Expand Down
2 changes: 0 additions & 2 deletions packages/fast-element/src/declarative/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,4 @@ export const debugMessages = {
"The first child of the <f-template> must be a <template>, this is missing from ${name}.",
[2001 /* moreThanOneTemplateProvided */]:
"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}.',
};
1 change: 0 additions & 1 deletion packages/fast-element/src/declarative/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@
export const enum Message {
noTemplateProvided = 2000,
moreThanOneTemplateProvided = 2001,
moreThanOneMatchingTemplateProvided = 2002,
}
107 changes: 97 additions & 10 deletions packages/fast-element/src/declarative/template-bridge.pw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ test.describe("DeclarativeTemplateBridge", () => {
expect(await requestB).toBe(templateB);
});

test("keeps the first publisher when duplicate publishers share a name", async () => {
const bridge = new DeclarativeTemplateBridge();
const registry = {
define() {},
get() {
return void 0;
},
} as unknown as CustomElementRegistry;
const firstTemplate = { id: "first" } as any;
const secondTemplate = { id: "second" } as any;

const request = bridge.requestTemplate({
name: "test-element",
registry,
} as any);

bridge.registerPublisher(registry, "test-element", {
publishTemplate: async () => firstTemplate,
});
bridge.registerPublisher(registry, "test-element", {
publishTemplate: async () => secondTemplate,
});

expect(await request).toBe(firstTemplate);
});

test("requeues active requests when a publisher disconnects", async () => {
const bridge = new DeclarativeTemplateBridge();
const registry = {
Expand Down Expand Up @@ -656,35 +682,96 @@ test.describe("declarativeTemplate", () => {
expect(result.shadowText).toContain("reconnected");
});

test("rejects duplicate matching templates with a clear error", async ({ page }) => {
test("does not reassign a resolved template for duplicate f-template names", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", error => {
pageErrors.push(error.message);
});

await page.goto("/");

const message = await page.evaluate(async () => {
const result = await page.evaluate(async () => {
const errors: string[] = [];
window.addEventListener("error", event => {
errors.push(event.message);
});
window.addEventListener("unhandledrejection", event => {
errors.push(event.reason?.message ?? String(event.reason));
});

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

const elementName = uniqueElementName();

document.body.insertAdjacentHTML(
"beforeend",
`<f-template name="${elementName}"><template><span>first</span></template></f-template><f-template name="${elementName}"><template><span>second</span></template></f-template>`,
`<f-template name="${elementName}"><template><span class="label">first {{label}}</span></template></f-template><f-template name="${elementName}"><template><span class="label">second {{label}}</span></template></f-template>`,
);

class TestElement extends FASTElement {}
class TestElement extends FASTElement {
public label = "initial";
}

let defineError = "";

try {
await TestElement.define({
name: elementName,
template: declarativeTemplate(),
});
return "";
} catch (error) {
return (error as Error).message;
defineError = (error as Error).message;
return {
defineError,
errors,
firstText: "",
secondText: "",
templateStable: false,
};
}

const definition = FASTElementDefinition.getByType(TestElement) as any;
const resolvedTemplate = definition.template;
const firstElement = document.createElement(elementName) as any;
document.body.appendChild(firstElement);
await new Promise(resolve => requestAnimationFrame(resolve));

const duplicateTemplate = document.createElement("f-template");
duplicateTemplate.setAttribute("name", elementName);
duplicateTemplate.innerHTML =
'<template><span class="label">third {{label}}</span></template>';
document.body.appendChild(duplicateTemplate);
await new Promise(resolve => requestAnimationFrame(resolve));

const secondElement = document.createElement(elementName) as any;
secondElement.label = "later";
document.body.appendChild(secondElement);
await new Promise(resolve => requestAnimationFrame(resolve));

return {
defineError,
errors,
firstText:
firstElement.shadowRoot?.querySelector(".label")?.textContent ?? "",
secondText:
secondElement.shadowRoot?.querySelector(".label")?.textContent ?? "",
templateStable: definition.template === resolvedTemplate,
};
});

expect(message).toContain("There can only be one connected <f-template");
expect(result.defineError).toBe("");
expect(result.errors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(result.templateStable).toBe(true);
expect(result.firstText).toBe("first initial");
expect(result.secondText).toBe("first later");
});
});
22 changes: 4 additions & 18 deletions packages/fast-element/src/declarative/template-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { FASTElementDefinition } from "../components/fast-definitions.js";
import { FAST } from "../platform.js";
import type { ElementViewTemplate } from "../templating/template.js";
import { Message } from "./interfaces.js";

/**
* Publishes a concrete template for a definition.
Expand Down Expand Up @@ -141,26 +139,14 @@ export class DeclarativeTemplateBridge {
return;
}

const publishers = [...bucket.publishers];

if (publishers.length > 1) {
const error = FAST.error(Message.moreThanOneMatchingTemplateProvided, {
name,
});

for (const request of [...bucket.requests]) {
this.rejectRequest(registry, name, bucket, request, error);
}
// Set iteration preserves insertion order, so duplicate publishers leave
// the first connected publisher responsible for pending requests.
const publisher = bucket.publishers.values().next().value;

if (publisher === undefined) {
return;
}

if (publishers.length === 0) {
return;
}

const [publisher] = publishers;

for (const request of bucket.requests) {
if (request.settled) {
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ Fixtures for complex scenarios that may involve multiple features interacting to

| Fixture | Description |
|---|---|
| `duplicate-template-names` | Duplicate connected `<f-template>` publishers with the same `name` attribute keep the first template assignment for a simple bound element. |
| `nested-elements` | Nested custom elements with state propagation through shadow boundaries, event handling inside `f-repeat` with `$c.parent` context access, and `f-when` conditions within repeated content. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";

test.describe("duplicate f-template names", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
(window as any).__duplicateTemplateErrors = [];
window.addEventListener("error", event => {
(window as any).__duplicateTemplateErrors.push(event.message);
});
window.addEventListener("unhandledrejection", event => {
event.preventDefault();
(window as any).__duplicateTemplateErrors.push(
event.reason?.message ?? String(event.reason),
);
});
});
});

test("keeps the first template assignment without errors", async ({ page }) => {
const hydrationCompleted = page.waitForFunction(
() => (window as any).hydrationCompleted === true,
);
await page.goto("/fixtures/scenarios/duplicate-template-names/");
await hydrationCompleted;

const customElement = page.locator("duplicate-template-element");

await expect(customElement).toHaveText("initial");

await page.evaluate(() => {
document
.querySelector("duplicate-template-element")
?.setAttribute("label", "updated");
});

await expect(customElement).toHaveText("updated");

const result = await page.evaluate(() => ({
errors: (window as any).__duplicateTemplateErrors,
status: (window as any).__duplicateTemplateStatus,
}));

expect(result.errors).toEqual([]);
expect(result.status).toEqual({
templateDidUpdateCount: 1,
templateWillUpdateCount: 1,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<duplicate-template-element label="{{label}}"></duplicate-template-element>
<script type="module" src="./main.ts"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"entry": "entry.html",
"state": "state.json",
"output": "index.html",
"templates": "templates.html"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<duplicate-template-element label="initial"><template shadowrootmode="open" shadowroot="open"><span><!--fe:b-->initial<!--fe:/b--></span></template></duplicate-template-element>
<f-template name="duplicate-template-element">
<template><span>{{label}}</span></template>
</f-template>
<f-template name="duplicate-template-element">
<template><span>{{label}}</span></template>
</f-template>

<script type="module" src="./main.ts"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { attr } from "@microsoft/fast-element/attr.js";
import { declarativeTemplate } from "@microsoft/fast-element/declarative.js";
import { FASTElement } from "@microsoft/fast-element/fast-element.js";
import { enableHydration } from "@microsoft/fast-element/hydration.js";

const status = {
templateDidUpdateCount: 0,
templateWillUpdateCount: 0,
};

(window as any).__duplicateTemplateStatus = status;

class DuplicateTemplateElement extends FASTElement {
@attr
public label: string = "";
}

DuplicateTemplateElement.define({
name: "duplicate-template-element",
template: declarativeTemplate({
templateDidUpdate() {
status.templateDidUpdateCount++;
},
templateWillUpdate() {
status.templateWillUpdateCount++;
},
}),
});

enableHydration({
hydrationComplete() {
(window as any).hydrationCompleted = true;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"label": "initial"
}
Loading
Loading