[Spike][DNM] Broadly isolate Epoxy to the main actor - #185
[Spike][DNM] Broadly isolate Epoxy to the main actor#185andreacipriani wants to merge 27 commits into
Conversation
Follow-up to airbnb#183: annotate the StyledView / ContentConfigurableView / BehaviorsConfigurableView view protocols as @preconcurrency @mainactor, and promote the model-builder DSL entry points (itemModel / barModel / supplementaryItemModel / groupItem / swiftUIView) from nonisolated to @preconcurrency @mainactor. Their deferred view-construction and configuration closures bridge to the main actor via MainActor.assumeIsolated. This lets consumers building under strict concurrency / the Swift 6 language mode use these factories from main-actor contexts without warnings, while @preconcurrency keeps existing call sites compiling.
Annotate the PresentationModel / NavigationModel view-construction and lifecycle closures (makeViewController / makePresentable / dismiss / remove / didPresent / didDismiss / didShow / didHide / didAdd / didRemove) and their backing PresentationQueue / NavigationQueue as @mainactor. These closures only ever run on the main thread — they construct and configure UIViewControllers, driven from the already-main-actor entry points UIViewController.setPresentation(_:animated:) and NavigationController.setStack(_:animated:) — so this lets consumers building under strict concurrency / the Swift 6 language mode present and navigate from @mainactor contexts without warnings or MainActor.assumeIsolated. The Diffable conformance and model identity/equality remain nonisolated so model diffing can continue off the main thread.
NavigationQueue and PresentationQueue are @mainactor, but Quick's spec() stores its beforeEach/it/afterEach closures as nonisolated @escaping closures that it invokes later, so those closures can't call into the queues directly (unlike UIKit-backed types, whose @mainactor is imported @preconcurrency and only warns). Add a shared MainActorSpec opt-in protocol: Quick runs every example on the main thread via XCTest, so it shadows the global Quick DSL with @mainactor variants that bridge through MainActor.assumeIsolated. NavigationQueueSpec and PresentationQueueSpec opt in by conforming. Keeps the queues as plain @mainactor (no @preconcurrency) and needs no production or Package.swift changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enable package-wide default main-actor isolation (swift-tools 6.2 + .defaultIsolation(MainActor.self)) across all library targets, building under the Swift 6 language mode, to test whether the concurrency compiler can be satisfied with @mainactor. Library changes required (all mechanical): - ~24 isolated-conformance annotations (': @mainactor SomeProtocol') - 1 isolated deinit (Token in BarInstaller) - 1 missing 'import EpoxyCore' (was a warning, now an error under Swift 6) - Make Constrainable identity (dataID / isEqual(to:)) nonisolated so the HGroup/VGroup ItemAlignment value enums can stay nonisolated data Result: all 7 modules + the EpoxyExample app build green. Test-target migration to the MainActorSpec bridge is deliberately left as follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make EpoxyTests compile and pass (286 tests) against the now main-actor library, without any nonisolated(unsafe): - Conform every QuickSpec that drives the main-actor API to the existing MainActorSpec bridge (its DSL takes @mainactor closures and hops on via MainActor.assumeIsolated). BaseBarInstallerSpec refines MainActorSpec. - Annotate XCTestCase-based tests and test-only helper types that model main-actor library protocols (EpoxyModeledSpec's providers/TestModel, CollectionViewSpec mocks) with @mainactor. - Move shared spec setup that builds main-actor values into beforeEach. - Keep the test targets on the Swift 5 language mode: Quick specs share non-Sendable state through captured vars across the bridged closures, which Swift 6's region-based sending analysis rejects even though the state never leaves the main thread. Actor-isolation is still enforced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With package-wide default main-actor isolation, the @preconcurrency escape hatch on the core protocols (StyledView, ContentConfigurableView, BehaviorsConfigurableView) and the model factory methods is no longer needed — we're committing to Swift concurrency, so callers should see the full @mainactor requirement. Dropping it exposed two test call sites that built models via the (now strictly main-actor) factories outside a bridged closure; moved those into beforeEach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI is pinned to Xcode 16.4 (Swift 6.1), which doesn't support the swift-tools-version 6.2 '.defaultIsolation(MainActor.self)' package setting. Lower the manifest to tools 6.0 and drive default main-actor isolation through the '-default-isolation MainActor' frontend flag via .unsafeFlags instead, so the package resolves on Swift 6.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An isolated deinit is only available in iOS 18.4+, but Epoxy deploys to iOS 13, so the podspec build failed. Make Token nonisolated (its deinit is then not main-actor isolated) and hop to the main actor to run the main-actor dispose.
The -default-isolation flag requires a Swift 6.2 toolchain; Xcode 16.4 rejects it. macos-15 provides Xcode 26.3 and the iPhone 16 simulators the suite uses.
…ailure - ConfigurePodspec.rb: build the pods in Swift 6 with the same -default-isolation and InferIsolatedConformances flags as SwiftPM, so CocoaPods consumers get the same isolation and pod lib lint stops warning on main-actor conformances. - Rakefile: on xcodebuild failure, print the raw log tail (xcbeautify hides the crash reason for test-package). TEMPORARY debug aid; revert once diagnosed.
- MainActorSpec: Quick doesn't guarantee example closures run on the main thread, and MainActor.assumeIsolated traps with SIGABRT off-main (crashed BarStackViewSpec on CI's iOS 26.2). Hop to the main thread explicitly before assuming isolation. - Rakefile: run pod lib lint --verbose to surface the underlying xcodebuild error.
Quick 4 ran example closures off the main thread under whole-package main-actor isolation, crashing (SIGABRT) on CI's iOS 26.2 sim inside the assumeIsolated bridge. Quick 7 runs examples on the main actor (its DSL closures are @mainactor), so: - Bump Quick to 7.6.2 and Nimble to 14.0.0. - Delete the MainActorSpec assumeIsolated bridge; specs call @mainactor code directly. - spec() is now a static 'class func spec()' (Quick 7). Convert all specs, and make the shared BaseBarInstallerSpec helpers + CollectionViewSpec mocks static. - Nimble 14: use equal(...) instead of the identity matcher be(...) for value checks.
…e 26.5 - Pin airbnb/swift to 1.0.3 so the SwiftFormat version stays 0.51-beta-6; the Quick/Nimble bump had let it drift to 1.2.0 (SwiftFormat nightly), which reformatted ~170 unrelated files and broke lint-swift. - Run test-package on macos-26 / Xcode 26.5 (iOS 26.5 simulator): the BarStackViewSpec SIGABRT reproduces only on the iOS 26.2 simulator (Xcode 26.3), not on 26.5, where the full suite passes locally.
macos-26 ships iPhone 17-family simulators, not iPhone 16, so the test destination must name an available device.
The Release optimizer (-Os) crashes swiftc 6.3 in the SIL EarlyPerfInliner pass while compiling EpoxySwiftUIHostingView's synthesized main-actor deinit — a compiler bug (it prints 'Please submit a bug report'), not a source error; SPM builds -Onone and is unaffected. Lint the pod in Debug. Also drop the temporary raw-log debug now that test-package is diagnosed and green.
The main-actor migration makes the .system PresentationModel NotificationCenter closure infer @sendable, producing non-Sendable-capture warnings. The code compiles (SPM builds don't fail on warnings); allow warnings for the CocoaPods lint too.
bachand
left a comment
There was a problem hiding this comment.
This is great @andreacipriani. 🙌 Thank you!
It's very helpful to see where we're going as we work to get there incrementally. 👍
|
|
||
| deinit { | ||
| dispose() | ||
| Task { @MainActor [dispose] in dispose() } |
There was a problem hiding this comment.
I wonder if introducing the asynchronous invocation may affect the behavior 🤔 Do we have any other options? I suspect we are somewhat limited, given the above comment.
There was a problem hiding this comment.
Yes, I added this in the PR description as one the points to further investigate, since I agree that it changes the behavior. I didn't find a great solution unless we bump the min iOS version of the library.
- isolated deinit needs iOS 18.4+ but Epoxy targets iOS 13.
- nonisolated deinit with an assume isolated is risky as the object could be disposed off main
For broader context, would bumping the iOS min version a viable solution here?
There was a problem hiding this comment.
I think it's fine for us to bump the min iOS version here - my understanding is that we may be introducing incompatible changes anyways.
There was a problem hiding this comment.
ok, updating the PR to bumb the iOS version
| /// - SeeAlso: `BottomBarInstaller` | ||
| /// - SeeAlso: `TopBarInstaller` | ||
| public struct BarModel<View: UIView>: ViewEpoxyModeled { | ||
| public struct BarModel<View: UIView>: @MainActor |
| let mainActorIsolation: [SwiftSetting] = [ | ||
| .unsafeFlags(["-default-isolation", "MainActor"]), | ||
| .enableUpcomingFeature("InferIsolatedConformances"), | ||
| ] |
There was a problem hiding this comment.
Asking to learn: are these flags necessary given the annotations we added? Or now that we've added the annotations, would we remove these?
I know that this is a spike PR that we don't intend to merge, so either way is reasonable — I am asking only to build my understanding. 👍
There was a problem hiding this comment.
I think it's okay for us to raise the minimum toolchain as well as the min iOS target.
My understanding is that "assume main actor" is just saving us on lots of @MainActor annotations, which I think can be preferable to keep the code simpler.
Meanwhile InferIsolatedConformances is necessary to solve annoyances with isolated types that conform to Equatable and Decodable, which are nonisolated. I think we'd need to keep it. There may be other workarounds I haven't understood.
There was a problem hiding this comment.
Agree, it's easier to set the default isolation to main: I followed up and removed 40+ redundant standalone MainActor tags. We still need to annotate types (like closures) and isolated conformances since the default isolation doesn't reach there.
| var lastBaselineAnchor: NSLayoutYAxisAnchor { get } | ||
| /// unique identifier for this constrainable | ||
| var dataID: AnyHashable { get } | ||
| nonisolated var dataID: AnyHashable { get } |
There was a problem hiding this comment.
Coming back to the discussion in #183 (comment) and below, does the fact that this needs to be nonisolated indicate that Epoxy is doing work off the main queue?
There was a problem hiding this comment.
I don't think diffing is done by Epoxy off the main thread, I don't see any proof of that.
I don't have broader context to know if this was a design decision to keep diffing nonisolated and give consumers the possibility of doing that outside the main thread. For example, if a consumer wants to diff a large list off main, they can do it today as the nonisolated is flexible to support both ways (main/bg).
But if you all tell me this is a no-goal, I can change this and isolate it to the main actor.
There was a problem hiding this comment.
I think i initially made these nonisolated because it had fewer downstream issues in our codebase (there were so many new warnings/error I couldn't really follow what was happening), but I've come around that we should just make everything MainActor if it simplifies thing.
We should probably test out the impact of changing this against our codebase - would that help us find any off-main callers to these types, or would we have a lot of callers that aren't properly annotated?
There was a problem hiding this comment.
After trying this out, I've changed my mind and think we should keep diffing nonisolated.
Isolating the example app to the main actor was harder than expect. With a Equatable or Hashable isolated conformance to main actor, every comparison must also be on the main actor. Many happen in nonisolated contexts you don't control, for example the compiler synthesized == hash, and Set/Dictionary.
One example was AlignableTextRow.Style
struct Style: Hashable {
var titleAlignment: VGroup.ItemAlignment
}If ItemAlignment conformance is main actor isolated, the synthesized hash (nonisolated) can't use it 🙈
Of course it compiles if Style becomes @mainactor too, but that cascades to every type holding an alignment. The only way I got the example to build was pushing default main actor isolation through the whole app, which seems exactly the adoption cost we don't want to impose on customers and ourselves.
My final proposal is to keep Diffable and the Equatable/Hashable conformances (e.g. ItemAlignment) nonisolated. Epoxy will still only diff on the main actor, but the value types stay usable from nonisolated code.
Happy to document on the code if we're aligned.
There was a problem hiding this comment.
Thanks for investigating with an example app, I think that brings the clarity we need.
There was a problem hiding this comment.
That sounds good to me @andreacipriani. Thanks for looking into this!
| @@ -35,8 +35,8 @@ public struct NavigationModel { | |||
| public init<Params: Equatable>( | |||
There was a problem hiding this comment.
Coming back to https://github.com/airbnb/epoxy-ios/pull/184/changes#r3546985071, could we see if we can remove the assumeIsolated(…) calls in this spike?
There was a problem hiding this comment.
absolutely, removed them all
Now that the package is fully main-actor, the only remaining assumeIsolated bridges were in the model factories, because the stored closure typealiases were still nonisolated function *types* (-default-isolation doesn't apply to type annotations). Annotate MakeView/SetContent/SetBehaviors (+ the WillDisplay/DidDisplay/DidEndDisplaying/ DidSelect/DidChangeState callbacks) and SwiftUIView's makeContent/Configuration as @mainactor, add a @mainactor EpoxyModelProperty.chain() overload, and drop all 42 assumeIsolated calls across the 5 factories. This is the EpoxyCore model-layer isolation that airbnb#183 deferred; it answers the review question on airbnb#185.
Per review feedback, the incremental Swift Concurrency work shouldn't carry unrelated style churn. The churn came from airbnb/swift 1.0.3 (SwiftFormat 0.51-beta-6) enforcing redundantOptionalBinding/redundantSelf, which master's files don't follow. Pin airbnb/swift to 1.0.1 (SwiftFormat 0.50-beta-2, matching master) and restore the formatting-only files to their base state, leaving a concurrency-focused diff.
Xcode 26.3 rejects two chain<A>() overloads differing only by @mainactor on the return closure as an invalid redeclaration (26.5 allowed it). Keep the original nonisolated chain<A>() and add a distinctly-named chainMainActor<A>() for the main-actor callback properties; revert the arity-1 test to the nonisolated closure.
With default main-actor isolation, explicit @mainactor on declarations (funcs, types, members) is redundant — the compiler already isolates them. Remove the 40 declaration-level annotations, keeping only what's genuinely required: @mainactor on function *types* (closures/typealiases/stored props, which default isolation doesn't reach) and the isolated-conformance annotations the compiler still demands (e.g. EpoxySwiftUIHostingView, Constrainable conformers). 132 -> 92 annotations.
Per review, the spike should show only Swift-concurrency differences. Disable the lint-swift and lint-podspec CI jobs (they only forced non-concurrency formatting and the CocoaPods swiftc-optimizer-crash workarounds), and revert the airbnb/swift formatter pin, ConfigurePodspec, and Rakefile to master. Keeps the build/test CI (iOS/tvOS/macOS + unit/perf) plus the concurrency source changes.
…aque generics) Earlier 0.51-beta-6 format runs had baked non-concurrency style changes into the concurrency-touched files (if let x = x -> if let x; <View: UIView> -> some UIView). Revert those so the diff shows only Swift-concurrency changes.
Now that the spike can raise the floors:
- swift-tools-version 6.2 + .defaultIsolation(MainActor.self) instead of the
-default-isolation raw flag via .unsafeFlags — the sanctioned API, and it lets the
package be consumed as a normal SPM dependency (.unsafeFlags forbids that).
- Min iOS 18.4, so the observer Token uses a synchronous isolated deinit instead of
hopping to the main actor via Task { @mainactor } — removes the async-disposal
behavior change. Example app deployment target bumped to match.
| - name: Test Package | ||
| run: bundle exec rake test:package | ||
|
|
||
| lint-swift: |
There was a problem hiding this comment.
removed from the spike to avoid including unnecessary stylistic changes
| GCC_WARN_UNUSED_FUNCTION = YES; | ||
| GCC_WARN_UNUSED_VARIABLE = YES; | ||
| IPHONEOS_DEPLOYMENT_TARGET = 14.1; | ||
| IPHONEOS_DEPLOYMENT_TARGET = 18.4; |
There was a problem hiding this comment.
bumped for the spike to allow for isolated deinit. Might not be necessary on real PR
Push the spike all the way: drop the nonisolated carve-outs on Constrainable (dataID/isEqual) and the HGroup/VGroup ItemAlignment enums so the identity/diffing layer is main-actor like everything else. The package now has zero nonisolated, zero @preconcurrency, and zero assumeIsolated. Trade-off: diffing is now locked to the main actor — a consumer can no longer diff off-main (compute off / apply on).
| dataID: dataID, | ||
| params: style, | ||
| content: content, | ||
| makeView: { style in MainActor.assumeIsolated { Self(style: style) } }, |
|
|
||
| private var setContentProperty: EpoxyModelProperty<SetContent?> { | ||
| .init(keyPath: \Self.setContent, defaultValue: nil, updateStrategy: .chain()) | ||
| .init(keyPath: \Self.setContent, defaultValue: nil, updateStrategy: .chainMainActor()) |
There was a problem hiding this comment.
Is it possible to keep the previous signature and update chain() to be bound to the main actor?
Spike: broadly isolate Epoxy to the main actor
Open points:
.defaultIsolation(MainActor.self)lint-podspec --configuration=Debugbreaks the build--allow-warningshides possible data races, check if we can removeTokendeinit is now async instead of sync