Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "fix(fast-html): only process single-brace bindings for event aspect",
"packageName": "@microsoft/fast-html",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
56 changes: 56 additions & 0 deletions packages/fast-html/src/components/utilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,62 @@ test.describe("utilities", async () => {
expect((templateResult as AttributeDataBindingBehaviorConfig)?.closingStartIndex).toEqual(29);
expect((templateResult as AttributeDataBindingBehaviorConfig)?.closingEndIndex).toEqual(30);
});

test("skip single-brace content bindings (e.g. CSS braces)", async () => {
const innerHTML = "<style>.foo { color: red }</style>";
const templateResult = getNextBehavior(innerHTML);

expect(templateResult).toBeNull();
});

test("skip single-brace non-event, non-property attribute bindings", async () => {
const innerHTML1 = "<input type=\"{type}\">";
const templateResult1 = getNextBehavior(innerHTML1);

expect(templateResult1).toBeNull();

const innerHTML2 = "<input ?disabled=\"{disabled}\">";
const templateResult2 = getNextBehavior(innerHTML2);

expect(templateResult2).toBeNull();
});

test("find double-brace binding after skipped single-brace content", async () => {
const innerHTML = "<style>.foo { color: red } .bar { color: blue }</style><span>{{name}}</span>";
const templateResult = getNextBehavior(innerHTML);

expect(templateResult?.type).toEqual("dataBinding");
expect((templateResult as ContentDataBindingBehaviorConfig)?.subtype).toEqual("content");
expect((templateResult as ContentDataBindingBehaviorConfig)?.bindingType).toEqual("default");
expect((templateResult as ContentDataBindingBehaviorConfig)?.openingStartIndex).toEqual(61);
expect((templateResult as ContentDataBindingBehaviorConfig)?.closingStartIndex).toEqual(67);
});

test("find event binding after skipped single-brace content", async () => {
const innerHTML = "<style>.foo { color: red }</style><button @click=\"{handler()}\">";
const templateResult = getNextBehavior(innerHTML);

expect(templateResult?.type).toEqual("dataBinding");
expect((templateResult as AttributeDataBindingBehaviorConfig)?.subtype).toEqual("attribute");
expect((templateResult as AttributeDataBindingBehaviorConfig)?.aspect).toEqual("@");
expect((templateResult as AttributeDataBindingBehaviorConfig)?.bindingType).toEqual("client");
});

test("find property binding after skipped single-brace content", async () => {
const innerHTML = "<style>.foo { color: red } .bar { color: blue }</style><button :value=\"{someValue}\">";
const templateResult = getNextBehavior(innerHTML);

expect(templateResult?.type).toEqual("dataBinding");
expect((templateResult as AttributeDataBindingBehaviorConfig)?.subtype).toEqual("attribute");
expect((templateResult as AttributeDataBindingBehaviorConfig)?.aspect).toEqual(":");
expect((templateResult as AttributeDataBindingBehaviorConfig)?.bindingType).toEqual("client");
});

test("ensure if there are expected missing {} this does not cause parsing issues", async () => {
const innerHTML = "<f-when value=\"missing\">";

expect(getNextBehavior(innerHTML)).toBeTruthy();
});
});

