Skip to content

Stop sharing one listener handle between the stars and comments queries - #1875

Open
rootkiller6788 wants to merge 3 commits into
firebase:mainfrom
rootkiller6788:fix-rtdb-swiftui-listener-leak
Open

rootkiller6788 wants to merge 3 commits into
firebase:mainfrom
rootkiller6788:fix-rtdb-swiftui-listener-leak

Conversation

@rootkiller6788

Copy link
Copy Markdown

The Realtime Database SwiftUI sample never actually detaches its listeners.

PostViewModel has a single refHandle, but two listeners write to it: updateStars() watches posts/<id>, fetchComments() watches post-comments/<id>. Whichever runs last wins the field, so one of the two removeObserver(withHandle:) calls in onViewDisappear() / 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 out onViewDisappear() tries to drop the stars listener with the comments handle and does nothing. The stars listener stays on posts/<id> forever.

That leaks more than a listener. Both observer closures capture self, and PostListViewModel.fetchPosts builds a whole new batch of PostViewModels on every /posts value event, so each write strands the previous batch. Same thing without ever opening the detail view: onAppear fires again every time a row scrolls back into view and updateStars() 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() calls removeAllObservers() on Database.root, but the observer lives on /posts or /user-posts/<uid>, and removeAllObservers() only touches the node you call it on. Nothing was detached there either, and the refHandle it 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.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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)
  }
}
Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similarly to fetchComments(), capture self weakly here to prevent a strong reference cycle that leaks the PostViewModel instance when the post list is refreshed.

Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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()
}

@rootkiller6788
rootkiller6788 marked this pull request as ready for review October 16, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant