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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add strict, quote-aware event argument parsing with string, number, boolean and null literal support to declarative event bindings.",
"packageName": "@microsoft/fast-element",
"email": "144495202+AKnassa@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 @@ -19,7 +19,7 @@ Bundle sizes for `@microsoft/fast-element` exports.
| repeat (@microsoft/fast-element/repeat.js) | 31.80 KB | 10.00 KB | 9.03 KB |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 46.53 KB | 13.91 KB | 12.49 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.15 KB | 19.46 KB | 17.42 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 63.94 KB | 20.10 KB | 17.98 KB |
| ArrayObserver (@microsoft/fast-element/arrays.js) | 12.55 KB | 4.46 KB | 4.03 KB |
| observerMap (@microsoft/fast-element/observer-map.js) | 21.96 KB | 7.73 KB | 6.97 KB |
| attributeMap (@microsoft/fast-element/attribute-map.js) | 15.31 KB | 5.41 KB | 4.88 KB |
17 changes: 15 additions & 2 deletions packages/fast-element/docs/declarative/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ Event bindings must include the `()` as well as being preceeded by `@` in keepin
<button @click="{handleClick()}"></button>
```

You can pass the DOM event object, the execution context, or both as arguments. Any other argument is treated as a binding expression and resolved against the current data source.
You can pass the DOM event object, the execution context, literal values, or binding paths as arguments.

**`$e` — DOM event object (preferred):**
```html
Expand All @@ -308,11 +308,24 @@ You can pass the DOM event object, the execution context, or both as arguments.
<button @click="{handleClick($e, $c)}"></button>
```

**Arbitrary binding expressions** — any token that is not `$e` or `$c` is resolved as a binding path on the data source:
**Binding paths** — any unquoted token that is not `$e`, `$c` or a literal is resolved as a binding path on the data source. Inside an `<f-repeat>` the path resolves against the repeat scope:
```html
<button @click="{handleClick(user.id)}"></button>
```

**Literals** — quoted strings (single or double quotes), numbers, `true`, `false` and `null` are passed through as values:
```html
<f-repeat value="{{item in items}}">
<button @click="{$c.parent.selectItem(item.id, 'from-list', 1, true, null, $e)}">
{{item.name}}
</button>
</f-repeat>
```

String literals support the `\0`, `\b`, `\f`, `\n`, `\r`, `\t`, `\v`, `\\`, `\'` and `\"` escape sequences.

Argument lists are parsed strictly. Malformed handler syntax — a missing or unbalanced call, an invalid handler name, trailing text after the call, an unclosed string, an unknown escape sequence, a trailing comma, an empty argument slot, or an invalid path token — throws while the template is being parsed rather than being silently normalized.

Use `$e` for the DOM event object.

### Directives
Expand Down
18 changes: 6 additions & 12 deletions packages/fast-element/src/declarative/template-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
getExpressionChain,
getNextBehavior,
getRootPropertyName,
parseEventArgs,
parseEventHandler,
type TemplateDirectiveBehaviorConfig,
} from "./utilities.js";

