diff --git a/packages/element-web-module-api/element-web-module-api.api.md b/packages/element-web-module-api/element-web-module-api.api.md index d0df58efb2..51eed63c63 100644 --- a/packages/element-web-module-api/element-web-module-api.api.md +++ b/packages/element-web-module-api/element-web-module-api.api.md @@ -110,14 +110,14 @@ originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) = // @public export interface DialogApiExtension { - openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props?: P): DialogHandle; + openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props: P): DialogHandle; } // @public export type DialogHandle = { finished: Promise<{ ok: boolean; - model: M; + model: M | null; }>; close(): void; }; @@ -130,7 +130,7 @@ export interface DialogOptions { // @public export type DialogProps = { onSubmit(model: M): void; - cancel(): void; + onCancel(): void; }; // @alpha @deprecated (undocumented) @@ -287,7 +287,6 @@ export interface Profile { // @public export interface ProfileApiExtension { - // Warning: (ae-forgotten-export) The symbol "Watchable" needs to be exported by the entry point index.d.ts readonly profile: Watchable; } @@ -318,6 +317,18 @@ export type Variables = { [key: string]: number | string | undefined; }; +// @public +export class Watchable { + constructor(currentValue: T); + // (undocumented) + unwatch(listener: (value: T) => void): void; + // (undocumented) + get value(): T; + set value(value: T); + // (undocumented) + watch(listener: (value: T) => void): void; +} + // @alpha @deprecated (undocumented) export interface WidgetPermissionsCustomisations { preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; diff --git a/packages/element-web-module-api/package.json b/packages/element-web-module-api/package.json index 2e08d65adc..1f8466cbba 100644 --- a/packages/element-web-module-api/package.json +++ b/packages/element-web-module-api/package.json @@ -43,7 +43,7 @@ "typescript": "^5.7.3", "vite": "^6.1.6", "vite-plugin-dts": "^4.5.0", - "vitest": "^3.0.5", + "vitest": "^3.2.4", "vitest-sonar-reporter": "^2.0.0" }, "peerDependencies": { diff --git a/packages/element-web-module-api/src/api/dialog.ts b/packages/element-web-module-api/src/api/dialog.ts index a11a25d432..2361d6dace 100644 --- a/packages/element-web-module-api/src/api/dialog.ts +++ b/packages/element-web-module-api/src/api/dialog.ts @@ -26,7 +26,7 @@ export type DialogHandle = { /** * Promise that resolves when the dialog is finished. */ - finished: Promise<{ ok: boolean; model: M }>; + finished: Promise<{ ok: boolean; model: M | null }>; /** * Method to close the dialog. */ @@ -46,7 +46,7 @@ export type DialogProps = { /** * Cancel the dialog programmatically. */ - cancel(): void; + onCancel(): void; }; /** @@ -60,9 +60,9 @@ export interface DialogApiExtension { * @param dialog - The body component to render in the dialog. This component should accept props of type `P`. * @param props - Additional props to pass to the body */ - openDialog( + openDialog( initialOptions: DialogOptions, dialog: ComponentType

>, - props?: P, + props: P, ): DialogHandle; } diff --git a/packages/element-web-module-api/src/api/watchable.test.ts b/packages/element-web-module-api/src/api/watchable.test.ts new file mode 100644 index 0000000000..c045286e2c --- /dev/null +++ b/packages/element-web-module-api/src/api/watchable.test.ts @@ -0,0 +1,58 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, test, vitest } from "vitest"; + +import { Watchable } from "./watchable"; + +test("initial value is set correctly", () => { + const watchable = new Watchable(42); + expect(watchable.value).toBe(42); +}); + +test("value can be updated", () => { + const watchable = new Watchable(100); + watchable.value = 200; + expect(watchable.value).toBe(200); +}); + +test("watchers are notified on value change", () => { + const watchable = new Watchable(1); + const listener = vitest.fn(); + + watchable.watch(listener); + watchable.value = 2; // This should trigger the listener + expect(listener).toHaveBeenCalledExactlyOnceWith(2); + + watchable.unwatch(listener); // Clean up after the test +}); + +test("watchers are not notified if value does not change", () => { + const watchable = new Watchable(10); + const listener = vitest.fn(); + + watchable.watch(listener); + watchable.value = 10; // This should not trigger the listener + expect(listener).not.toHaveBeenCalled(); + + watchable.unwatch(listener); // Clean up after the test +}); + +test("when value is an object, shallow comparison works", () => { + const watchable = new Watchable({ a: 1, b: 2 }); + const listener = vitest.fn(); + watchable.watch(listener); + + // Update with a new object that has the same properties + watchable.value = { a: 3, b: 2 }; // This should trigger the listener + expect(listener).toHaveBeenCalledExactlyOnceWith({ a: 3, b: 2 }); + listener.mockClear(); + watchable.value = { a: 3, b: 2 }; // This should not trigger the listener again + expect(listener).not.toHaveBeenCalled(); + + watchable.unwatch(listener); // Clean up after the test +}); diff --git a/packages/element-web-module-api/src/api/watchable.ts b/packages/element-web-module-api/src/api/watchable.ts index 7f74f775c1..4bd8d74239 100644 --- a/packages/element-web-module-api/src/api/watchable.ts +++ b/packages/element-web-module-api/src/api/watchable.ts @@ -7,8 +7,21 @@ Please see LICENSE files in the repository root for full details. type WatchFn = (value: T) => void; +function shallowCompare(obj1: T, obj2: T): boolean { + return ( + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every((key) => obj1[key as keyof T] === obj2[key as keyof T]) + ); +} + +function isObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + /** * Utility class to wrap a value and allow listeners to be notified when the value changes. + * If T is an object, it will use a shallow comparison to determine if the value has changed. + * @public */ export class Watchable { private readonly listeners = new Set>(); @@ -20,11 +33,17 @@ export class Watchable { } public set value(value: T) { - if (this.currentValue !== value) { - this.currentValue = value; - for (const listener of this.listeners) { - listener(this.currentValue); - } + // If the value hasn't changed, do nothing. + if (value === this.currentValue) { + return; + } + if (isObject(value) && isObject(this.currentValue) && shallowCompare(this.currentValue as object, value)) { + return; + } + + this.currentValue = value; + for (const listener of this.listeners) { + listener(this.currentValue); } } diff --git a/packages/element-web-module-api/src/index.ts b/packages/element-web-module-api/src/index.ts index 952480135f..196db8d9fc 100644 --- a/packages/element-web-module-api/src/index.ts +++ b/packages/element-web-module-api/src/index.ts @@ -17,3 +17,4 @@ export type * from "./api/auth"; export type * from "./api/dialog"; export type * from "./api/profile"; export type * from "./api/navigation"; +export * from "./api/watchable";