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": "Avoid mutating render template bindings",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
2 changes: 1 addition & 1 deletion packages/fast-element/SIZES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Bundle sizes for `@microsoft/fast-element` exports.

| Export | Minified | Gzip | Brotli |
|--------|----------|------|--------|
| CDN Rollup Bundle | 76.36 KB | 22.91 KB | 20.35 KB |
| CDN Rollup Bundle | 76.61 KB | 23.01 KB | 20.39 KB |
| FASTElement (@microsoft/fast-element/fast-element.js) | 23.73 KB | 7.36 KB | 6.63 KB |
| Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 288 B |
| Observable (@microsoft/fast-element/observable.js) | 6.70 KB | 2.50 KB | 2.22 KB |
Expand Down
71 changes: 71 additions & 0 deletions packages/fast-element/src/templating/render-binding.pw.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect, test } from "@playwright/test";

test.describe("The render template binding", () => {
test("does not mutate a provided template binding so it can be reused", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const { render, RenderInstruction, html, oneWay, Fake } = await import(
"/main.js"
);

const childEditTemplate = html`
<p>Child Edit Template</p>
`;
const parentEditTemplate = html`
<p>Parent Edit Template</p>
`;

class TestChild {
name = "FAST";
}

class TestParent {
name = "FAST";
}

RenderInstruction.register({
type: TestChild,
template: childEditTemplate,
name: "edit",
});

RenderInstruction.register({
type: TestParent,
template: parentEditTemplate,
name: "edit",
});

const source = {
child: new TestChild(),
parent: new TestParent(),
viewName: "edit",
};
const context = Fake.executionContext();
const templateBinding = oneWay((x: any) => x.viewName);
const originalEvaluate = templateBinding.evaluate;
const childDirective = render((x: any) => x.child, templateBinding);
const parentDirective = render((x: any) => x.parent, templateBinding);

return {
bindingEvaluateUnchanged: templateBinding.evaluate === originalEvaluate,
bindingValueUnchanged:
templateBinding.evaluate(source, context) === "edit",
childTemplate:
childDirective.templateBinding.evaluate(source, context) ===
childEditTemplate,
parentTemplate:
parentDirective.templateBinding.evaluate(source, context) ===
parentEditTemplate,
};
});

expect(result.bindingEvaluateUnchanged).toBe(true);
expect(result.bindingValueUnchanged).toBe(true);
expect(result.childTemplate).toBe(true);
expect(result.parentTemplate).toBe(true);
});
});
78 changes: 60 additions & 18 deletions packages/fast-element/src/templating/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type ViewBehaviorFactory,
type ViewController,
} from "./html-directive.js";
import { HydrationStage } from "./hydration-view.js";
import { Markup } from "./markup.js";
import {
type CaptureType,
Expand All @@ -29,7 +30,6 @@ import {
type TemplateValue,
ViewTemplate,
} from "./template.js";
import { HydrationStage } from "./hydration-view.js";

type ComposableView = ContentView & {
isComposed?: boolean;
Expand Down Expand Up @@ -368,6 +368,8 @@ const nullTemplate = html`
&nbsp;
`;

type TemplateBindingValue = ContentTemplate | string | Node;

function instructionToTemplate(def: RenderInstruction | undefined) {
if (def === void 0) {
return nullTemplate;
Expand All @@ -376,6 +378,62 @@ function instructionToTemplate(def: RenderInstruction | undefined) {
return def.template;
}

function resolveTemplateBindingValue<TSource = any, TParent = any>(
result: TemplateBindingValue,
dataBinding: Binding<TSource>,
source: TSource,
context: ExecutionContext<TParent>,
): ContentTemplate {
if (isString(result)) {
return instructionToTemplate(
getForInstance(dataBinding.evaluate(source, context), result),
);
}

if (result instanceof Node) {
return (result as any).$fastTemplate ?? new NodeTemplate(result);
}

return result;
}

function adaptTemplateBinding<TSource = any, TParent = any>(
binding: Binding<TSource, TemplateBindingValue, TParent>,
dataBinding: Binding<TSource>,
): Binding<TSource, ContentTemplate, TParent> {
const evaluateTemplate = binding.evaluate;
const adapter = Object.create(Object.getPrototypeOf(binding)) as Binding<
TSource,
ContentTemplate,
TParent
>;

for (const propertyName of Object.getOwnPropertyNames(binding)) {
if (propertyName !== "evaluate") {
Object.defineProperty(
adapter,
propertyName,
Object.getOwnPropertyDescriptor(binding, propertyName)!,
);
}
}

Object.defineProperty(adapter, "evaluate", {
configurable: true,
enumerable: true,
value: (source: TSource, context: ExecutionContext<TParent>) =>
resolveTemplateBindingValue(
evaluateTemplate(source, context),
dataBinding,
source,
context,
),
writable: true,
});

return adapter;
}

function createElementTemplate<TSource = any, TParent = any>(
tagName: string,
options?: ElementCreateOptions,
Expand Down Expand Up @@ -731,23 +789,7 @@ export function render<TSource = any, TItem = any, TParent = any>(
return instructionToTemplate(getForInstance(data, template));
});
} else if (template instanceof Binding) {
const evaluateTemplate = template.evaluate;

template.evaluate = (s: any, c: ExecutionContext) => {
let result = evaluateTemplate(s, c);

if (isString(result)) {
result = instructionToTemplate(
getForInstance(dataBinding.evaluate(s, c), result),
);
} else if (result instanceof Node) {
result = (result as any).$fastTemplate ?? new NodeTemplate(result);
}

return result;
};

templateBinding = template as any;
templateBinding = adaptTemplateBinding(template, dataBinding);
} else {
templateBinding = oneTime((s: any, c: ExecutionContext) => template);
}
Expand Down