Skip to content
Merged
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
13 changes: 2 additions & 11 deletions src/view/directives/on-modifiers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { evaluateRaw } from '../evaluate';
import { runOnExpression } from './on-shared';
import type { BindingContext } from '../types';

const KEY_ALIASES: Record<string, string[]> = {
Expand Down Expand Up @@ -155,16 +155,7 @@ export const handleOnWithModifiers = (
removeListener();
}

const eventContext = { ...context, $event: event, $el: el };
const containsCall = expression.includes('(');
if (!containsCall) {
const result = evaluateRaw<unknown>(expression, eventContext);
if (typeof result === 'function') {
(result as (e: Event) => void)(event);
}
return;
}
evaluateRaw(expression, eventContext);
runOnExpression(expression, { ...context, $event: event, $el: el }, event);
};

el.addEventListener(eventName, handler, listenerOptions);
Expand Down
29 changes: 29 additions & 0 deletions src/view/directives/on-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { evaluateRaw } from '../evaluate';
import type { BindingContext } from '../types';

/**
* Evaluates a `bq-on` handler expression and, if it resolves to a function,
* invokes it with the event.
*
* Rather than guessing "bare reference vs. call" by string-scanning for `(`
* (which misfires on expressions like `items.find(x => x).handler` — a paren
* that is not the top-level call, causing the returned handler to never run),
* the expression is always evaluated. A function result is invoked with the
* event (so a bare `handler` or a resolved-to-function member chain fires);
* any other result means the expression itself was the side effect (e.g.
* `count.value++` or `handleClick($event)`).
*
* Note: `this` is unbound for a function resolved from a member chain — use an
* explicit call (`obj.method($event)`) when the receiver matters.
* @internal
*/
export const runOnExpression = (
expression: string,
eventContext: BindingContext,
event: Event
): void => {
const result = evaluateRaw<unknown>(expression, eventContext);
if (typeof result === 'function') {
(result as (e: Event) => void)(event);
}
};
28 changes: 2 additions & 26 deletions src/view/directives/on.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { evaluateRaw } from '../evaluate';
import { runOnExpression } from './on-shared';
import type { DirectiveHandler } from '../types';

/**
Expand All @@ -8,31 +8,7 @@ import type { DirectiveHandler } from '../types';
export const handleOn = (eventName: string): DirectiveHandler => {
return (el, expression, context, cleanups) => {
const handler = (event: Event) => {
// Add $event to context for expression evaluation
const eventContext = { ...context, $event: event, $el: el };

// Check if expression contains a function call (has parentheses)
// If not, it might be a plain function reference like "handleClick"
// Note: Method references like "handlers.onClick" will lose their receiver
// when auto-invoked. For methods, use explicit calls: "handlers.onClick($event)"
const containsCall = expression.includes('(');

if (!containsCall) {
// Evaluate the expression - if it returns a function, invoke it with $event
const result = evaluateRaw<unknown>(expression, eventContext);
if (typeof result === 'function') {
// Auto-invoke with event. Note: `this` will be undefined for method references.
// For proper method binding, use explicit syntax: "obj.method($event)"
result(event);
return;
}
// If not a function, the expression was already evaluated (e.g., "count.value++")
return;
}

// Otherwise evaluate as expression using evaluateRaw to allow signal mutations
// (e.g., "count.value++" or "handleClick($event)")
evaluateRaw(expression, eventContext);
runOnExpression(expression, { ...context, $event: event, $el: el }, event);
};

el.addEventListener(eventName, handler);
Expand Down
23 changes: 23 additions & 0 deletions tests/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,29 @@ describe('View', () => {
expect(count.value).toBe(6);
expect(span.textContent).toBe('6');
});

it('invokes a handler resolved from an expression containing inner parens (#180)', () => {
container.innerHTML = '<button bq-on:click="items.find(matcher).handler">Go</button>';
let called = false;
const items = [{ id: 1, handler: () => { called = true; } }];

view = mount(container, {
items,
matcher: (x: { id: number }) => x.id === 1,
});

container.querySelector('button')!.click();
expect(called).toBe(true);
});

it('invokes a bare handler reference with the event (#180)', () => {
container.innerHTML = '<button bq-on:click="onClick">Go</button>';
let receivedType = '';
view = mount(container, { onClick: (e: Event) => { receivedType = e.type; } });

container.querySelector('button')!.click();
expect(receivedType).toBe('click');
});
});

describe('bq-for', () => {
Expand Down