Skip to content

Upgrade to fully modern stack: Xcode 16 / Swift 6 / iOS 17 / @Observable / Swift Testing #2

Description

@timfallmk

Context

The current branch (claude/modernize-legacy-repo-9Z7pQ) targets Xcode 14 / Swift 5.7 / iOS 16 because that was the newest Xcode available at time of writing. This issue tracks the remaining steps to reach the fully modern baseline: Xcode 16+ / Swift 6 / iOS 17+.

All changes are mechanical — no architectural work required. The code is already written correctly; this is mostly about unlocking native language features that were held back by the Xcode 14 constraint.


1. Bump Xcode and SDK versions

File: WikiMessage.xcodeproj/project.pbxproj

Setting Current Target
IPHONEOS_DEPLOYMENT_TARGET 16.0 17.0
SWIFT_VERSION 5.7 6.0
SWIFT_STRICT_CONCURRENCY targeted complete
LastUpgradeCheck 1400 1700 (or current)
LastSwiftUpdateCheck 1400 1700 (or current)
objectVersion 56 77 (or current Xcode value)
compatibilityVersion "Xcode 14.0" "Xcode 14.0" ← keep; this is the minimum Xcode to open the project, not the build Xcode

Make all of these changes to all four XCBuildConfiguration blocks (Debug/Release for each target) and the two test target configs.


2. Replace ObservableObject with @Observable

@Observable (the Observation framework, iOS 17+) is cleaner than ObservableObject + @Published: no @Published boilerplate, no @StateObject/@EnvironmentObject distinction, and strict concurrency works correctly out of the box.

AppModel.swift

// Before
import Combine
import Messages

final class AppModel: ObservableObject {
    @Published var presentationStyle: MSMessagesAppPresentationStyle = .compact
    @Published var composer: (any MessageComposer)?
    @Published var selectedArticleURL: URL?
}

// After
import Observation
import Messages

@Observable
final class AppModel {
    var presentationStyle: MSMessagesAppPresentationStyle = .compact
    var composer: (any MessageComposer)?
    var selectedArticleURL: URL?
}

SearchModel.swift

// Before
import Combine
final class SearchModel: ObservableObject {
    @Published var query: String = ""
    @Published private(set) var phase: SearchPhase = .idle
    @Published private(set) var recentSearches: [String] = RecentSearchesStore.load()
    ...
}

// After
import Observation
@Observable
final class SearchModel {
    var query: String = ""
    private(set) var phase: SearchPhase = .idle
    private(set) var recentSearches: [String] = RecentSearchesStore.load()
    ...
}

NetworkMonitor.swift

// Before
import Combine
import Network
final class NetworkMonitor: ObservableObject, @unchecked Sendable {
    @Published private(set) var isConnected: Bool = true
    ...
}

// After
import Network
import Observation
@Observable
final class NetworkMonitor: @unchecked Sendable {
    private(set) var isConnected: Bool = true
    ...
}

3. Update SwiftUI injection patterns

With @Observable, the environment and state APIs change:

RootView.swift

// Before
@StateObject private var searchModel = SearchModel()
@EnvironmentObject private var appModel: AppModel
// injected via: .environmentObject(searchModel)

// After
@State private var searchModel = SearchModel()
@Environment(AppModel.self) private var appModel
// injected via: .environment(searchModel)

SearchResultsList.swift

// Before
@EnvironmentObject private var searchModel: SearchModel
@EnvironmentObject private var appModel: AppModel

// After
@Environment(SearchModel.self) private var searchModel
@Environment(AppModel.self) private var appModel

MessagesViewController.swift

// Before
let root = RootView().environmentObject(appModel)

// After
let root = RootView().environment(appModel)

4. Restore ContentUnavailableView

ContentUnavailableView is iOS 17+ and was replaced with hand-rolled VStack views. Restore the native API:

LoadingView.swift

var body: some View {
    ContentUnavailableView {
        ProgressView().controlSize(.large)
    } description: {
        Text("Searching…").foregroundStyle(.secondary)
    }
}

EmptyResultsView.swift

var body: some View {
    ContentUnavailableView.search(text: query)
}

