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
10 changes: 7 additions & 3 deletions packages/core/src/Signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ export class Signal<T extends any[] = []> {
on(fn: (...args: T) => void, target?: any): void;
/**
* Add a structured binding listener. Structured bindings support clone remapping.
* The target method will be invoked as `method(...signalArgs, ...args)` —
* runtime signal arguments come first, bound arguments are appended.
* @param target - The target component
* @param methodName - The method name to invoke on the target
* @param args - Pre-resolved arguments
* @param args - Pre-resolved arguments appended after the runtime signal arguments
*/
on(target: Component, methodName: string, ...args: any[]): void;
on(fnOrTarget: ((...args: T) => void) | Component, targetOrMethodName?: any, ...args: any[]): void {
Expand All @@ -37,9 +39,11 @@ export class Signal<T extends any[] = []> {
once(fn: (...args: T) => void, target?: any): void;
/**
* Add a one-time structured binding listener.
* The target method will be invoked as `method(...signalArgs, ...args)` —
* runtime signal arguments come first, bound arguments are appended.
* @param target - The target component
* @param methodName - The method name to invoke on the target
* @param args - Pre-resolved arguments
* @param args - Pre-resolved arguments appended after the runtime signal arguments
*/
once(target: Component, methodName: string, ...args: any[]): void;
once(fnOrTarget: ((...args: T) => void) | Component, targetOrMethodName?: any, ...args: any[]): void {
Expand Down Expand Up @@ -171,7 +175,7 @@ export class Signal<T extends any[] = []> {
const methodName = targetOrMethodName as string;
const fn =
args.length > 0
? (...signalArgs: any[]) => (target as any)[methodName](...args, ...signalArgs)
? (...signalArgs: any[]) => (target as any)[methodName](...signalArgs, ...args)
: (...signalArgs: any[]) => (target as any)[methodName](...signalArgs);
this._listeners.push({
fn: fn as (...args: T) => void,
Expand Down
56 changes: 56 additions & 0 deletions tests/src/core/Signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
class TestHandler extends Script {
callCount = 0;
lastPrefix = "";
lastArgs: any[] = [];

handleClick() {
this.callCount++;
Expand All @@ -14,6 +15,11 @@ class TestHandler extends Script {
this.callCount++;
this.lastPrefix = prefix;
}

handleWithArgs(...args: any[]) {
this.callCount++;
this.lastArgs = args;
}
}

describe("Signal", async () => {
Expand Down Expand Up @@ -274,6 +280,56 @@ describe("Signal", async () => {
expect(fn2).toHaveBeenCalledOnce();
});

// ---- Structured binding arg order (runtime first, bound last) ----

it("structured binding: runtime args precede bound args", () => {
const signal = new Signal<[string, number]>();
const entity = root.createChild("sb-order");
const handler = entity.addComponent(TestHandler);

signal.on(handler, "handleWithArgs", "boundA", "boundB");
signal.invoke("event", 42);
expect(handler.lastArgs).toEqual(["event", 42, "boundA", "boundB"]);

entity.destroy();
});

it("structured binding: event object stays at index 0 regardless of bound args count", () => {
const event = { type: "click", x: 10, y: 20 };
const signal = new Signal<[typeof event]>();
const e1 = root.createChild("sb-evt-1");
const e2 = root.createChild("sb-evt-2");
const h1 = e1.addComponent(TestHandler);
const h2 = e2.addComponent(TestHandler);

signal.on(h1, "handleWithArgs");
signal.on(h2, "handleWithArgs", "ctx", 1, true);

signal.invoke(event);

expect(h1.lastArgs[0]).toBe(event);
expect(h2.lastArgs[0]).toBe(event);
expect(h2.lastArgs).toEqual([event, "ctx", 1, true]);

e1.destroy();
e2.destroy();
});

it("structured binding once: runtime + bound args order preserved", () => {
const signal = new Signal<[number]>();
const entity = root.createChild("sb-once-args");
const handler = entity.addComponent(TestHandler);

signal.once(handler, "handleWithArgs", "bound");
signal.invoke(99);
signal.invoke(100);

expect(handler.callCount).toBe(1);
expect(handler.lastArgs).toEqual([99, "bound"]);

entity.destroy();
});

// ---- Clone ----

it("clone: closure-based listeners are not cloned", () => {
Expand Down
Loading