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": "fix: clear event context when event handlers throw",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
4 changes: 4 additions & 0 deletions packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ This gives FAST automatic, fine-grained dependency tracking without explicit dec
| `listener` | Same as `oneWay` but attaches as a DOM event handler |

`normalizeBinding(value)` converts raw arrow functions or static values into a `Binding` object.
Event listener bindings set the current DOM event on `ExecutionContext` only while
the handler expression is evaluating. The event is cleared in a `finally` path even
when the handler throws; for completed evaluations, any result other than `true`
continues to call `preventDefault()` on the event.

---

Expand Down
8 changes: 4 additions & 4 deletions 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.37 KB | 22.91 KB | 20.32 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 All @@ -15,11 +15,11 @@ Bundle sizes for `@microsoft/fast-element` exports.
| slotted (@microsoft/fast-element/slotted.js) | 4.60 KB | 1.79 KB | 1.58 KB |
| volatile (@microsoft/fast-element/volatile.js) | 6.79 KB | 2.53 KB | 2.25 KB |
| when (@microsoft/fast-element/when.js) | 1.82 KB | 712 B | 565 B |
| html (@microsoft/fast-element/html.js) | 25.92 KB | 8.50 KB | 7.61 KB |
| html (@microsoft/fast-element/html.js) | 25.93 KB | 8.50 KB | 7.62 KB |
| 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 |
| enableHydration (@microsoft/fast-element/hydration.js) | 43.28 KB | 13.19 KB | 11.88 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 58.78 KB | 18.46 KB | 16.47 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 |
11 changes: 8 additions & 3 deletions packages/fast-element/docs/template-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,19 @@ sequenceDiagram
Directive->>Context: ExecutionContext.setEvent(event)
Directive->>Binding: evaluate(controller.source, controller.context)
Binding->>Source: Execute handler: (s, c) => s.handleClick(c.event)
Source-->>Binding: Return result
Binding-->>Directive: Return result
Source-->>Binding: Return result or throw
Binding-->>Directive: Return result or throw
Directive->>Context: ExecutionContext.setEvent(null) in finally
alt result !== true
Directive->>DOM: event.preventDefault()
end
Directive->>Context: ExecutionContext.setEvent(null)
```

`ExecutionContext.setEvent(null)` runs in a `finally` path, so thrown event
handlers cannot leak the event context into later handlers or evaluations. When
the handler completes normally, the existing convention remains: any return value
other than `true` prevents the event's default action.

### 7. Content Binding – Template Composition

When a binding expression returns a `ContentTemplate` (e.g., another `ViewTemplate`), the content update sink composes a child view into the DOM.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";

test.describe("HTMLBindingDirective", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});

test("clears the current event when the event handler throws", async ({ page }) => {
const {
defaultPrevented,
eventAvailableDuringHandler,
eventClearedAfterThrow,
threw,
} = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const {
HTMLBindingDirective,
HTMLDirective,
ExecutionContext,
Fake,
DOM,
nextId,
listener,
} = await import("/main.js");

class Model {
eventAvailableDuringHandler = false;

invokeAction(context: any) {
this.eventAvailableDuringHandler =
context.event === ExecutionContext.getEvent();
throw new Error("Event handler failed.");
}
}

const directive = new HTMLBindingDirective(
listener((x: Model, c: any) => x.invokeAction(c), {}),
);
HTMLDirective.assignAspect(directive, "@my-event");

const node = document.createElement("div");
directive.id = nextId();
directive.targetNodeId = "r";
directive.targetTagName = node.tagName ?? null;
directive.policy = DOM.policy;

const targets = { r: node };
const behavior = directive.createBehavior();
const controller = Fake.viewController(targets, behavior);
const model = new Model();
controller.bind(model);

const event = new CustomEvent("my-event", { cancelable: true });
Object.defineProperty(event, "currentTarget", { value: node });

let threw = false;

try {
directive.handleEvent(event);
} catch {
threw = true;
}

return {
defaultPrevented: event.defaultPrevented,
eventAvailableDuringHandler: model.eventAvailableDuringHandler,
eventClearedAfterThrow: ExecutionContext.getEvent() === null,
threw,
};
});

expect(threw).toBe(true);
expect(eventAvailableDuringHandler).toBe(true);
expect(eventClearedAfterThrow).toBe(true);
expect(defaultPrevented).toBe(false);
});
});
16 changes: 9 additions & 7 deletions packages/fast-element/src/templating/html-binding-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,20 +425,22 @@ export class HTMLBindingDirective
* Implements the EventListener interface. When a DOM event fires on the target
* element, this method retrieves the ViewController stored on the element,
* sets the event on the ExecutionContext so `c.event` is available to the
* binding expression, and evaluates the expression. If the expression returns
* binding expression, and clears it after evaluation. If the expression returns
* anything other than `true`, the event's default action is prevented.
* @internal
*/
handleEvent(event: Event): void {
const controller = event.currentTarget![this.data] as ViewController;

if (controller.isBound) {
ExecutionContext.setEvent(event);
const result = this.dataBinding.evaluate(
controller.source,
controller.context,
);
ExecutionContext.setEvent(null);
let result: any;

try {
ExecutionContext.setEvent(event);
result = this.dataBinding.evaluate(controller.source, controller.context);
} finally {
ExecutionContext.setEvent(null);
}

if (result !== true) {
event.preventDefault();
Expand Down