Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/components/views/messages/MAudioBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>

public componentWillUnmount(): void {
this.state.playback?.destroy();
this.state.audioPlayerVm?.dispose();
}

protected get showFileBody(): boolean {
Expand Down
10 changes: 8 additions & 2 deletions src/events/EventTileFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import ModuleApi from "../modules/Api";
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
import { TextualEventView } from "../shared-components/event-tiles/TextualEventView";
import { ElementCallEventType } from "../call-types";
import { useAutoDisposedViewModel } from "../viewmodels/base/useAutoDisposedViewModel";

// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
Expand Down Expand Up @@ -79,10 +80,15 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
<LegacyCallEvent ref={ref} {...props} />
);
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => {
const vm = new TextualEventViewModel(props);

const TextualEventComponent: React.FC<FactoryProps> = (props) => {
const vm = useAutoDisposedViewModel(() => new TextualEventViewModel(props));
return <TextualEventView vm={vm} />;
};
export const TextualEventFactory: Factory = (ref, props) => {
return <TextualEventComponent {...props} />;
};

const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;

Expand Down
12 changes: 3 additions & 9 deletions src/viewmodels/audio/AudioPlayerViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export class AudioPlayerViewModel

public constructor(props: Props) {
super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName));
this.disposables.trackListener(props.playback, UPDATE_EVENT, this.setSnapshot);
// There is no unsubscribe method in SimpleObservable
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);

// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
Expand All @@ -97,15 +100,6 @@ export class AudioPlayerViewModel
}
}

protected addDownstreamSubscription(): void {
this.props.playback.on(UPDATE_EVENT, this.setSnapshot);
// There is no unsubscribe method in SimpleObservable
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
}
protected removeDownstreamSubscription(): void {
this.props.playback.off(UPDATE_EVENT, this.setSnapshot);
}

/**
* Sets the snapshot and emits an update to subscribers.
*/
Expand Down
42 changes: 14 additions & 28 deletions src/viewmodels/base/BaseViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@ Please see LICENSE files in the repository root for full details.
*/

import { type ViewModel } from "../../shared-components/ViewModel";
import { Disposables } from "./Disposables";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";

export abstract class BaseViewModel<T, P> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected snapshot: Snapshot<T>;
protected props: P;
protected disposables = new Disposables();

protected constructor(props: P, initialSnapshot: T) {
this.props = props;
this.subs = new ViewModelSubscriptions(
this.addDownstreamSubscriptionWrapper,
this.removeDownstreamSubscriptionWrapper,
);
this.subs = new ViewModelSubscriptions();
this.snapshot = new Snapshot(initialSnapshot, () => {
this.subs.emit();
});
Expand All @@ -30,36 +29,23 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
};

/**
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
* has a bound `this` context.
*/
private addDownstreamSubscriptionWrapper = (): void => {
this.addDownstreamSubscription();
};

/**
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
* in the constructor.
* Returns the current snapshot of the view model.
*/
private removeDownstreamSubscriptionWrapper = (): void => {
this.removeDownstreamSubscription();
public getSnapshot = (): T => {
return this.snapshot.current;
};

/**
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
* to call this.subs.emit() when the snapshot changes.
*/
protected abstract addDownstreamSubscription(): void;

/**
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
* Relinquish any resources held by this view-model.
*/
protected abstract removeDownstreamSubscription(): void;
public dispose(): void {
this.disposables.dispose();
}

/**
* Returns the current snapshot of the view model.
* Whether this view-model has been disposed.
*/
public getSnapshot = (): T => {
return this.snapshot.current;
};
public get isDisposed(): boolean {
return this.disposables.isDisposed;
}
}
70 changes: 70 additions & 0 deletions src/viewmodels/base/Disposables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
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 {
if (this.isDisposed) return;
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 {
this.throwIfDisposed();
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;
}
}
18 changes: 1 addition & 17 deletions src/viewmodels/base/ViewModelSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,20 @@ Please see LICENSE files in the repository root for full details.
*/

/**
* Utility class for view models to manage suscriptions to their updates
* Utility class for view models to manage subscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();

/**
* @param subscribeCallback Called when the first listener subscribes.
* @param unsubscribeCallback Called when the last listener unsubscribes.
*/
public constructor(
private subscribeCallback: () => void,
private unsubscribeCallback: () => void,
) {}

/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
* @returns A function to unsubscribe from the view model updates.
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
if (this.listeners.size === 1) {
this.subscribeCallback();
}

return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.unsubscribeCallback();
}
};
};

Expand Down
62 changes: 62 additions & 0 deletions src/viewmodels/base/useAutoDisposedViewModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
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 { useEffect, useState } from "react";

import type { BaseViewModel } from "./BaseViewModel";

type VmCreator<B extends BaseViewModel<unknown, unknown>> = () => B;

/**
* Instantiate a view-model that gets disposed when the calling react component unmounts.
* In other words, this hook ties the lifecycle of a view-model to the lifecycle of a
* react component.
*
* @param vmCreator A function that returns a view-model instance
* @returns view-model instance from vmCreator
* @example
* const vm = useAutoDisposedViewModel(() => new FooViewModel({prop1, prop2, ...});
*/
export function useAutoDisposedViewModel<B extends BaseViewModel<unknown, unknown>>(vmCreator: VmCreator<B>): B {
/**
* The view-model instance may be replaced by a different instance in some scenarios.
* We want to be sure that whatever react component called this hook gets re-rendered
* when this happens, hence the state.
*/
const [viewModel, setViewModel] = useState<B>(vmCreator);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're okay with the view-model returned from this hook being undefined, we could just delay creating the vm to the effect body below. But we would have to always vm && <FooView vm={vm}> and vm?.doSomething().

It would also mean that all views would require an additional render (effect body always runs after render so the vm is only created after one render) before they are mounted. Also for view-models that immediately start doing async operations on instantiation, they would be delayed by one render cycle. But I doubt any of these would have actual performance implications.

I personally favor this approach because it feels equivalent to just calling the vm ctor.


/**
* Our intention here is to ensure that the dispose method of the view-model gets called
* when the component that uses this hook unmounts.
* We can do that by combining a useEffect cleanup with an empty dependency array.
*/
useEffect(() => {
let toDispose = viewModel;

/**
* Because we use react strict mode, react will run our effects twice in dev mode to make
* sure that they are pure.
* This presents a complication - the vm instance that we created in our state initializer
* will get disposed on the first cleanup.
* So we'll recreate the view-model if it's already disposed.
*/
if (viewModel.isDisposed) {
const newViewModel = vmCreator();
// Change toDispose so that we don't end up disposing the already disposed vm.
toDispose = newViewModel;
setViewModel(newViewModel);
}
return () => {
// Dispose the view-model when this component unmounts
toDispose.dispose();
};

// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return viewModel;
}
9 changes: 1 addition & 8 deletions src/viewmodels/event-tiles/TextualEventViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,11 @@ export class TextualEventViewModel extends BaseViewModel<TextualEventViewSnapsho
public constructor(props: EventTileTypeProps) {
super(props, { content: "" });
this.setTextFromEvent();
this.disposables.trackListener(this.props.mxEvent, MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
}

private setTextFromEvent = (): void => {
const content = textForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), true, this.props.showHiddenEvents);
this.snapshot.set({ content });
};

protected addDownstreamSubscription = (): void => {
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};

protected removeDownstreamSubscription = (): void => {
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
}
57 changes: 57 additions & 0 deletions test/viewmodels/base/Disposables-test.ts
Original file line number Diff line number Diff line change
@@ -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 { 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);
});
});
Loading
Loading