Skip to content
Closed
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
5 changes: 3 additions & 2 deletions src/components/views/messages/MAudioBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX, useEffect, useMemo } from "react";
import React, { type JSX, useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type IContent } from "matrix-js-sdk/src/matrix";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
Expand All @@ -21,6 +21,7 @@ import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContex
import MediaProcessingError from "./shared/MediaProcessingError";
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerView";
import { useAutoDisposedViewModel } from "../../../viewmodels/base/useAutoDisposedViewModel";

interface IState {
error?: boolean;
Expand Down Expand Up @@ -132,7 +133,7 @@ interface AudioPlayerProps {
* AudioPlayer component that initializes the AudioPlayerViewModel and renders the AudioPlayerView.
*/
function AudioPlayer({ playback, mediaName }: AudioPlayerProps): JSX.Element {
const vm = useMemo(() => new AudioPlayerViewModel({ playback, mediaName }), [playback, mediaName]);
const vm = useAutoDisposedViewModel(() => new AudioPlayerViewModel({ playback, mediaName }));

useEffect(() => {
return () => {
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
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;
}
47 changes: 47 additions & 0 deletions test/viewmodels/base/useAutoDisposedViewModel-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
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 { renderHook } from "jest-matrix-react";

import { BaseViewModel } from "../../../src/viewmodels/base/BaseViewModel";
import { useAutoDisposedViewModel } from "../../../src/viewmodels/base/useAutoDisposedViewModel";

class TestViewModel extends BaseViewModel<{ count: number }, { initial: number }> {
constructor(props: { initial: number }) {
super(props, { count: props.initial });
}

public increment() {
const newCount = this.getSnapshot().count + 1;
this.snapshot.set({ count: newCount });
}
}

describe("useAutoDisposedViewModel", () => {
it("should return view-model", () => {
const vmCreator = () => new TestViewModel({ initial: 0 });
const { result } = renderHook(() => useAutoDisposedViewModel(vmCreator));
const vm = result.current;
expect(vm).toBeInstanceOf(TestViewModel);
expect(vm.isDisposed).toStrictEqual(false);
});

it("should dispose view-model on unmount", () => {
const vmCreator = () => new TestViewModel({ initial: 0 });
const { result, unmount } = renderHook(() => useAutoDisposedViewModel(vmCreator));
const vm = result.current;
vm.increment();
unmount();
expect(vm.isDisposed).toStrictEqual(true);
});

it("should recreate view-model on react strict mode", async () => {
const vmCreator = () => new TestViewModel({ initial: 0 });
const output = renderHook(() => useAutoDisposedViewModel(vmCreator), { reactStrictMode: true });
const vm = output.result.current;
expect(vm.isDisposed).toStrictEqual(false);
});
});
Loading