test.describe("templates", async () => {
Expand Down
88 changes: 79 additions & 9 deletions packages/fast-html/src/components/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,20 +401,90 @@ function getNextDataBindingBehavior(innerHTML: string): DataBindingBehaviorConfi
* @returns DataBindingBehaviorConfig | DirectiveBehaviorConfig | null - A configuration object or null
*/
export function getNextBehavior(
Comment thread
janechu marked this conversation as resolved.
innerHTML: string
innerHTML: string,
offset: number = 0
): DataBindingBehaviorConfig | TemplateDirectiveBehaviorConfig | null {
const dataBindingOpen = innerHTML.indexOf(openClientSideBinding); // client side binding will capture all bindings starting with "{"
const directiveBindingOpen = innerHTML.indexOf(openTagStart);
// eslint-disable-next-line no-constant-condition
while (true) {
const currentSlice = innerHTML.slice(offset);
// client side binding will capture all bindings starting with "{"
const dataBindingOpen = currentSlice.indexOf(openClientSideBinding);
const directiveBindingOpen = currentSlice.indexOf(openTagStart);
const nextDataBindingBehavior = getNextDataBindingBehavior(currentSlice);

if (dataBindingOpen === -1 && directiveBindingOpen === -1) {
return null;
}

if (dataBindingOpen === -1 && directiveBindingOpen === -1) {
return null;
}
if (
dataBindingOpen !== -1 &&
nextDataBindingBehavior.bindingType === "client" &&
!isLegitimateClientSideBinding(nextDataBindingBehavior)
) {
offset = nextDataBindingBehavior.closingEndIndex + offset;
continue;
}

if (directiveBindingOpen !== -1 && dataBindingOpen > directiveBindingOpen) {
return getNextDirectiveBehavior(innerHTML);
if (
directiveBindingOpen !== -1 &&
(dataBindingOpen === -1 || dataBindingOpen > directiveBindingOpen)
) {
return offsetDirective(getNextDirectiveBehavior(currentSlice), offset);
}

return offsetDataBinding(nextDataBindingBehavior, offset);
}
}

return getNextDataBindingBehavior(innerHTML);
/**
* Apply an offset to a data binding
* @param config DataBindingBehaviorConfig
* @param offset number
* @returns DataBindingBehaviorConfig
*/
function offsetDataBinding(
config: DataBindingBehaviorConfig,
offset: number
): DataBindingBehaviorConfig {
config.openingStartIndex = config.openingStartIndex + offset;
config.openingEndIndex = config.openingEndIndex + offset;
config.closingStartIndex = config.closingStartIndex + offset;
config.closingEndIndex = config.closingEndIndex + offset;

return config;
}

/**
* Apply an offset to a directive
* @param config TemplateDirectiveBehaviorConfig
* @param offset number
* @returns TemplateDirectiveBehaviorConfig
*/
function offsetDirective(
config: TemplateDirectiveBehaviorConfig,
offset: number
): TemplateDirectiveBehaviorConfig {
config.openingTagStartIndex = config.openingTagStartIndex + offset;
config.openingTagEndIndex = config.openingTagEndIndex + offset;
config.closingTagStartIndex = config.closingTagStartIndex + offset;
config.closingTagEndIndex = config.closingTagEndIndex + offset;

return config;
}

/**
* Determine if this client side binding is legitimate.
* Single-brace (client) bindings are only valid for events, properties, and attribute directives.
* Checking for this prevents CSS/JS curly braces from being misinterpreted as bindings.
* @param result
* @returns
*/
function isLegitimateClientSideBinding(result: DataBindingBehaviorConfig): boolean {
return (
(result.subtype === "attribute" &&
(result.aspect === "@" || result.aspect === ":")) ||
result.subtype === "attributeDirective"
);
}

type AccessibleObject = { [key: string]: AccessibleObject };
Expand Down
12 changes: 6 additions & 6 deletions packages/fast-html/test/fixtures/host-bindings/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
</template>
</host-multi-element>
<f-template name="host-multi-element">
<template @click="{handleClick()}" ?disabled="{isDisabled}">
<template @click="{handleClick()}" ?disabled="{{isDisabled}}">
<span>{{text}}</span>
</template>
</f-template>
Expand Down Expand Up @@ -85,7 +85,7 @@
</host-multi-content-element>
<f-template name="host-multi-content-element">
<template @click="{handleClick()}">
<span first="{first}" second="{second}"></span>
<span first="{{first}}" second="{{second}}"></span>
</template>
</f-template>

Expand Down Expand Up @@ -123,7 +123,7 @@
</template>
</host-all-types-element>
<f-template name="host-all-types-element">
<template @click="{handleClick()}" ?disabled="{isDisabled}" :title="{hostTitle}" attr="{hostAttr}">
<template @click="{handleClick()}" ?disabled="{{isDisabled}}" :title="{hostTitle}" attr="{{hostAttr}}">
<span>{{text}}</span>
</template>
</f-template>
Expand All @@ -136,7 +136,7 @@
</template>
</host-perm-attr-first>
<f-template name="host-perm-attr-first">
<template attr="{hostAttr}" :title="{hostTitle}" ?disabled="{isDisabled}" @click="{handleClick()}">
<template attr="{{hostAttr}}" :title="{hostTitle}" ?disabled="{{isDisabled}}" @click="{handleClick()}">
<span>{{text}}</span>
</template>
</f-template>
Expand All @@ -149,7 +149,7 @@
</template>
</host-perm-bool-first>
<f-template name="host-perm-bool-first">
<template ?disabled="{isDisabled}" @click="{handleClick()}" attr="{hostAttr}" :title="{hostTitle}">
<template ?disabled="{{isDisabled}}" @click="{handleClick()}" attr="{{hostAttr}}" :title="{hostTitle}">
<span>{{text}}</span>
</template>
</f-template>
Expand All @@ -162,7 +162,7 @@
</template>
</host-perm-prop-first>
<f-template name="host-perm-prop-first">
<template :title="{hostTitle}" attr="{hostAttr}" @click="{handleClick()}" ?disabled="{isDisabled}">
<template :title="{hostTitle}" attr="{{hostAttr}}" @click="{handleClick()}" ?disabled="{{isDisabled}}">
<span>{{text}}</span>
</template>
</f-template>
Expand Down
4 changes: 2 additions & 2 deletions packages/fast-html/test/fixtures/observer-map/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,13 @@ <h4>Posts ({{user.posts.length}} posts)</h4>
Monthly: {{metrics.engagement.monthly}}
</div>
</div>
<f-when value="{a}">
<f-when value="{{a}}">
<span class="nested-define">{{a.b.c}}</span>
</f-when>
<button @click="{defineB()}">Define B</button>
<button @click="{updateC()}">Update C</button>
<br>
<f-when value="{x}">
<f-when value="{{x}}">
<span class="nested-define-2">{{x.y.z}}</span>
</f-when>
<button @click="{defineY()}">Define Y</button>
Expand Down
2 changes: 1 addition & 1 deletion packages/fast-html/test/fixtures/repeat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
<f-template name="test-element-no-item-repeat-binding">
<template>
<ul>
<f-repeat value="{item in list}">
<f-repeat value="{{item in list}}">
<li>{{item}}</li>
</f-repeat>
</ul>
Expand Down
Loading