Repository navigation
Stop sharing one listener handle between the stars and comments queries - #1875
rootkiller6788 wants to merge 3 commits into
Conversation
updateStars() watches posts/<id> and fetchComments() watches post-comments/<id>, but both wrote their DatabaseHandle into the same refHandle. Whichever ran last won, so onViewDisappear() and onDetailViewDisappear() were calling removeObserver(withHandle:) on a path that handle was never registered on - one of the two listeners always stayed attached. Give each listener its own handle and clear it after removing.
PostCell calls onViewAppear() from onAppear, which SwiftUI fires again every time the row scrolls back into view. Each call added another observer on posts/<id> without removing the old one, and the closures hold self, so those PostViewModels were never released either.
onViewDisappear() called removeAllObservers() on Database.root, but the observer actually lives on /posts or /user-posts/<uid>, so nothing was ever detached and the refHandle we kept was never read. Remember the reference we observed and remove the handle from that one.
There was a problem hiding this comment.
Code Review
This pull request refactors observer management in PostListViewModel and PostViewModel to prevent stacking duplicate listeners when views reappear, introducing specific handles (postHandle, commentsHandle, and observedRef) and cleanup logic. The review feedback highlights critical memory leak risks due to strong reference cycles in the observer closures of fetchComments(), updateStars(), and fetchPosts(). To address this, self should be captured weakly in these closures, and deinit methods should be implemented to ensure proper cleanup when the view models are deallocated.
| if let commentsHandle = commentsHandle { | ||
| commentRef.removeObserver(withHandle: commentsHandle) | ||
| } | ||
| commentsHandle = commentRef.observe(DataEventType.value, with: { snapshot in |
There was a problem hiding this comment.
To avoid a strong reference cycle (retain cycle) and memory leaks, capture self weakly in the observer closure. Since PostViewModel instances are recreated and discarded when the post list updates, any strong reference to self inside the Firebase observer closure will keep the discarded PostViewModel instances alive in memory indefinitely.
Additionally, you should implement a deinit method to ensure that any active observers are removed when the view model is deallocated:
deinit {
if let postHandle = postHandle {
ref.child("posts").child(id).removeObserver(withHandle: postHandle)
}
if let commentsHandle = commentsHandle {
ref.child("post-comments").child(id).removeObserver(withHandle: commentsHandle)
}
}| commentsHandle = commentRef.observe(DataEventType.value, with: { snapshot in | |
| commentsHandle = commentRef.observe(DataEventType.value, with: { [weak self] snapshot in | |
| guard let self = self else { return } |
| if let postHandle = postHandle { | ||
| postListRef.removeObserver(withHandle: postHandle) | ||
| } | ||
| postHandle = postListRef.observe(DataEventType.value, with: { snapshot in |
There was a problem hiding this comment.
Similarly to fetchComments(), capture self weakly here to prevent a strong reference cycle that leaks the PostViewModel instance when the post list is refreshed.
| postHandle = postListRef.observe(DataEventType.value, with: { snapshot in | |
| postHandle = postListRef.observe(DataEventType.value, with: { [weak self] snapshot in | |
| guard let self = self else { return } |
| // getPosts() runs on every onAppear, so don't stack up listeners | ||
| removeObserver() | ||
| // read data by listening for value events | ||
| observedRef = ref |
There was a problem hiding this comment.
In fetchPosts(from:for:), the observer closure on the next line strongly captures self (self.posts = ...). Since PostListViewModel holds refHandle and observedRef, this creates a strong reference cycle.
If the view model is deallocated or if the view disappears without onViewDisappear() being called, the view model and the observer will leak.
Please capture self weakly in the observer closure:
refHandle = ref.observe(DataEventType.value, with: { [weak self] snapshot in
guard let self = self else { return }
// ...
})And implement deinit to clean up the observer when the view model is deallocated:
deinit {
removeObserver()
}
The Realtime Database SwiftUI sample never actually detaches its listeners.
PostViewModelhas a singlerefHandle, but two listeners write to it:updateStars()watchesposts/<id>,fetchComments()watchespost-comments/<id>. Whichever runs last wins the field, so one of the tworemoveObserver(withHandle:)calls inonViewDisappear()/onDetailViewDisappear()is always given a handle that was never registered on that path. Following the normal flow: the row appears and the stars listener registers, tapping into the detail registers the comments listener and clobbers it, coming back outonViewDisappear()tries to drop the stars listener with the comments handle and does nothing. The stars listener stays onposts/<id>forever.That leaks more than a listener. Both observer closures capture
self, andPostListViewModel.fetchPostsbuilds a whole new batch ofPostViewModels on every/postsvalue event, so each write strands the previous batch. Same thing without ever opening the detail view:onAppearfires again every time a row scrolls back into view andupdateStars()just stacks another observer on the same path.So I gave each listener its own handle, and made
updateStars()/fetchComments()drop their previous listener before registering a new one.Last commit is the same bug one level up.
PostListViewModel.onViewDisappear()callsremoveAllObservers()onDatabase.root, but the observer lives on/postsor/user-posts/<uid>, andremoveAllObservers()only touches the node you call it on. Nothing was detached there either, and therefHandleit stored was never read. It now remembers the reference it observed and removes the handle from that one.I don't have Xcode on this machine, so this is from reading the listener bookkeeping rather than from a run - flag anything that doesn't line up and I'll dig in.