Skip to content

Leak hints, SwiftUI hosting and nested tracking (3.3) - #20

Open
DanielCech wants to merge 1 commit into
dc/feat/expect-deallocationfrom
dc/feat/leak-hints-swiftui
Open

DanielCech wants to merge 1 commit into
dc/feat/expect-deallocationfrom
dc/feat/leak-hints-swiftui

Conversation

@DanielCech

Copy link
Copy Markdown
Member

Stacked on #19, which is stacked on #18. Merge those first. GitHub will then retarget this PR to master.

Why

#19 made dealloc tests short and reported leaks at the right line. The next questions are why something leaked, and how to test the code most new screens are written in: SwiftUI.

What's new

Leak messages say what to look at

When an object leaks, DeallocTests now looks at its stored properties and lists the usual suspects. Here's the sample app's deliberate leak:

SecondViewController was not deallocated within 2 sec. Possible causes:
  • `someClosure` is a closure. Make sure it captures self weakly

It points out:

  • closures stored on the object (the most common leak),
  • Tasks, Combine subscriptions and timers,
  • reference cycles through properties, e.g. self.child.parent pointing back to the object.

If nothing suspicious is found, you get the general advice as before.

SwiftUI: .hosting

SwiftUI views are values, so the thing that leaks is the object behind them, usually the view model. .hosting shows the view in a test window, lets onAppear and .task run, then removes it:

await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) {
    ProfileViewModel(api: MockAPI())
}

The tests check both sides: work in .task is cancelled with the view and passes, while a Task started in onAppear and never cancelled is caught (and the hint names it). It works on iOS and macOS.

Checking owned objects too

Inside an expectDeallocation closure, trackForDeallocation adds objects to the same check. They must go away together with the tested object:

await expectDeallocation(.present) {
    let viewModel = trackForDeallocation(ProfileViewModel(api: MockAPI()))
    return ProfileViewController(viewModel: viewModel)
}

This works in Swift Testing and XCTest.

A deliberate design decision

I looked at automatically checking every object reachable from the tested one, as XCTAssertNoLeak does. I left it out on purpose. Swift's reflection (Mirror) can't tell a weak property from a strong one, so a screen's weak delegate (in the sample app, the coordinator) would be reported as a leak. False failures would make people distrust the tool. Instead:

  • the hints use reflection only to explain a leak that has already been confirmed, so they can't cause false failures,
  • owned objects are checked only when you opt in with trackForDeallocation.

The README says plainly that hints are suggestions, not proof.

Tests

  • macOS (swift test): all suites pass, including the new hint, SwiftUI and nested-tracking tests.
  • iOS simulator package tests: 31 Swift Testing tests pass, SwiftUI hosting included.
  • Sample app: the deliberate leak is reported with the someClosure hint, and the weak flowDelegate is correctly not flagged.

Not in this PR (4.0)

  • Replace the two products with a package trait for dependency injection.
  • Deprecate DeallocTester in favour of expectDeallocation.
  • Remove DefaultInitializable.

🤖 Generated with Claude Code

- Leak messages list likely causes from the leaked object's stored properties:
  closures, tasks, Combine subscriptions, timers and property cycles
- .hosting lifecycle shows a SwiftUI view in a test window (UIKit and AppKit)
- trackForDeallocation inside expectDeallocation joins its check
- Tests for hints, SwiftUI and nested tracking; README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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