ErrorView.swift

var body: some View {
    ContentUnavailableView {
        Label("Something went wrong", systemImage: "exclamationmark.triangle")
    } description: {
        Text(error.localizedDescription)
    } actions: {
        Button("Try Again", action: retry).buttonStyle(.bordered)
    }
}

The idle state in SearchResultsList.swift can similarly be replaced:

case .idle:
    ContentUnavailableView(
        "Search Wikipedia",
        systemImage: "magnifyingglass",
        description: Text("Type to search for articles.")
    )

5. Switch tests to Swift Testing

Swift Testing (@Suite, @Test, #expect) is built into Xcode 16+ and is the modern replacement for XCTestCase. All four test files need updating.

Pattern (apply to all four files)

// Before (XCTest)
import XCTest
@testable import WikiMessage_MessagesExtension

final class SummaryDTOTests: XCTestCase {
    func testDecodesTitle() throws {
        let dto = try decoder.decode(SummaryDTO.self, from: fixture())
        XCTAssertEqual(dto.title, "Albert_Einstein")
        XCTAssertEqual(dto.displayTitle, "Albert Einstein")
    }
}

// After (Swift Testing)
import Testing
import Foundation
@testable import WikiMessage_MessagesExtension

@Suite("SummaryDTO decoding")
struct SummaryDTOTests {
    @Test func decodesTitle() throws {
        let dto = try decoder.decode(SummaryDTO.self, from: fixture())
        #expect(dto.title == "Albert_Einstein")
        #expect(dto.displayTitle == "Albert Einstein")
    }
}

XCTAssertEqual(a, b) → #expect(a == b)
XCTAssertTrue(x) → #expect(x)
XCTAssertNil(x) → #expect(x == nil)
XCTAssertNotNil(x) → #expect(x != nil)
XCTFail("msg") → Issue.record("msg")
XCTAssertThrowsError → await #expect(throws: SomeError.self) { ... }

Also update WikipediaServiceTests to use #expect(throws:) for the error propagation test:

@Test func propagatesHTTPErrors() async throws {
    MockURLProtocol.handler = { _ in MockURLProtocol.response(statusCode: 404, data: Data(), url: ...) }
    await #expect(throws: HTTPClient.HTTPError.self) {
        _ = try await makeService().search(query: "anything")
    }
}

6. Update CI

File: .github/workflows/ci.yml

# Before
runs-on: macos-13
xcode-version: "14"
destination: "platform=iOS Simulator,name=iPhone 14,OS=latest"

# After
runs-on: macos-latest
xcode-version: latest-stable
destination: "platform=iOS Simulator,name=iPhone 16,OS=latest"

7. Notes and gotchas

  • @retroactive on extension URL: @retroactive Identifiable is still valid and required in Swift 6 — no change needed.
  • SWIFT_STRICT_CONCURRENCY = complete will surface any remaining actor-isolation warnings. Most will be in MessagesViewController (a UIViewController subclass whose lifecycle methods are implicitly @MainActor in Xcode 16 but may need explicit annotation). Expect to add a handful of @MainActor annotations.
  • actor WikipediaService and actor HTTPClient are already Swift 6 correct — no changes needed there.
  • MessageComposer protocol conformance (LiveMessageComposer: MessageComposer) may need @MainActor adjustments once strict concurrency is on — check the insert(_:) call site.
  • The @Observable macro requires import Observation, not import Combine. Remove the Combine imports from the model files.
  • Fixture JSON loading in tests: Bundle(for: type(of: self)) is an XCTestCase pattern. With Swift Testing, use Bundle.module if the tests are in a Swift package, or Bundle(for: MockURLProtocol.self) (class reference, works in both).

Acceptance criteria

  • Project compiles clean with zero errors and zero warnings under Xcode 16+, Swift 6 strict concurrency
  • All WikiMessageExtensionTests tests pass using Swift Testing
  • CI passes on macos-latest with xcode-version: latest-stable
  • Extension runs on an iOS 17+ simulator: search returns results, tapping a result inserts a message
  • No force-unwraps or try! introduced

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions