From d19208bee3d34dea16122fbd4254bb8750f48c76 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Thu, 19 Feb 2026 17:10:05 +0100 Subject: [PATCH] resolve undefined this in onClick handler (#32576) (#32583) * resolve undefined this in onClick handler * add regression test for bound DisambiguatedProfileViewModel onClick * Update docs * add regression for sender profile click mention insertion * Eslint Fix (cherry picked from commit 134c7cde46447bf865fe1b3b136d2c1c4c6eb64f) Co-authored-by: Zack --- docs/MVVM.md | 14 ++++++++++ playwright/e2e/timeline/timeline.spec.ts | 28 +++++++++++++++++++ .../profile/DisambiguatedProfileViewModel.ts | 4 +-- .../DisambiguatedProfileViewModel-test.tsx | 16 ++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/MVVM.md b/docs/MVVM.md index d6f182bd2e..065a44e294 100644 --- a/docs/MVVM.md +++ b/docs/MVVM.md @@ -127,6 +127,20 @@ export class FooViewModel extends BaseViewModel implemen } ``` +#### Binding of View Model Actions: + +All view model actions must be defined as arrow functions to ensure they are bound to the class instance. + +Using standard class methods can result in `this` being undefined when the function is passed as a callback (e.g. to a React event handler), which may cause runtime errors. + +Correct pattern: + +```ts +public doSomething = (): void => { + ... +}; +``` + ### `useViewModel` hook Your view must call this hook with the view-model as argument. Think of this as your view subscribing to the view model.
diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index b4e927cdac..ad93a241ff 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -1008,6 +1008,34 @@ test.describe("Timeline", () => { await expect(page.getByRole("button", { name: "Show video" })).toBeVisible(); await expect(page.locator("video")).not.toBeVisible(); }); + + test("should insert a mention when clicking sender profile in timeline", async ({ + page, + app, + homeserver, + room, + }) => { + const senderDisplayName = "SenderBot"; + const messageFromSender = "message from sender"; + + const bot = new Bot(page, homeserver, { + displayName: senderDisplayName, + autoAcceptInvites: false, + }); + await bot.prepareClient(); + await app.client.inviteUser(room.roomId, bot.credentials.userId); + await bot.joinRoom(room.roomId); + await bot.sendMessage(room.roomId, messageFromSender); + + await app.viewRoomById(room.roomId); + + const senderMessageTile = getEventTilesWithBodies(page).filter({ hasText: messageFromSender }).first(); + await expect(senderMessageTile).toBeVisible(); + + await senderMessageTile.locator(".mx_DisambiguatedProfile").click(); + + await expect(app.getComposerField().getByText(senderDisplayName)).toBeVisible(); + }); }); test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => { diff --git a/src/viewmodels/profile/DisambiguatedProfileViewModel.ts b/src/viewmodels/profile/DisambiguatedProfileViewModel.ts index 807ae20522..49a2db4e29 100644 --- a/src/viewmodels/profile/DisambiguatedProfileViewModel.ts +++ b/src/viewmodels/profile/DisambiguatedProfileViewModel.ts @@ -141,7 +141,7 @@ export class DisambiguatedProfileViewModel this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props)); } - public onClick(evt: MouseEvent): void { + public onClick = (evt: MouseEvent): void => { this.props.onClick?.(evt); - } + }; } diff --git a/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx b/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx index 270f3e66b4..faf2edecfd 100644 --- a/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx +++ b/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx @@ -75,13 +75,27 @@ describe("DisambiguatedProfileViewModel", () => { const subscriber = jest.fn(); vm.subscribe(subscriber); - onClick({} as never); + vm.onClick?.({} as never); expect(onClick).toHaveBeenCalledTimes(1); expect(subscriber).not.toHaveBeenCalled(); expect(vm.getSnapshot()).toBe(prevSnapshot); }); + it("should keep onClick bound when extracted as a callback", () => { + const onClick = jest.fn(); + const vm = new DisambiguatedProfileViewModel({ + member, + fallbackName: "Fallback", + onClick, + }); + + const clickHandler = vm.onClick; + + expect(() => clickHandler?.({} as never)).not.toThrow(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + it("should emit snapshot update when fallbackName changes", () => { const vm = new DisambiguatedProfileViewModel({ member: null,