Expand Down Expand Up @@ -318,22 +318,14 @@ export class TemplateParser {
behaviorConfig.openingEndIndex,
behaviorConfig.closingStartIndex,
);
const openingParenthesis = bindingHTML.indexOf("(");
const closingParenthesis = bindingHTML.indexOf(")");
const propName = innerHTML.slice(
behaviorConfig.openingEndIndex,
behaviorConfig.closingStartIndex -
(closingParenthesis - openingParenthesis) -
1,
);
const { name: propName, args: parsedArgs } = parseEventHandler(bindingHTML);
const type = "event";
rootPropertyName = getRootPropertyName(
rootPropertyName,
propName,
context.parentContext,
type,
);
const argsString = bindingHTML.slice(openingParenthesis + 1, closingParenthesis);
const previousString = strings.previousString;
const resolved = bindingResolver(
previousString,
Expand All @@ -353,8 +345,6 @@ export class TemplateParser {
}
: (x: any, _c: any) => x;

const parsedArgs = parseEventArgs(argsString);

const argResolvers = parsedArgs.map((parsedArg): ((x: any, c: any) => any) => {
switch (parsedArg.type) {
case "event":
Expand All @@ -372,6 +362,10 @@ export class TemplateParser {
context.parentContext,
context.level,
);
default: {
const { value } = parsedArg;
return () => value;
}
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getIndexOfNextMatchingTag,
getNextBehavior,
parseEventArgs,
parseEventHandler,
pathResolver,
type TemplateDirectiveBehaviorConfig,
transformInnerHTML,
Expand Down Expand Up @@ -975,5 +976,127 @@ test.describe("utilities", async () => {
{ type: "binding", rawArg: "foo" },
]);
});
test("should parse a single quoted string literal", async () => {
expect(parseEventArgs("'static'")).toEqual([
{ type: "string", value: "static" },
]);
});
test("should parse a double quoted string literal", async () => {
expect(parseEventArgs('"static"')).toEqual([
{ type: "string", value: "static" },
]);
});
test("should parse an empty string literal", async () => {
expect(parseEventArgs("''")).toEqual([{ type: "string", value: "" }]);
});
test("should treat a comma inside a string literal as content", async () => {
expect(parseEventArgs("'a,b'")).toEqual([{ type: "string", value: "a,b" }]);
});
test("should treat a parenthesis inside a string literal as content", async () => {
expect(parseEventArgs("'a)b'")).toEqual([{ type: "string", value: "a)b" }]);
});
test("should parse an escaped quote inside a string literal", async () => {
expect(parseEventArgs("'it\\'s'")).toEqual([
{ type: "string", value: "it's" },
]);
});
test("should decode escape sequences inside a string literal", async () => {
expect(parseEventArgs(String.raw`'a\nb\tc\rd\\e\'f\"g'`)).toEqual([
{ type: "string", value: "a\nb\tc\rd\\e'f\"g" },
]);
});
test("should throw for an unknown escape sequence", async () => {
expect(() => parseEventArgs(String.raw`'a\qb'`)).toThrow();
});
test("should parse number literals", async () => {
expect(parseEventArgs("1, -2, 1.5, 1e3")).toEqual([
{ type: "number", value: 1 },
{ type: "number", value: -2 },
{ type: "number", value: 1.5 },
{ type: "number", value: 1000 },
]);
});
test("should parse boolean literals", async () => {
expect(parseEventArgs("true, false")).toEqual([
{ type: "boolean", value: true },
{ type: "boolean", value: false },
]);
});
test("should parse a null literal", async () => {
expect(parseEventArgs("null")).toEqual([{ type: "null", value: null }]);
});
test("should parse a mixed list of literals, paths and accessors", async () => {
expect(parseEventArgs("item.id, 'static', 1, true, null, $e")).toEqual([
{ type: "binding", rawArg: "item.id" },
{ type: "string", value: "static" },
{ type: "number", value: 1 },
{ type: "boolean", value: true },
{ type: "null", value: null },
{ type: "event" },
]);
});
test("should tolerate surrounding whitespace", async () => {
expect(parseEventArgs(" $e , 'a b' ")).toEqual([
{ type: "event" },
{ type: "string", value: "a b" },
]);
});
test("should throw for a trailing comma", async () => {
expect(() => parseEventArgs("$e,")).toThrow();
});
test("should throw for an empty argument slot", async () => {
expect(() => parseEventArgs("$e,,$c")).toThrow();
});
test("should throw for an unclosed string literal", async () => {
expect(() => parseEventArgs("'unclosed")).toThrow();
});
test("should throw for text after a string literal", async () => {
expect(() => parseEventArgs("'a'b")).toThrow();
});
test("should throw for an invalid path token", async () => {
expect(() => parseEventArgs("foo bar")).toThrow();
});
test("should throw for a malformed number", async () => {
expect(() => parseEventArgs("1abc")).toThrow();
});
});

test.describe("parseEventHandler", async () => {
test("should parse a handler without arguments", async () => {
expect(parseEventHandler("handleClick()")).toEqual({
name: "handleClick",
args: [],
});
});
test("should parse a context path handler with arguments", async () => {
expect(
parseEventHandler("$c.parent.selectItem(item.id, 'from-list', $e)"),
).toEqual({
name: "$c.parent.selectItem",
args: [
{ type: "binding", rawArg: "item.id" },
{ type: "string", value: "from-list" },
{ type: "event" },
],
});
});
test("should find the closing parenthesis outside of a string literal", async () => {
expect(parseEventHandler("handleClick('a)b', $e)")).toEqual({
name: "handleClick",
args: [{ type: "string", value: "a)b" }, { type: "event" }],
});
});
test("should throw when the function call is missing", async () => {
expect(() => parseEventHandler("handleClick")).toThrow();
});
test("should throw when the closing parenthesis is missing", async () => {
expect(() => parseEventHandler("handleClick($e")).toThrow();
});
test("should throw when there is text after the function call", async () => {
expect(() => parseEventHandler("handleClick() junk")).toThrow();
});
test("should throw for an invalid handler name", async () => {
expect(() => parseEventHandler("handle-click()")).toThrow();
});
});
});
Loading