diff --git a/packages/shared-components/src/viewmodel/tests/useCreateAutoDisposedViewModel.test.ts b/packages/shared-components/src/viewmodel/tests/useCreateAutoDisposedViewModel.test.ts new file mode 100644 index 00000000000..e3be1f9bd0e --- /dev/null +++ b/packages/shared-components/src/viewmodel/tests/useCreateAutoDisposedViewModel.test.ts @@ -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 "../BaseViewModel"; +import { useCreateAutoDisposedViewModel } from "../useCreateAutoDisposedViewModel"; + +class TestViewModel extends BaseViewModel<{ count: number }, { initial: number }> { + public constructor(props: { initial: number }) { + super(props, { count: props.initial }); + } + + public increment(): void { + const newCount = this.getSnapshot().count + 1; + this.snapshot.set({ count: newCount }); + } +} + +describe("useAutoDisposedViewModel", () => { + it("should return view-model", () => { + const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 }); + const { result } = renderHook(() => useCreateAutoDisposedViewModel(vmCreator)); + const vm = result.current; + expect(vm).toBeInstanceOf(TestViewModel); + expect(vm.isDisposed).toStrictEqual(false); + }); + + it("should dispose view-model on unmount", () => { + const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 }); + const { result, unmount } = renderHook(() => useCreateAutoDisposedViewModel(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 = (): TestViewModel => new TestViewModel({ initial: 0 }); + const output = renderHook(() => useCreateAutoDisposedViewModel(vmCreator), { reactStrictMode: true }); + const vm = output.result.current; + expect(vm.isDisposed).toStrictEqual(false); + }); +}); diff --git a/packages/shared-components/src/viewmodel/useCreateAutoDisposedViewModel.ts b/packages/shared-components/src/viewmodel/useCreateAutoDisposedViewModel.ts new file mode 100644 index 00000000000..8dabaa15851 --- /dev/null +++ b/packages/shared-components/src/viewmodel/useCreateAutoDisposedViewModel.ts @@ -0,0 +1,62 @@ +/* +Copyright 2025 Element Creations 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; + +/** + * 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 = useCreateAutoDisposedViewModel(() => new FooViewModel({prop1, prop2, ...}); + */ +export function useCreateAutoDisposedViewModel>(vmCreator: VmCreator): 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(vmCreator); + + /** + * 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; +}