From 6728ba4569a43be2a002a9570a1f2780ed72595e Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 21 Apr 2026 17:31:35 +0800 Subject: [PATCH 1/2] fix(core): invoke structured Signal listeners with runtime args before bound args Previously `Signal._addListener` applied bound arguments first and runtime signal args last, producing `method(...boundArgs, ...signalArgs)`. This made the event object's position shift with the number of bound arguments and diverged from the DOM / Cocos `(event, customEventData)` convention. Flip the order so the listener method receives runtime args first and bound args last: `method(...signalArgs, ...boundArgs)`. The event object now always sits at index 0, making migrated Cocos scripts with `(event, customData)` signatures work without rewrites. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/Signal.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index a18b86cf5a..e337e03d9d 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -20,9 +20,11 @@ export class Signal { 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 { @@ -37,9 +39,11 @@ export class Signal { 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 { @@ -171,7 +175,7 @@ export class Signal { 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, From eb8ac58b803b96d5684044706e02800134673128 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 11 May 2026 18:09:06 +0800 Subject: [PATCH 2/2] test(core): cover Signal structured-binding listener arg order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three test cases verifying that runtime signal args are passed before bound args: - "runtime args precede bound args" — full ordering with two of each. - "event object stays at index 0 regardless of bound args count" — position invariant of the conventional event argument. - "once: runtime + bound args order preserved" — same guarantee for once-style listeners. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/src/core/Signal.test.ts | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/src/core/Signal.test.ts b/tests/src/core/Signal.test.ts index cbf399a731..82a4fef4f4 100644 --- a/tests/src/core/Signal.test.ts +++ b/tests/src/core/Signal.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; class TestHandler extends Script { callCount = 0; lastPrefix = ""; + lastArgs: any[] = []; handleClick() { this.callCount++; @@ -14,6 +15,11 @@ class TestHandler extends Script { this.callCount++; this.lastPrefix = prefix; } + + handleWithArgs(...args: any[]) { + this.callCount++; + this.lastArgs = args; + } } describe("Signal", async () => { @@ -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", () => {