Introduce a hook to auto dispose view models (#31178)

* Create hook to auto dispose vm

* Write test

* Fix lint error

* Use proper license

* whatever -> whichever

* Remove eslint comments

* Add clarifying comment
This commit is contained in:
R Midhun Suresh 2025-12-05 12:18:50 +05:30 committed by GitHub
parent d62f4d4d49
commit f4e74c8dd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 115 additions and 0 deletions

View File

@ -0,0 +1,47 @@
/*
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 { 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);
});
});

View File

@ -0,0 +1,68 @@
/*
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 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 = useCreateAutoDisposedViewModel(() => new FooViewModel({prop1, prop2, ...});
*/
export function useCreateAutoDisposedViewModel<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 whichever react component called this hook gets re-rendered
* when this happens, hence the state.
*/
const [viewModel, setViewModel] = useState<B>(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();
};
/**
* We explicitly provide an empty dependency array as we don't expect the viewModel/viewCreator to
* change.
* Or to put it in another way, the only reason to use this hook is to create/dispose the view-model
* and that is something that should only happen at the start/end of the lifecycle of this component.
*/
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return viewModel;
}