Compare commits

...

6 Commits

Author SHA1 Message Date
R Midhun Suresh
e387ca3b57 Use disposables in base view model 2025-07-26 22:41:18 +05:30
R Midhun Suresh
de4b48b170 Write tests 2025-07-26 22:41:00 +05:30
R Midhun Suresh
4671200499 Introduce a way to track disposable values 2025-07-26 22:40:41 +05:30
R Midhun Suresh
787caf600a Write tests 2025-07-26 18:55:16 +05:30
R Midhun Suresh
b44ad4543b Create a base vm class:
- Implements callback aggregation for subscribe method
- Provides `emit` method for re-rendering the UI
2025-07-26 18:54:32 +05:30
R Midhun Suresh
23a2785223 Provide a new interface for generic vm 2025-07-26 18:28:17 +05:30
5 changed files with 283 additions and 0 deletions

View File

@@ -21,3 +21,20 @@ export interface ViewModel<T> {
*/
subscribe: (listener: () => void) => () => void;
}
/**
* The interface for a generic ViewModel passed to the shared components.
*/
export interface ViewModelNew {
/**
* Subscribes to changes in the view model.
* ViewModel will invoke the callback when the UI needs to be updated.
*/
subscribe: (callback: () => void) => () => void;
/**
* React uses the return value of this method to determine if it needs to
* re-render the component.
*/
getSnapshot: () => unknown;
}

View File

@@ -0,0 +1,57 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { ViewModelNew } from "../../shared-components/ViewModel";
import { Disposables } from "./Disposables";
export abstract class BaseViewModel<P> implements ViewModelNew {
/**
* We are relying on {@link https://react.dev/reference/react/useSyncExternalStore|useSyncExternalStore}
* to keep the react component in sync with view model.
* We only use this snapshot as way of convincing react that it should do a re-render when {@link emit} is called.
*/
private snapshot: unknown = {};
private callbacks: Set<() => void> = new Set();
protected disposables = new Disposables();
public constructor(protected props: P) {}
public getSnapshot(): unknown {
return this.snapshot;
}
public subscribe(callback: () => void): () => void {
this.callbacks.add(callback);
return () => {
this.callbacks.delete(callback);
};
}
/**
* Re-render any subscribed components
*/
protected emit(): void {
/**
* When we invoke the callbacks, react will check if the result of getSnapshot()
* matches the previously known snapshot value via Object.is().
* Since the intention of calling this method is to make the UI re-render, we want
* that comparison to fail.
* We can do that by assigning a new empty object to snapshot.
*/
this.snapshot = {};
for (const callback of this.callbacks) {
callback();
}
}
/**
* Relinquish any resources held by this view-model.
*/
public dispose(): void {
this.disposables.dispose();
}
}

View File

@@ -0,0 +1,69 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { EventEmitter } from "events";
/**
* Something that needs to be eventually disposed. This can be:
* - A function that does the disposing
* - An object containing a dispose method which does the disposing
*/
export type DisposableItem = { dispose: () => void } | (() => void);
/**
* This class provides a way for the view-model to track any resource
* that it needs to eventually relinquish.
*/
export class Disposables {
private readonly disposables: DisposableItem[] = [];
private _isDisposed: boolean = false;
/**
* Relinquish all tracked disposable values
*/
public dispose(): void {
this.throwIfDisposed();
this._isDisposed = true;
for (const disposable of this.disposables) {
if (typeof disposable === "function") {
disposable();
} else {
disposable.dispose();
}
}
}
/**
* Track a value that needs to be eventually relinquished
*/
public track<T extends DisposableItem>(disposable: T): T {
this.throwIfDisposed();
this.disposables.push(disposable);
return disposable;
}
/**
* Add an event listener that will be removed on dispose
*/
public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void {
emitter.on(event, callback);
this.track(() => {
emitter.off(event, callback);
});
}
private throwIfDisposed(): void {
if (this.isDisposed) throw new Error("Disposable is already disposed");
}
/**
* Whether this disposable has been disposed
*/
public get isDisposed(): boolean {
return this._isDisposed;
}
}

View File

@@ -0,0 +1,82 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel } from "../../../src/viewmodels/base/BaseViewModel";
interface Props {
initialTodos: string[];
}
/**
* BaseViewModel is an abstract class, so we'll have to test it with a dummy view model.
*/
class TodoViewModel extends BaseViewModel<Props> {
public todos: string[];
public constructor(props: Props) {
super(props);
this.todos = [...props.initialTodos];
}
public doEmit(): void {
this.emit();
}
}
describe("BaseViewModel", () => {
describe("Test integrity of getSnapshot()", () => {
it("Multiple calls to getSnapshot returns a cached value", () => {
// To be sure that we don't run into
// https://react.dev/reference/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached
const vm = new TodoViewModel({ initialTodos: ["todo1", "todo2"] });
const snapshot1 = vm.getSnapshot();
const snapshot2 = vm.getSnapshot();
expect(snapshot1).toBe(snapshot2);
});
it("emit() causes snapshot value to change", () => {
// When emit() is called, the snapshot should change in order for react to rerender any subscribed components.
const vm = new TodoViewModel({ initialTodos: ["todo1", "todo2"] });
const snapshot1 = vm.getSnapshot();
vm.doEmit();
const snapshot2 = vm.getSnapshot();
expect(snapshot1).not.toBe(snapshot2);
});
});
describe("Subscriptions", () => {
it("subscribe() returns unsubscribe callback", () => {
const vm = new TodoViewModel({ initialTodos: ["todo1", "todo2"] });
const result = vm.subscribe(jest.fn());
expect(typeof result).toBe("function");
});
it("emit() calls subscribe callbacks", () => {
const vm = new TodoViewModel({ initialTodos: ["todo1", "todo2"] });
const callbacks = [jest.fn(), jest.fn()];
callbacks.forEach((c) => vm.subscribe(c));
vm.doEmit();
callbacks.forEach((c) => expect(c).toHaveBeenCalledTimes(1));
});
it("Invoking unsubscribe callback returned from subscribe() removes subscription", () => {
const vm = new TodoViewModel({ initialTodos: ["todo1", "todo2"] });
const callback1 = jest.fn();
const callback2 = jest.fn();
const unsubscribe = vm.subscribe(callback1);
vm.subscribe(callback2);
unsubscribe();
vm.doEmit();
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,58 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import { Disposables } from "../../../src/viewmodels/base/Disposables";
describe("Disposable", () => {
it("isDisposed is true after dispose() is called", () => {
const disposables = new Disposables();
expect(disposables.isDisposed).toEqual(false);
disposables.dispose();
expect(disposables.isDisposed).toEqual(true);
});
it("dispose() calls the correct disposing function", () => {
const disposables = new Disposables();
const item1 = {
foo: 5,
dispose: jest.fn(),
};
disposables.track(item1);
const item2 = jest.fn();
disposables.track(item2);
disposables.dispose();
expect(item1.dispose).toHaveBeenCalledTimes(1);
expect(item2).toHaveBeenCalledTimes(1);
});
it("Throws error if acting on already disposed disposables", () => {
const disposables = new Disposables();
disposables.dispose();
expect(() => {
disposables.track(jest.fn);
}).toThrow();
});
it("Removes tracked event listeners on dispose", () => {
const disposables = new Disposables();
const emitter = new EventEmitter();
const fn = jest.fn();
disposables.trackListener(emitter, "FooEvent", fn);
emitter.emit("FooEvent");
expect(fn).toHaveBeenCalled();
disposables.dispose();
expect(emitter.listenerCount("FooEvent", fn)).toEqual(0);
});
});