-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Introduce a hook to auto dispose view models #30549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
MidhunSureshR
wants to merge
13
commits into
develop
Choose a base branch
from
midhun/mvvm/state-management-4
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
4e42654
Introduce disposables to track sub vms and event listeners
MidhunSureshR 3bbb62d
Remove old code
MidhunSureshR 2cd2a4a
Use disposable in BaseViewModel
MidhunSureshR d86dcb1
Update vm so that the listener is tracked through disposable
MidhunSureshR 4caf52a
No-op on dispose call instead of throwing error
MidhunSureshR ce2f9ae
Throw error in trackListener as well
MidhunSureshR c3f1879
Fix audio player vm
MidhunSureshR 1927a4b
Expose isDisposed through base vm
MidhunSureshR c08e756
Dispose AudioPlayerViewModel
MidhunSureshR 1e449ae
Merge branch 'develop' into midhun/mvvm/state-management-2
MidhunSureshR 14af817
Add hook to auto dispose viewmodels
MidhunSureshR a0a0314
Use hook in TextualEventFactory
MidhunSureshR a76654f
Fix license
MidhunSureshR File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
|
||
/** | ||
* 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; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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}>
andvm?.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.